diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml
index af678bc..612590f 100644
--- a/.forgejo/workflows/ci.yaml
+++ b/.forgejo/workflows/ci.yaml
@@ -37,6 +37,29 @@ 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 500-character limit,
+ # and the porcelain check below catches a version with no committed
+ # changelog, which would otherwise ship the CHANGELOG.md section instead
+ # of a hand-written summary.
+ - name: Changelog fits the stores, 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: no committed What's New for this version." >&2
+ echo "Write fastlane/metadata/android//changelogs/.txt" >&2
+ echo "(under 500 chars, every shipped locale) and commit it." >&2
+ exit 1
+ fi
+
+ # The fastlane tree feeds F-Droid and Play alike; Play rejects oversized
+ # text and off-spec graphics at upload time, so catch that on the PR.
+ - name: Store listing fits both stores
+ run: python3 scripts/check_store_listing.py
+
# 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`.
@@ -47,7 +70,7 @@ jobs:
# about defaults to building. Only paths the Gradle build provably
# never reads belong here — note that the workflows themselves, the
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
- SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
+ SKIP_RE: '(\.md$|^docs/|^fastlane/|^fdroid-metadata/|^Gemfile$|^design/|^\.(forgejo|gitea)/ISSUE_TEMPLATE/|^\.editorconfig$|^\.gitattributes$|^\.gitignore$|^LICENSE$)'
run: |
set -e
BASE="${{ github.base_ref }}"
@@ -141,9 +164,12 @@ jobs:
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
+ # :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
+ # that task would compile the vendored suite and run none of it, which is
+ # the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests
if: steps.scope.outputs.code == 'true'
- run: ./gradlew testDebugUnitTest
+ run: ./gradlew testDebugUnitTest :dav:test :caldav:test
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml
index 38d729b..d6555a6 100644
--- a/.gitea/workflows/release.yaml
+++ b/.gitea/workflows/release.yaml
@@ -1,4 +1,4 @@
-name: Release — F-Droid repo + Gitea/Codeberg release
+name: Release — F-Droid repo + Gitea/Codeberg release + Play
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
@@ -9,6 +9,11 @@ name: Release — F-Droid repo + Gitea/Codeberg release
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
+# A trailing `play` job then uploads the App Bundle to Google Play. It is last
+# and separate because Play can reject a good build for reasons the pipeline
+# can't see, and that must not endanger a release which already shipped to
+# F-Droid and Codeberg. It skips cleanly until PLAY_SERVICE_ACCOUNT_JSON exists.
+#
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
# credentials) lives on the self-hosted Gitea instance, and this is the only
@@ -110,6 +115,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 the stores
+ 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).
@@ -501,3 +516,136 @@ jobs:
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."
+
+ # Play takes an App Bundle, not the APK: a second artifact from the same
+ # source and signing config. Play treats the release key only as the
+ # upload key and re-signs with its own (Play App Signing), so Play and
+ # F-Droid installs carry different signatures and can't update each other.
+ #
+ # Built last and continue-on-error: everything above has already shipped,
+ # and nothing Play-related may take it down. The AAB never touches the
+ # F-Droid repo or the releases. AGP embeds the R8 mapping in the bundle,
+ # so Play gets deobfuscated stacktraces without a separate upload.
+ - name: Build release AAB
+ if: env.IS_RELEASE == 'true'
+ continue-on-error: true
+ run: ./gradlew bundleRelease
+
+ # NOT actions/upload-artifact@v4: its client refuses any non-github.com
+ # server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
+ # check. Pinned to a commit — a third-party action in the signing
+ # pipeline must not change under us.
+ - name: Hand the AAB to the Play job
+ if: env.IS_RELEASE == 'true'
+ continue-on-error: true
+ uses: https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7 # v4
+ with:
+ name: release-aab-${{ needs.detect.outputs.version }}
+ path: app/build/outputs/bundle/release/app-release.aab
+ if-no-files-found: error
+ retention-days: 14
+
+ # Google Play channel. A separate job after the F-Droid publish and both forge
+ # releases, so a Play rejection (policy review, API outage, listing rules)
+ # shows up as one red job next to a release that already shipped.
+ #
+ # Not a `container:` job: act_runner provides no node inside custom job
+ # containers, so JavaScript actions (checkout, download-artifact) can't run.
+ play:
+ needs: [detect, release]
+ # workflow_dispatch is the F-Droid re-sign recovery path; never touch Play.
+ if: needs.detect.outputs.is_release == 'true'
+ runs-on: docker
+ env:
+ VERSION: ${{ needs.detect.outputs.version }}
+ VERSION_CODE: ${{ needs.detect.outputs.version_code }}
+ # The release itself is the gate (a bumped versionName only reaches main
+ # after on-device review), so it goes straight to production. Override
+ # with repo variables to stage instead.
+ PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
+ PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
+ # true validates the edit against the API and discards it.
+ PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
+ BUNDLE_PATH: vendor/bundle
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ # Skip cleanly when Play isn't configured yet, same contract as the
+ # Codeberg publish.
+ - name: Write the Play service-account key
+ id: key
+ env:
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
+ run: |
+ set -euo pipefail
+ if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then
+ echo "PLAY_SERVICE_ACCOUNT_JSON not set — skipping the Play upload."
+ echo "configured=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
+ python3 -c "import json,sys; d=json.load(open('play-service-account.json')); sys.exit(0 if d.get('type')=='service_account' else 1)" \
+ || { echo "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
+ echo "configured=true" >> "$GITHUB_OUTPUT"
+
+ # Same GHES-detection fix as the upload side.
+ - name: Download the AAB
+ if: steps.key.outputs.configured == 'true'
+ uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
+ with:
+ name: release-aab-${{ needs.detect.outputs.version }}
+ path: dist
+
+ - name: Install Ruby
+ if: steps.key.outputs.configured == 'true'
+ run: |
+ set -euo pipefail
+ SUDO=""
+ if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
+ $SUDO apt-get update
+ # Several fastlane dependencies build native extensions.
+ $SUDO apt-get install -y ruby-full ruby-dev build-essential
+ ruby -v
+
+ - name: Cache bundled gems
+ if: steps.key.outputs.configured == 'true'
+ uses: actions/cache@v4
+ with:
+ path: vendor/bundle
+ key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }}
+ restore-keys: |
+ ${{ runner.os }}-gems-
+
+ - name: Install fastlane
+ if: steps.key.outputs.configured == 'true'
+ run: |
+ set -euo pipefail
+ gem install bundler --no-document
+ bundle config set --local path vendor/bundle
+ bundle install --jobs 4
+ bundle exec fastlane --version
+
+ - name: Upload to Play
+ if: steps.key.outputs.configured == 'true'
+ env:
+ SUPPLY_JSON_KEY: play-service-account.json
+ FASTLANE_SKIP_UPDATE_CHECK: '1'
+ FASTLANE_HIDE_CHANGELOG: '1'
+ run: |
+ set -euo pipefail
+ # Absolute: a lane body runs from fastlane/, not the workspace root.
+ AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
+ test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
+ bundle exec fastlane deploy \
+ aab:"$AAB" \
+ track:"$PLAY_TRACK" \
+ release_status:"$PLAY_RELEASE_STATUS" \
+ dry_run:"$PLAY_DRY_RUN"
+ echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
+
+ # The workspace is reused on a self-hosted runner; the key must not
+ # outlive the job.
+ - name: Shred the service-account key
+ if: always()
+ run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
diff --git a/.gitignore b/.gitignore
index db92862..99a9f02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,23 @@ Thumbs.db
# KSP
.ksp/
+
+# Local agent notes: machine-specific build setup and on-device rules, not
+# anything the project itself depends on.
+/CLAUDE.md
+
+# Scratch backlog. Says so in its own header — dumped items get turned into
+# real work, not committed as a list.
+/req_changes.md
+
+# Google Play service-account key (fastlane/Appfile). Never committed.
+/play-service-account.json
+# fastlane run output
+/fastlane/report.xml
+/fastlane/README.md
+/vendor/bundle/
+/.bundle/
+
+# Emulator captures (scripts/emulator_screenshot.sh); regenerate from design/store/sample.
+/design/store/raw/
+/design/store/framed/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ada51d..e66eebf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,19 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
+## [1.0.0] - 2026-09-21
+
+### Added
+- CalDAV sync built in: Nextcloud, Radicale, Baïkal and more.
+- Agendula keeps your tasks itself, no other app needed. Copy them over from
+ OpenTasks or tasks.org in Settings → Storage.
+- Repeating tasks, several reminders per task, lists managed in the app,
+ iCalendar import and export, a home-screen widget and a Quick Settings tile.
+
+### Changed
+- New settings for all-day reminders, snooze length, sync interval, time format
+ and week start.
+
## [0.4.0] - 2026-08-31
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c9155d1..68700b3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,29 +1,29 @@
# Contributing to Agendula
-Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
-pure front-end over the OpenTasks `TaskContract` provider, with no own database
-or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
-(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
-[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
-practical how.
+Thanks for your interest in Agendula — a Material 3 Expressive task app with its
+own Room task store and its own CalDAV sync, which can also work on top of the
+OpenTasks `TaskContract` provider. This file covers the practical how.
## The one architectural rule
Everything above the data layer talks to `TasksRepository` and sees only domain
-types and Flows. **Provider column names, `TaskContract`, `ContentResolver`, and
-the authority string never leak above `data/tasks/`.** This is what keeps
-"Posture B" (bundling the provider later) an additive change instead of a
-rewrite — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §7. If a change
-would expose provider details to a ViewModel or the UI, it's in the wrong layer.
+types and Flows. **Room entities, provider column names, `TaskContract`,
+`ContentResolver` and the authority string never leak above `data/tasks/`.**
+That seam is what let the store move from the provider to Room without touching
+a screen, and what lets both stores sit behind one `TasksDataSource`. If a
+change would expose storage details to a ViewModel or the UI, it's in the wrong
+layer.
## Prerequisites
- JDK 17
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
-- A device or emulator with **OpenTasks** or **tasks.org** installed for
- anything touching the read/write paths (ideally with DAVx5 syncing a CalDAV
- task list, so there's real data). Debug builds fall back to `DemoSeeder` for
- sample data.
+- Any device or emulator for the default path — the store is Agendula's own.
+ Debug builds seed an "Agendula Demo" list via `DemoSeeder`. For External
+ mode, one with **OpenTasks** or **tasks.org** installed; for sync, a CalDAV
+ account (a local Radicale is the quickest).
+- Clone with `--recurse-submodules`: the `floret-kit` component library is a
+ submodule.
## Build, test, lint
@@ -65,7 +65,8 @@ truth for both the in-app picker and the Android 13+ per-app language setting.
| Layer | Lives in | Rule of thumb |
|---|---|---|
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
-| Provider access | `data/tasks/` | The only place that knows about the provider. New provider work goes through `TasksDataSource`. |
+| Task storage | `data/tasks/` (`room/` for our own store) | The only place that knows about Room or the provider. New storage work goes through `TasksDataSource`, for both stores. |
+| CalDAV sync | `data/sync/` | Accounts, the engine, scheduling and sync notices. |
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
| Screens | `ui//` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
@@ -74,20 +75,20 @@ truth for both the in-app picker and the Android 13+ per-app language setting.
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
Match the surrounding code.
-- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
- colour-scheme tokens (never hardcoded colours), and canonical M3 components
- (e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
- new screen or component.
-- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
- the only Android-coupled data implementation so the rest stays JVM-testable.
+- **Material 3 Expressive** for all UI, built from **floret-kit** components
+ first (`CollapsingScaffold`, `GroupedRow`, `InlineTextField`,
+ `FullScreenPicker` / `OptionPicker`, …) and colour-scheme tokens (never
+ hardcoded colours). If a floret-kit component is nearly right, add the
+ parameter there rather than dropping to raw Material 3.
+- Prefer the domain layer for anything testable.
## Tests
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
come with JVM unit tests under `app/src/test/`. The data source is the
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
-- Add an instrumented test only when a path genuinely needs a real
- `ContentResolver`.
+- Add an instrumented test only when a path genuinely needs Android: the Room
+ data source, migrations and the import paths live under `app/src/androidTest/`.
## Commits & PRs
@@ -96,9 +97,6 @@ truth for both the in-app picker and the Android 13+ per-app language setting.
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
user-visible change — its sections feed the release notes and F-Droid "What's
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
-- If your change shifts the architecture or completes a milestone, update
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
- in the same PR.
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
`versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
@@ -106,10 +104,9 @@ truth for both the in-app picker and the Android 13+ per-app language setting.
## Scope
-Agendula stays true to its thesis: a front-end over **open** task backends
-(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
-(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
-a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
+Agendula stays on **open** standards: CalDAV and iCalendar, through its own
+sync or through the OpenTasks provider (OpenTasks and tasks.org). Proprietary
+backends (Google Tasks, Microsoft To Do) are out of scope by design. jtx's
richer contract is a possible later addition.
## License
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..2e792a2
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,6 @@
+source "https://rubygems.org"
+
+# fastlane is used ONLY as the Google Play Developer API client (see
+# fastlane/Fastfile). It never builds and never signs. Pinned exactly, no
+# Gemfile.lock: it resolves an uploader's deps, not the app's.
+gem "fastlane", "2.237.0"
diff --git a/README.md b/README.md
index a67a934..424f013 100644
--- a/README.md
+++ b/README.md
@@ -3,49 +3,131 @@
Agendula
A modern Material 3 Expressive task app for Android.
-Reads, writes, and reminds — on top of an existing tasks provider, with no own
-sync stack.
+Syncs over CalDAV, or keeps your tasks on the device. Open standards, no account
+required.
+
+
+
+
+
+
+
+
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
-Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
-a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
-DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
-database, no reinvented sync.
+It keeps its own task store, designed around RFC 5545's `VTODO`, and syncs it
+with any CalDAV server — Nextcloud, Radicale, Baïkal and the rest. No account is
+needed to use it: without one, your tasks simply stay on the phone.
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
“things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
many small *florets* — so the two apps are florets of one bloom.)
-> **Status: data layer done, UI in progress.** The full non-visual stack over
-> the `TaskContract` provider — provider resolution, live-updating reads,
-> writes, smart-list filtering, and a self-scheduled reminder engine — is built
-> and unit-tested. The Material 3 Expressive screens are now being built on top,
-> one at a time. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for status,
-> [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how it's built, and
-> [`docs/PLAN.md`](docs/PLAN.md) for the A-now-B-later design rationale.
+## What it does
-## Sync sources (by design)
+- **Lists** you create, colour, reorder and delete in the app, plus smart lists:
+ Today, Upcoming, Overdue, No date, All and Completed.
+- **Tasks** with due and start dates, all-day tasks, subtasks, priorities,
+ progress, location and URL, and cancel/restore.
+- **Repeating tasks**, expanded per RFC 5545. Edit or delete one occurrence,
+ this and the following ones, or the whole series.
+- **Reminders** that fire at the exact time: several per task, separate defaults
+ for timed and all-day tasks (globally and per list), Done and Snooze right on
+ the notification, and they survive reboots.
+- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
+ list out as standard `.ics` files.
+- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
+ "share to Agendula" to turn text from another app into a task.
+- **Material 3 Expressive** throughout, with dynamic colour, expressive motion
+ and shapes.
-Agendula works with anything that writes to the tasks provider — **DAVx5**
-(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
-adapter — because it builds on the provider, not on any one sync app. Google
-Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
-owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
+## Sync
+
+Add a CalDAV account under **Settings → Accounts** and choose which of its task
+lists to sync. Nextcloud signs in through its own login flow in the browser, so
+Agendula never sees your password; any other server takes an address, a user
+name and a password — ideally an app password, which you can revoke on its own.
+
+Edits you make are sent within about half a minute. Changes from the server
+arrive on a background interval you choose (15 minutes to a day, or only when
+you tap sync), and every time you open the app. Several accounts can sync side
+by side, and lists can be created, renamed and deleted on the server from the
+app.
+
+Sync uses `sync-collection` (RFC 6578) where the server supports it and falls
+back to a full comparison where it does not. Everything the store does not model
+is kept verbatim and sent back unchanged, so passing your tasks through Agendula
+does not quietly lose fields another client wrote.
+
+## Where your tasks live
+
+| | Where | Sync | Needs |
+|---|---|---|---|
+| **In Agendula** *(default)* | Agendula's own database | Agendula's own CalDAV sync, if you add an account | nothing — no permissions, no other app |
+| **In a provider you already use** | OpenTasks or tasks.org | whatever syncs it for you — DAVx5 and friends | that app installed, and its read/write permission |
+
+Agendula's own store is an ordinary app database, so it **coexists with
+OpenTasks rather than replacing it**. If you already sync through a provider,
+Agendula can work on top of it exactly as before.
+
+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.
+
+Google Tasks and Microsoft To Do are out of scope by design: they are
+proprietary, and open standards — CalDAV and iCalendar — are the lane.
+
+## Install
+
+### F-Droid repository
+
+Every release is built, signed and published to a self-hosted F-Droid
+repository. Add it once and your F-Droid client handles updates from then on:
+
+1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
+ link below on your phone):
+
+ ```
+ https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
+ ```
+
+2. Refresh, search for **Agendula**, install.
+
+### Codeberg release / Obtainium
+
+Every release is also published on
+**[Codeberg](https://codeberg.org/jlmakiola/agendula/releases)** with the signed
+APK and a `.sha256` checksum attached — the same APK the F-Droid repository
+serves. For automatic updates from there, use
+**[Obtainium](https://github.com/ImranR98/Obtainium)** and
+**[add Agendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula)**.
+
+### Build from source
+
+```sh
+git clone --recurse-submodules https://codeberg.org/jlmakiola/agendula.git
+cd agendula
+./gradlew :app:assembleDebug
+```
+
+JDK 17 and the Android SDK are all it needs; see
+[`CONTRIBUTING.md`](CONTRIBUTING.md) for tests and lint.
## Translations
-Agendula ships in English so far, and would like not to. Translations are
-managed on a self-hosted **Weblate**, and partial ones are fine — an
-untranslated string simply falls back to English.
+Translations are managed on a self-hosted **Weblate**, and partial ones are
+fine — an untranslated string simply falls back to English. Agendula ships in
+English, German and Brazilian Portuguese so far.
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
@@ -53,6 +135,20 @@ No coding needed: register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
+## Contributing
+
+Issues and pull requests live on
+**[Codeberg](https://codeberg.org/jlmakiola/agendula)**.
+[`CONTRIBUTING.md`](CONTRIBUTING.md) covers the practical how.
+
+## Privacy
+
+No analytics, no advertising, no tracking, no third-party SDK, and no server of
+the developer's. Your tasks stay on your device unless you add a CalDAV account
+yourself, and then they go only to the server you chose.
+
+**→ [Privacy policy](https://jeanlucmakiola.de/agendula/privacy)**
+
## License
MIT — see [LICENSE](LICENSE).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fb34c3e..b96a84d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -27,12 +27,21 @@ 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"
+
+ // The sync-adapter and authenticator XML descriptors cannot read
+ // BuildConfig, so the two identifiers they need are generated here.
+ // Derived from applicationId so the debug and releaseTest builds get
+ // their own and can be installed alongside the real app without their
+ // accounts colliding. Must stay in step with SyncContract.
+ resValue("string", "account_type", "de.jeanlucmakiola.agendula.caldav")
+ resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.sync")
}
signingConfigs {
@@ -66,6 +75,8 @@ android {
debug {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
+ resValue("string", "account_type", "de.jeanlucmakiola.agendula.debug.caldav")
+ resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.debug.sync")
}
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
@@ -82,6 +93,8 @@ android {
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
+ resValue("string", "account_type", "de.jeanlucmakiola.agendula.releasetest.caldav")
+ resValue("string", "sync_authority", "de.jeanlucmakiola.agendula.releasetest.sync")
}
}
@@ -93,6 +106,10 @@ android {
buildFeatures {
compose = true
buildConfig = true
+ // The account type and sync authority are generated per variant so the
+ // debug and releaseTest builds do not fight the real app over ownership
+ // of an account type. AGP 9 requires opting in.
+ resValues = true
}
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
@@ -109,6 +126,12 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
+ // Ship the prebuilt .so files (datastore's shared counter) exactly as the
+ // AAR has them. AGP strips them only when an NDK happens to be installed,
+ // so CI and F-Droid's buildserver would otherwise disagree on the bytes.
+ jniLibs {
+ keepDebugSymbols += "**/*.so"
+ }
}
lint {
@@ -129,6 +152,10 @@ android {
isReturnDefaultValues = true
}
}
+
+ // MigrationTestHelper reads the exported schemas out of the test APK's
+ // assets, so app/schemas/ has to ship with the instrumented tests.
+ sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
}
kotlin {
@@ -137,11 +164,27 @@ kotlin {
}
}
+// Export each Room schema version to app/schemas/ and commit it. That JSON is
+// what MigrationTestHelper reads to build an old database and migrate it, so
+// without it a migration can only be tested by hand.
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+}
+
dependencies {
+ // Not a dependency we use directly — lifecycle already drags it in at 1.7.3.
+ // AGP's consistent resolution then pins androidTest to the app classpath, and
+ // room-testing's MigrationTestHelper needs 1.8+ to deserialize the exported
+ // schema; on 1.7.3 it dies with an AbstractMethodError. Raise it in one place.
+ constraints {
+ implementation(libs.kotlinx.serialization.json)
+ }
+
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@@ -154,22 +197,53 @@ 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)
- implementation(libs.androidx.datastore.preferences)
+ // Sync runs in WorkManager, triggered *through* the sync-adapter framework.
+ // hilt-work supplies the HiltWorkerFactory; its compiler generates the
+ // @HiltWorker plumbing.
+ implementation(libs.androidx.work.runtime.ktx)
+ // Custom Tabs: the Nextcloud login flow hands the browser an approval page.
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
+ // Push sync: a UnifiedPush distributor delivers the server's WebDAV-Push messages.
+ implementation(libs.unifiedpush.connector)
+
+ // 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.
+ implementation(project(":dav"))
+ // Discovery, auth and Nextcloud Login Flow v2.
+ implementation(project(":caldav"))
+ // :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
+ // xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
+ // with no framework, and android.jar's stub factory returns null under
+ // isReturnDefaultValues — so anything touching XmlUtils would NPE without a
+ // real implementation here.
+ testImplementation(libs.xpp3)
+
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
+ implementation(libs.androidx.documentfile)
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)
@@ -185,6 +259,7 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
+ androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index d4314e3..007cf29 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -5,17 +5,18 @@
# 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.**
+
+# dnsjava (CalDAV SRV/TXT discovery) references JNA, JNDI, Lombok and SLF4J
+# bindings that only exist on desktop JVMs; its Android resolver needs none.
+-dontwarn com.sun.jna.**
+-dontwarn javax.naming.**
+-dontwarn lombok.Generated
+-dontwarn org.slf4j.impl.StaticLoggerBinder
diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json
new file mode 100644
index 0000000..485362a
--- /dev/null
+++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json
@@ -0,0 +1,522 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "c94852274d874fe255ee76e1e46a3003",
+ "entities": [
+ {
+ "tableName": "accounts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "principalUrl",
+ "columnName": "principal_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "homeSetUrl",
+ "columnName": "home_set_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastSyncAt",
+ "columnName": "last_sync_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastSyncError",
+ "columnName": "last_sync_error",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "task_lists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accountId",
+ "columnName": "account_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "isVisible",
+ "columnName": "is_visible",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "isSynced",
+ "columnName": "is_synced",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "owner",
+ "columnName": "owner",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isReadOnly",
+ "columnName": "is_read_only",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "href",
+ "columnName": "href",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "ctag",
+ "columnName": "ctag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "syncToken",
+ "columnName": "sync_token",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isDirty",
+ "columnName": "is_dirty",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_task_lists_account_id",
+ "unique": false,
+ "columnNames": [
+ "account_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "accounts",
+ "onDelete": "SET NULL",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "account_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "tasks",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "listId",
+ "columnName": "list_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "href",
+ "columnName": "href",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "etag",
+ "columnName": "etag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "location",
+ "columnName": "location",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "percentComplete",
+ "columnName": "percent_complete",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "completedAt",
+ "columnName": "completed_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "priority",
+ "columnName": "priority",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "classification",
+ "columnName": "classification",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dtstart",
+ "columnName": "dtstart",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "due",
+ "columnName": "due",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isAllDay",
+ "columnName": "is_all_day",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "timezone",
+ "columnName": "timezone",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "rrule",
+ "columnName": "rrule",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "rdate",
+ "columnName": "rdate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "exdate",
+ "columnName": "exdate",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "recurrenceId",
+ "columnName": "recurrence_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "masterId",
+ "columnName": "master_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "last_modified",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "sequence",
+ "columnName": "sequence",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isDirty",
+ "columnName": "is_dirty",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isDeleted",
+ "columnName": "is_deleted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "unknownProperties",
+ "columnName": "unknown_properties",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_tasks_list_id_is_deleted",
+ "unique": false,
+ "columnNames": [
+ "list_id",
+ "is_deleted"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)"
+ },
+ {
+ "name": "index_tasks_parent_id",
+ "unique": false,
+ "columnNames": [
+ "parent_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)"
+ },
+ {
+ "name": "index_tasks_master_id_recurrence_id",
+ "unique": false,
+ "columnNames": [
+ "master_id",
+ "recurrence_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)"
+ },
+ {
+ "name": "index_tasks_is_dirty",
+ "unique": false,
+ "columnNames": [
+ "is_dirty"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)"
+ },
+ {
+ "name": "index_tasks_list_id_uid_recurrence_id",
+ "unique": true,
+ "columnNames": [
+ "list_id",
+ "uid",
+ "recurrence_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "task_lists",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "list_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "tasks",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "master_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "tasks",
+ "onDelete": "SET NULL",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "parent_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "task_alarms",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "taskId",
+ "columnName": "task_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minutesBefore",
+ "columnName": "minutes_before",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "reference",
+ "columnName": "reference",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'DUE'"
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_task_alarms_task_id",
+ "unique": false,
+ "columnNames": [
+ "task_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "tasks",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "task_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c94852274d874fe255ee76e1e46a3003')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/assets/tasks-v23.db b/app/src/androidTest/assets/tasks-v23.db
new file mode 100644
index 0000000..e305e90
Binary files /dev/null and b/app/src/androidTest/assets/tasks-v23.db differ
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt
new file mode 100644
index 0000000..2750df2
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/CredentialStoreTest.kt
@@ -0,0 +1,123 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStoreFile
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestName
+import org.junit.runner.RunWith
+
+/**
+ * The credential store, against a real Keystore.
+ *
+ * Instrumented rather than Robolectric because the thing under test *is* the
+ * platform: a shadowed Keystore would encrypt and decrypt happily and prove
+ * nothing about whether the key spec is usable for background sync.
+ *
+ * The case that matters most is the last one. A restored backup carries the
+ * ciphertext but not the key — Keystore keys are non-exportable — so the blob
+ * becomes permanently undecryptable. That must surface as "sign in again", never
+ * as a crash and never as a silently non-syncing account, which is why
+ * `backup_rules.xml` excludes this file in the first place.
+ */
+@RunWith(AndroidJUnit4::class)
+class CredentialStoreTest {
+
+ @get:Rule val testName = TestName()
+
+ private lateinit var scope: CoroutineScope
+ private lateinit var dataStore: DataStore
+ private lateinit var store: CredentialStore
+
+ private val context: Context get() = ApplicationProvider.getApplicationContext()
+
+ @Before
+ fun setUp() {
+ // ⚠️ A file per test, and a scope we can cancel. DataStore's FileStorage
+ // keeps a process-wide set of active files and refuses a second
+ // connection to one ("There are multiple DataStores active for the same
+ // file"); the entry is released only when the owning scope's job
+ // completes, and the factory's default scope is never cancelled. Sharing
+ // one file across methods therefore fails every test after the first.
+ scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ dataStore = PreferenceDataStoreFactory.create(scope = scope) {
+ context.preferencesDataStoreFile("credential_store_test_${testName.methodName}")
+ }
+ store = CredentialStore(dataStore)
+ }
+
+ @After
+ fun tearDown() {
+ runTest { store.clearAll() }
+ scope.cancel()
+ context.preferencesDataStoreFile("credential_store_test_${testName.methodName}").delete()
+ }
+
+ @Test
+ fun anAppPasswordRoundTrips() = runTest {
+ assertThat(store.put(accountId = 1L, appPassword = "s3cret-app-pw")).isTrue()
+ assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("s3cret-app-pw"))
+ }
+
+ @Test
+ fun aNonLatin1PasswordSurvives() = runTest {
+ // The same charset trap the Basic interceptor has: anything that silently
+ // mangles "ä" produces a 401 the user reads as a wrong password.
+ store.put(accountId = 1L, appPassword = "pä§§wörd-🔐")
+ assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("pä§§wörd-🔐"))
+ }
+
+ @Test
+ fun accountsDoNotShareACredential() = runTest {
+ store.put(1L, "first")
+ store.put(2L, "second")
+ assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("first"))
+ assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
+ }
+
+ @Test
+ fun anUnknownAccountIsAbsentRatherThanAnError() = runTest {
+ assertThat(store.get(99L)).isEqualTo(CredentialStore.Secret.Absent)
+ }
+
+ @Test
+ fun clearingRemovesOnlyThatAccount() = runTest {
+ store.put(1L, "first")
+ store.put(2L, "second")
+ store.clear(1L)
+ assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Absent)
+ assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
+ }
+
+ @Test
+ fun aCiphertextThisDeviceCannotDecryptMeansReAuthenticate() = runTest {
+ // Stands in for the restored-backup case: the blob is present and
+ // well-formed Base64, but was not produced by this device's key.
+ dataStore.edit {
+ it[stringPreferencesKey("caldav_app_password_1")] =
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ }
+ assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
+ }
+
+ @Test
+ fun aBlobThatIsNotBase64AtAllIsAlsoRecoverable() = runTest {
+ dataStore.edit { it[stringPreferencesKey("caldav_app_password_1")] = "not base64 !!" }
+ assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
+ }
+}
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt
new file mode 100644
index 0000000..5332f97
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/sync/SyncContractTest.kt
@@ -0,0 +1,44 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.agendula.R
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The account type and authority exist in two places that cannot see each other:
+ * `SyncContract`, derived from `BuildConfig.APPLICATION_ID`, and the `resValue`
+ * strings the XML descriptors read. Drift between them is invisible at build
+ * time and shows up as an account the sync framework will not trigger — the
+ * silent no-op, with nothing in the log.
+ */
+@RunWith(AndroidJUnit4::class)
+class SyncContractTest {
+
+ private val context: Context get() = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun theAccountTypeMatchesTheAuthenticatorDescriptor() {
+ assertThat(SyncContract.ACCOUNT_TYPE)
+ .isEqualTo(context.getString(R.string.account_type))
+ }
+
+ @Test
+ fun theAuthorityMatchesTheSyncAdapterDescriptor() {
+ assertThat(SyncContract.AUTHORITY)
+ .isEqualTo(context.getString(R.string.sync_authority))
+ }
+
+ @Test
+ fun theAuthorityMatchesTheStubProviderInTheManifest() {
+ // The provider is what makes the authority real; a mismatch here means
+ // requestSync addresses nothing.
+ val provider = context.packageManager
+ .resolveContentProvider(SyncContract.AUTHORITY, 0)
+ assertThat(provider).isNotNull()
+ assertThat(provider!!.name).isEqualTo(SyncStubProvider::class.java.name)
+ }
+}
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt
new file mode 100644
index 0000000..c040421
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImportTest.kt
@@ -0,0 +1,290 @@
+package de.jeanlucmakiola.agendula.data.tasks.legacy
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import androidx.datastore.preferences.core.Preferences
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
+import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import de.jeanlucmakiola.agendula.domain.TaskStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.UUID
+import kotlin.time.Instant
+
+/**
+ * The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture
+ * `scripts/make_import_fixture.py` seeds. Instrumented because both halves need
+ * a real SQLite: the source file and Room.
+ */
+@RunWith(AndroidJUnit4::class)
+class OneShotImportTest {
+
+ @get:Rule
+ val temp = TemporaryFolder()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private lateinit var scope: CoroutineScope
+ private lateinit var prefs: DataStore
+ private lateinit var db: TasksDatabase
+ private lateinit var importer: OneShotImport
+
+ @Before
+ fun setUp() {
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ prefs = PreferenceDataStoreFactory.create(scope = scope) {
+ temp.newFile("import-${counter++}.preferences_pb").also(File::delete)
+ }
+ db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
+ .allowMainThreadQueries()
+ .build()
+ importer = OneShotImport(context, db, prefs)
+ legacyFile().delete()
+ archiveFile().delete()
+ }
+
+ @After
+ fun tearDown() {
+ db.close()
+ scope.cancel()
+ legacyFile().delete()
+ archiveFile().delete()
+ }
+
+ private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME)
+ private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME)
+
+ /** The fixture, copied out of the test APK's assets. */
+ private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File {
+ InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source ->
+ target.outputStream().use(source::copyTo)
+ }
+ return target
+ }
+
+ private fun taskRows(): Map =
+ db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task }
+
+ // --- what lands -----------------------------------------------------------
+
+ @Test
+ fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() {
+ val counts = importer.importFrom(fixture())
+
+ assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2))
+ assertThat(taskRows().keys).containsExactly(
+ "Buy milk",
+ "Call the dentist",
+ "Gather receipts",
+ "Renew domain",
+ "Water the plants",
+ "Team offsite",
+ "Task in a hidden list",
+ "Ship the release",
+ )
+ }
+
+ @Test
+ fun importsEveryListAsADeviceOnlyListWithItsFlags() {
+ importer.importFrom(fixture())
+
+ val lists = db.taskLists().lists().associateBy { it.list.name }
+ assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work")
+ assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null)
+ assertThat(lists.getValue("Personal").list.isVisible).isTrue()
+ assertThat(lists.getValue("Hidden list").list.isVisible).isFalse()
+ // The list that sat under a real account: still imported, owner kept.
+ assertThat(lists.getValue("Work").list.owner).isEqualTo("Me")
+ assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt())
+ }
+
+ @Test
+ fun carriesTheTaskFieldsAcross() {
+ importer.importFrom(fixture())
+ val tasks = taskRows()
+
+ val milk = tasks.getValue("Buy milk")
+ assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY))
+ assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION)
+ assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0))
+
+ val dentist = tasks.getValue("Call the dentist")
+ assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS)
+ assertThat(dentist.percentComplete).isEqualTo(40)
+
+ val domain = tasks.getValue("Renew domain")
+ assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED)
+ assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY))
+
+ val plants = tasks.getValue("Water the plants")
+ assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
+ assertThat(plants.timezone).isEqualTo("Europe/Berlin")
+ assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0))
+
+ assertThat(tasks.getValue("Team offsite").isAllDay).isTrue()
+ }
+
+ // --- uids -----------------------------------------------------------------
+
+ @Test
+ fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() {
+ importer.importFrom(fixture())
+ val tasks = taskRows()
+
+ assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001")
+ // The external-account row's uid is what lets it be re-attached later.
+ assertThat(tasks.getValue("Ship the release").uid)
+ .isEqualTo("a1b2c3d4-0000-4000-8000-000000000009")
+
+ val minted = tasks.getValue("Call the dentist").uid
+ assertThat(minted).isNotEmpty()
+ assertThat(UUID.fromString(minted).version()).isEqualTo(4)
+ assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size)
+ }
+
+ // --- the id remap ---------------------------------------------------------
+
+ @Test
+ fun remapsListIdsOntoTheNewRowIds() {
+ importer.importFrom(fixture())
+
+ val lists = db.taskLists().lists().associateBy { it.list.name }
+ val byList = db.tasks().tasks(null, includeCompleted = true)
+ .groupBy { it.task.listId }
+ .mapValues { (_, rows) -> rows.size }
+
+ assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6)
+ assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1)
+ assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1)
+ // No task kept a dmfs row id that Room never handed out.
+ assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id })
+ }
+
+ @Test
+ fun remapsParentIdsOntoTheNewRowIds() {
+ importer.importFrom(fixture())
+ val tasks = taskRows()
+
+ val parent = tasks.getValue("Buy milk")
+ val child = tasks.getValue("Gather receipts")
+ assertThat(child.parentId).isEqualTo(parent.id)
+ assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts")
+ assertThat(tasks.values.filter { it.parentId != null }).hasSize(1)
+ }
+
+ // --- alarms ---------------------------------------------------------------
+
+ @Test
+ fun importsAlarmsAndSkipsEveryOtherProperty() {
+ importer.importFrom(fixture())
+ val tasks = taskRows()
+
+ assertThat(db.alarms().all()).hasSize(2)
+
+ val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single()
+ assertThat(milk.minutesBefore).isEqualTo(30)
+ assertThat(milk.reference).isEqualTo(AlarmReference.DUE)
+ assertThat(milk.message).isNull()
+
+ val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single()
+ assertThat(release.minutesBefore).isEqualTo(1440)
+ assertThat(release.reference).isEqualTo(AlarmReference.DUE)
+ assertThat(release.message).isEqualTo("Ship it")
+
+ // The category property on task 1 is not an alarm.
+ assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
+ }
+
+ // --- running it -----------------------------------------------------------
+
+ @Test
+ fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking {
+ fixture(legacyFile())
+
+ val first = importer.runIfNeeded()
+
+ assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
+ assertThat(legacyFile().exists()).isFalse()
+ assertThat(archiveFile().exists()).isTrue()
+ assertThat(importer.isDone.first()).isTrue()
+
+ val second = importer.runIfNeeded()
+
+ assertThat(second).isEqualTo(ImportResult.AlreadyDone)
+ assertThat(taskRows()).hasSize(8)
+ }
+
+ @Test
+ fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking {
+ // The process dying between the commit and the flag write is the one gap
+ // the DataStore flag cannot cover on its own. Because the rename happens
+ // first and the import always replaces, the next run finds the archive and
+ // redoes the same work rather than importing a second copy.
+ fixture(legacyFile())
+ importer.runIfNeeded()
+ importer.clearCompletion()
+
+ val resumed = importer.runIfNeeded()
+
+ assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
+ assertThat(taskRows()).hasSize(8)
+ assertThat(db.taskLists().lists()).hasSize(3)
+ assertThat(db.alarms().all()).hasSize(2)
+ }
+
+ @Test
+ fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking {
+ assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport)
+ assertThat(importer.isDone.first()).isTrue()
+ assertThat(taskRows()).isEmpty()
+ }
+
+ @Test
+ fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking {
+ fixture(legacyFile())
+ importer.runIfNeeded()
+
+ val again = importer.reimportFromArchive()
+
+ assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
+ assertThat(db.taskLists().lists()).hasSize(3)
+ assertThat(taskRows()).hasSize(8)
+ assertThat(db.alarms().all()).hasSize(2)
+ assertThat(archiveFile().exists()).isTrue()
+ }
+
+ @Test
+ fun replacingTwiceFromTheSameFileLeavesOneCopy() {
+ importer.importFrom(fixture())
+ val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true)
+
+ assertThat(counts).isEqualTo(ImportCounts(3, 8, 2))
+ assertThat(taskRows()).hasSize(8)
+ assertThat(db.taskLists().lists()).hasSize(3)
+ assertThat(db.alarms().all()).hasSize(2)
+ }
+
+ private companion object {
+ const val FIXTURE = "tasks-v23.db"
+ const val T0 = 1_768_467_600_000L
+ const val DAY = 86_400_000L
+ var counter = 0
+ }
+}
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
new file mode 100644
index 0000000..e54c1ea
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSourceTest.kt
@@ -0,0 +1,542 @@
+package de.jeanlucmakiola.agendula.data.tasks.room
+
+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.TaskQuery
+import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
+import de.jeanlucmakiola.agendula.domain.TaskForm
+import de.jeanlucmakiola.agendula.domain.TaskStatus
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Instant
+
+/**
+ * The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
+ * .TasksDataSource] rather than the DAOs — recurrence expansion and override
+ * forking only exist at this level.
+ */
+@RunWith(AndroidJUnit4::class)
+class RoomTasksDataSourceTest {
+
+ private lateinit var db: TasksDatabase
+ private lateinit var source: RoomTasksDataSource
+ private var listId = 0L
+
+ /** Truncated to the store's granularity: instants are columns of epoch millis. */
+ private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ TasksDatabase::class.java,
+ ).allowMainThreadQueries().build()
+ source = RoomTasksDataSource(db)
+ listId = source.createLocalList("Personal", 0xFF112233.toInt())
+ }
+
+ @After
+ fun tearDown() = db.close()
+
+ private fun form(
+ title: String = "task",
+ due: Instant? = null,
+ percentComplete: Int? = null,
+ ) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
+
+ /** A list that belongs to an account, so writes owe a server something. */
+ private fun syncedList(): Long {
+ val accountId = db.accounts().insert(
+ AccountEntity(displayName = "me@example.com", username = "me"),
+ )
+ return db.taskLists().insert(
+ TaskListEntity(name = "Work", color = 0, accountId = accountId, href = "https://s/w/"),
+ )
+ }
+
+ /** Turns [taskId] into a weekly series anchored at [anchor]. */
+ private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
+ val entity = db.tasks().entity(taskId)!!
+ db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
+ }
+
+ @Test
+ fun aWriteNamesTheListsItTouched() {
+ val touched = mutableListOf>()
+ val observed = RoomTasksDataSource(db) { touched += it }
+ val work = syncedList()
+ val id = observed.insertTask(form().copy(listId = work))
+
+ observed.updateTask(id, form().copy(listId = listId))
+
+ assertThat(touched).containsExactly(setOf(work), setOf(work, listId)).inOrder()
+ }
+
+ @Test
+ fun movingASyncedTaskLeavesATombstoneInTheOldList() {
+ val work = syncedList()
+ val id = source.insertTask(form().copy(listId = work))
+ db.tasks().markSynced(listOf(id), "https://s/w/a.ics", "e1")
+
+ source.updateTask(id, form().copy(listId = listId))
+
+ val moved = db.tasks().entity(id)!!
+ assertThat(moved.listId).isEqualTo(listId)
+ assertThat(moved.href).isNull()
+ val tombstone = db.tasks().allIn(work).single()
+ assertThat(tombstone.isDeleted).isTrue()
+ assertThat(tombstone.href).isEqualTo("https://s/w/a.ics")
+ assertThat(tombstone.etag).isEqualTo("e1")
+ }
+
+ @Test
+ fun createsAndReadsBackALocalList() {
+ val lists = source.taskLists()
+
+ assertThat(lists).hasSize(1)
+ assertThat(lists.single().name).isEqualTo("Personal")
+ // No account, so the list still has to report something the lists screen
+ // can group under.
+ assertThat(lists.single().isLocal).isTrue()
+ assertThat(lists.single().accountName).isEqualTo("Local")
+ }
+
+ @Test
+ fun renamesAndRecoloursAList() {
+ source.updateList(listId, " Errands ", 0xFF445566.toInt())
+
+ val list = source.taskLists().single()
+ assertThat(list.name).isEqualTo("Errands")
+ assertThat(list.color).isEqualTo(0xFF445566.toInt())
+ // Nothing to sync a device-only list to, so the edit leaves it clean.
+ assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse()
+ }
+
+ @Test
+ fun deletingAListTakesItsTasksWithIt() {
+ source.insertTask(form(title = "Buy milk"))
+ source.insertTask(form(title = "Call the bank"))
+ val other = source.createLocalList("Work", 0xFF778899.toInt())
+ val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other))
+
+ source.deleteList(listId)
+
+ assertThat(source.taskLists().map { it.id }).containsExactly(other)
+ assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId })
+ .containsExactly(keeper)
+ }
+
+ @Test
+ fun createsAndReadsBackANonRecurringTask() {
+ val due = now + 1.days
+ val id = source.insertTask(form(title = "Buy milk", due = due))
+
+ val task = source.task(id)!!
+
+ assertThat(task.taskId).isEqualTo(id)
+ assertThat(task.title).isEqualTo("Buy milk")
+ assertThat(task.due).isEqualTo(due)
+ assertThat(task.isRecurring).isFalse()
+ // A task that does not recur has no occurrence anchor, so it keys and edits
+ // by task id exactly as it did against the provider.
+ assertThat(task.occurrenceStart).isNull()
+ assertThat(task.occurrenceKey).isEqualTo("$id")
+ }
+
+ @Test
+ fun mintsAUidForEveryTask() {
+ val id = source.insertTask(form())
+
+ assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
+ }
+
+ @Test
+ fun expandsARecurringSeriesIntoManyOccurrences() {
+ val anchor = now
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, anchor)
+
+ val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
+
+ // The provider materialised exactly one upcoming occurrence; we expand the
+ // whole window, so a weekly series yields well over a hundred.
+ assertThat(occurrences.size).isGreaterThan(100)
+ assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
+ assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
+ assertThat(occurrences.all { it.isRecurring }).isTrue()
+ // Each occurrence keeps the series' length rather than the master's dates.
+ val first = occurrences.minBy { it.occurrenceStart!! }
+ assertThat(first.due!! - first.start!!).isEqualTo(1.days)
+ }
+
+ @Test
+ fun exactlyOneOccurrenceIsTheCurrentOne() {
+ val id = source.insertTask(form())
+ makeRecurring(id, now - 30.days)
+
+ val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
+
+ assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
+ assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
+ }
+
+ @Test
+ fun editingOneOccurrenceForksARecurrenceIdOverride() {
+ val anchor = now
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, anchor)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
+
+ val override = db.tasks().override(id, target.occurrenceStart)!!
+ // RFC 5545's model: the override shares its master's UID — that is what
+ // makes it an override rather than a separate task. The dmfs provider
+ // detached the occurrence into a new task with its own UID instead.
+ assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
+ assertThat(override.masterId).isEqualTo(id)
+ assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
+ assertThat(override.rrule).isNull()
+ assertThat(override.title).isEqualTo("Water them twice")
+ }
+
+ @Test
+ fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, now)
+ val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
+ val before = open()
+ val target = before.first { it.distanceFromCurrent == 0 }
+
+ source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
+
+ // Writing the status onto the master would close the series: the master is
+ // the row the task query filters on, so every occurrence would vanish.
+ val after = open()
+ assertThat(after).hasSize(before.size - 1)
+ assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart)
+ assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
+
+ val override = db.tasks().override(id, target.occurrenceStart)!!
+ assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
+ assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
+ assertThat(override.rrule).isNull()
+ // The override stands for *that* occurrence, so it carries the
+ // occurrence's resolved times, not the master's anchor.
+ assertThat(override.dtstart).isEqualTo(target.occurrenceStart)
+ }
+
+ @Test
+ fun reopeningACompletedOccurrenceReusesItsOverride() {
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .first { it.taskId == id && it.distanceFromCurrent == 0 }
+
+ source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
+ source.setCompletedInstance(id, target.occurrenceStart, completed = false)
+
+ assertThat(db.tasks().overrides(id)).hasSize(1)
+ assertThat(db.tasks().override(id, target.occurrenceStart)!!.status)
+ .isEqualTo(TaskStatus.NEEDS_ACTION)
+ assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
+ .contains(target.occurrenceStart)
+ }
+
+ @Test
+ fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .first { it.taskId == id && it.distanceFromCurrent == 0 }
+
+ source.setCancelledInstance(id, target.occurrenceStart!!, cancelled = true)
+
+ assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
+ assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.CANCELLED)
+
+ source.setCancelledInstance(id, target.occurrenceStart, cancelled = false)
+
+ assertThat(db.tasks().overrides(id)).hasSize(1)
+ assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
+ }
+
+ @Test
+ fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
+ val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
+
+ source.setCompletedInstance(id, now, completed = true)
+
+ assertThat(db.tasks().overrides(id)).isEmpty()
+ assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
+ }
+
+ @Test
+ fun anOverrideReplacesOnlyItsOwnOccurrence() {
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, now)
+ // The list holds this series alone, so no filter is needed — and none can
+ // be written on taskId, since the override reports its own row id.
+ val before = source.tasks(TaskQuery(listId = listId))
+ val target = before.first { it.distanceFromCurrent == 1 }
+
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
+
+ val after = source.tasks(TaskQuery(listId = listId))
+ assertThat(after).hasSize(before.size)
+ val edited = after.single { it.title == "Water them twice" }
+ assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart)
+ assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1)
+ }
+
+ /**
+ * An edited occurrence addresses its own row, not the master's. That is what
+ * sends the *next* edit down `updateTask` rather than forking a second time:
+ * an override carries no rule, so it reads back as non-recurring.
+ */
+ @Test
+ fun anEditedOccurrenceReportsTheOverridesOwnId() {
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 }
+
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
+
+ val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" }
+ val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id
+ assertThat(edited.taskId).isEqualTo(overrideId)
+ assertThat(edited.taskId).isNotEqualTo(id)
+ assertThat(source.task(overrideId)!!.isRecurring).isFalse()
+ }
+
+ @Test
+ fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
+ val anchor = now
+ val id = source.insertTask(form())
+ makeRecurring(id, anchor)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 2 }
+
+ source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
+
+ assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
+ }
+
+ @Test
+ fun updatingANonRecurringTaskWritesThroughToItsRow() {
+ val id = source.insertTask(form(title = "old"))
+
+ source.updateTask(id, form(title = "new"))
+
+ assertThat(source.task(id)!!.title).isEqualTo("new")
+ }
+
+ @Test
+ fun completionTogglesTheWholeTriple() {
+ val id = source.insertTask(form())
+
+ source.setCompleted(id, completed = true)
+ val done = db.tasks().entity(id)!!
+ assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
+ assertThat(done.percentComplete).isEqualTo(100)
+ assertThat(done.completedAt).isNotNull()
+
+ source.setCompleted(id, completed = false)
+ assertThat(db.tasks().entity(id)!!.completedAt).isNull()
+ }
+
+ @Test
+ fun completedTasksAreExcludedUnlessAskedFor() {
+ val id = source.insertTask(form())
+ source.setCompleted(id, completed = true)
+
+ assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
+ assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
+ }
+
+ @Test
+ 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(TaskReminder(minutesBefore = 30))
+
+ source.setAlarm(id, 60)
+ assertThat(db.alarms().forTask(id)).hasSize(1)
+ assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
+
+ source.setAlarm(id, null)
+ assertThat(source.alarms()).doesNotContainKey(id)
+ }
+
+ @Test
+ fun settingTheEditableReminderLeavesTheOthersAlone() {
+ val id = source.insertTask(form(due = now + 1.days))
+ val start = TaskReminder(minutesBefore = 10, fromStart = true)
+ source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
+
+ source.setAlarm(id, 45)
+ assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
+ assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
+
+ source.setAlarm(id, null)
+ assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
+ assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
+ }
+
+ @Test
+ fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
+ val id = source.insertTask(form(due = now + 1.days))
+ makeRecurring(id, now)
+ source.setAlarm(id, 30)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+
+ source.updateInstance(id, target.occurrenceStart!!, form())
+
+ val override = db.tasks().override(id, target.occurrenceStart)!!
+ assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
+ }
+
+ @Test
+ fun deletingATaskInALocalListRemovesItOutright() {
+ val id = source.insertTask(form())
+
+ source.deleteTask(id)
+
+ // No account knows about it, so there is nothing to tombstone for.
+ assertThat(db.tasks().entity(id)).isNull()
+ }
+
+ @Test
+ fun deletingASeriesTakesItsOverridesWithIt() {
+ val id = source.insertTask(form())
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
+
+ source.deleteTask(id)
+
+ assertThat(db.tasks().allOverrides(listId)).isEmpty()
+ }
+
+ @Test
+ fun aForkedOccurrenceCarriesTheSeriesReminder() {
+ val id = source.insertTask(form())
+ makeRecurring(id, now)
+ source.setAlarm(id, minutesBeforeDue = 30)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
+
+ // The fork copies the master's properties, which is what carries the
+ // reminder across — the repository is what puts the series' own back.
+ val override = db.tasks().override(id, target.occurrenceStart)!!
+ assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
+ }
+
+ @Test
+ fun anOccurrenceCannotBeMovedOutOfItsSeriesList() {
+ val other = source.createLocalList("Work", 0)
+ val id = source.insertTask(form())
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
+ val override = db.tasks().override(id, target.occurrenceStart)!!
+
+ source.updateTask(override.id, TaskForm(title = "moved", listId = other))
+
+ // ⚠️ list_id = B with master_id in list A is invisible in both — the task
+ // query skips non-null master_id, and the override query finds no master
+ // in B — while still uploading as part of A's resource.
+ assertThat(db.tasks().entity(override.id)!!.listId).isEqualTo(listId)
+ assertThat(db.tasks().entity(override.id)!!.title).isEqualTo("moved")
+ }
+
+ @Test
+ fun deletingASyncedSeriesTombstonesItsOverridesToo() {
+ val syncedList = syncedList()
+ val id = source.insertTask(TaskForm(title = "Standup", listId = syncedList))
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = syncedList))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+ source.updateInstance(id, target.occurrenceStart!!, TaskForm(title = "moved", listId = syncedList))
+
+ source.deleteTask(id)
+
+ // ⚠️ master_id cascades on delete, and a tombstone deletes nothing — so
+ // a master marked alone left the resource reading as partly deleted, the
+ // DELETE was never sent, and the task stayed on the server for ever.
+ val rows = db.tasks().allIn(syncedList)
+ assertThat(rows).hasSize(2)
+ assertThat(rows.all { it.isDeleted && it.isDirty }).isTrue()
+ }
+
+ @Test
+ fun deletingOneOccurrenceExceptsItOnTheMaster() {
+ val id = source.insertTask(form())
+ makeRecurring(id, now)
+ val target = source.tasks(TaskQuery(listId = listId))
+ .filter { it.taskId == id }
+ .first { it.distanceFromCurrent == 1 }
+ source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
+ val override = db.tasks().override(id, target.occurrenceStart)!!
+
+ source.deleteTask(override.id)
+
+ // ⚠️ Dropping the override row un-overrides the occurrence, and the
+ // master's RRULE regenerates it. The EXDATE is the deletion.
+ assertThat(db.tasks().entity(override.id)).isNull()
+ assertThat(db.tasks().entity(id)!!.exdate).isNotEmpty()
+ assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
+ .doesNotContain(target.occurrenceStart)
+ }
+
+ @Test
+ fun subtasksReadBackUnderTheirParent() {
+ val parent = source.insertTask(form(title = "Prepare invoice"))
+ val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
+
+ assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
+ }
+
+ @Test
+ fun exportReadsMastersNotOccurrences() {
+ val id = source.insertTask(form(title = "Water the plants"))
+ makeRecurring(id, now)
+
+ val exported = source.exportTasks(listId)
+
+ // One row carrying the rule, not one row per occurrence with the rule lost.
+ assertThat(exported).hasSize(1)
+ assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
+ assertThat(exported.single().uid).isNotEmpty()
+ }
+
+ @Test
+ fun insertingIntoAMissingListFails() {
+ val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
+
+ assertThat(thrown).isNotNull()
+ }
+}
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt
new file mode 100644
index 0000000..cf4b272
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt
@@ -0,0 +1,68 @@
+package de.jeanlucmakiola.agendula.data.tasks.room
+
+import androidx.room.testing.MigrationTestHelper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The migration harness, proven against the committed schema in `app/schemas/`.
+ *
+ * There is one schema version today, so all there is to assert is that the helper
+ * can build v1 from the exported JSON, seed it, and validate it back — i.e. the
+ * export, the assets wiring and the identity hash all line up. That is the point:
+ * the first real migration only has to add its own case.
+ *
+ * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
+ * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
+ * database, and add a test here shaped like this:
+ *
+ * ```
+ * helper.createDatabase(TEST_DB, 1).use { db ->
+ * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
+ * }
+ * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
+ * // read the seeded rows back — validation proves the shape, not the data
+ * }
+ * ```
+ */
+@RunWith(AndroidJUnit4::class)
+class TasksDatabaseMigrationTest {
+
+ @get:Rule
+ val helper = MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ TasksDatabase::class.java,
+ )
+
+ @Test
+ fun buildsV1FromTheExportedSchema() {
+ helper.createDatabase(TEST_DB, 1).use { db ->
+ db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
+ db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
+
+ db.query("SELECT title FROM tasks").use { cursor ->
+ assertThat(cursor.moveToFirst()).isTrue()
+ assertThat(cursor.getString(0)).isEqualTo("Buy milk")
+ }
+ }
+ }
+
+ @Test
+ fun validatesV1AgainstTheExportedSchema() {
+ helper.createDatabase(TEST_DB, 1).close()
+
+ // No migrations to run: v1 is opened and checked against 1.json, which is
+ // what proves the harness rather than the schema.
+ helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
+ assertThat(db.version).isEqualTo(1)
+ }
+ }
+
+ private companion object {
+ const val TEST_DB = "migration-test.db"
+ }
+}
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt
new file mode 100644
index 0000000..594e500
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt
@@ -0,0 +1,107 @@
+package de.jeanlucmakiola.agendula.data.tasks.room
+
+import android.content.Context
+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.TaskQuery
+import de.jeanlucmakiola.agendula.domain.TaskForm
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+import kotlin.time.measureTime
+import kotlin.time.measureTimedValue
+
+/**
+ * The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
+ * smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
+ * includes expanding every series in memory.
+ *
+ * The assertion is a deliberately loose ceiling, so it catches a real regression
+ * rather than CI jitter; the printed numbers are what the check is actually for.
+ */
+@RunWith(AndroidJUnit4::class)
+class TasksDatabasePerformanceTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ private lateinit var db: TasksDatabase
+ private lateinit var source: RoomTasksDataSource
+ private var listId = 0L
+
+ @Before
+ fun setUp() {
+ delete()
+ db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
+ .allowMainThreadQueries()
+ .build()
+ source = RoomTasksDataSource(db)
+ listId = source.createLocalList("Everything", 0xFF112233.toInt())
+ }
+
+ @After
+ fun tearDown() {
+ db.close()
+ delete()
+ }
+
+ @Test
+ fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
+ val seeded = measureTime { seed() }
+
+ // Discard the first read: it pays for statement compilation and page cache
+ // warming, which a running app has already paid.
+ source.tasks(TaskQuery(includeCompleted = true))
+ val (tasks, elapsed) = measureTimedValue {
+ source.tasks(TaskQuery(includeCompleted = true))
+ }
+
+ println(
+ "[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
+ "in $elapsed (seed $seeded)",
+ )
+ // Expansion is bounded twice over: the read window is 1 year back and 2
+ // forward, and each series stops at ExpansionWindow.maxOccurrences (500),
+ // so the occurrence count cannot grow with the age of the series.
+ assertThat(tasks.size).isAtLeast(TASK_COUNT)
+ assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
+ }
+
+ private fun seed() {
+ val anchor = Clock.System.now() - 30.days
+ val ids = ArrayList(TASK_COUNT)
+ db.runInTransaction {
+ repeat(TASK_COUNT) { index ->
+ ids += source.insertTask(
+ TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
+ )
+ }
+ }
+ db.runInTransaction {
+ ids.take(SERIES_COUNT).forEach { id ->
+ val entity = db.tasks().entity(id)!!
+ db.tasks().update(
+ entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
+ )
+ }
+ }
+ }
+
+ private fun delete() {
+ val base = context.getDatabasePath(DB)
+ base.delete()
+ listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
+ }
+
+ private companion object {
+ const val DB = "performance-test.db"
+ const val TASK_COUNT = 5_000
+ const val SERIES_COUNT = 20
+ const val CEILING_MILLIS = 8_000L
+ }
+}
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt
new file mode 100644
index 0000000..962ddde
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt
@@ -0,0 +1,155 @@
+package de.jeanlucmakiola.agendula.data.tasks.room
+
+import android.content.Context
+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.TaskQuery
+import de.jeanlucmakiola.agendula.domain.TaskForm
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+
+/**
+ * The Auto Backup restore path, on disk.
+ *
+ * Auto Backup copies database files without checkpointing, and Room runs in WAL
+ * mode — so `.db` alone can be a *stale* copy of a database whose recent writes
+ * are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
+ * files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
+ * that both of those actually do what they claim, and that neither alone is an
+ * assumption.
+ *
+ * A file copy of a live database stands in for the backup transport — the
+ * transport is what Auto Backup does to these files, and it is not what is under
+ * test here.
+ */
+@RunWith(AndroidJUnit4::class)
+class TasksDatabaseRestoreTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ private lateinit var db: TasksDatabase
+ private lateinit var source: RoomTasksDataSource
+ private var listId = 0L
+ private var restored: TasksDatabase? = null
+
+ @Before
+ fun setUp() {
+ delete(LIVE)
+ delete(BACKUP)
+ db = open(LIVE)
+ source = RoomTasksDataSource(db)
+ listId = source.createLocalList("Personal", 0xFF112233.toInt())
+ }
+
+ @After
+ fun tearDown() {
+ restored?.close()
+ db.close()
+ delete(LIVE)
+ delete(BACKUP)
+ }
+
+ @Test
+ fun roomRunsInWalMode() {
+ // Everything below is only interesting because of this.
+ assertThat(journalMode()).isEqualTo("wal")
+ }
+
+ @Test
+ fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
+ write("checkpointed")
+ checkpoint()
+ write("only in the wal")
+
+ backUp(withSidecars = false)
+
+ assertThat(restore()).containsExactly("checkpointed")
+ }
+
+ @Test
+ fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
+ write("checkpointed")
+ checkpoint()
+ write("only in the wal")
+
+ backUp(withSidecars = true)
+
+ assertThat(restore()).containsExactly("checkpointed", "only in the wal")
+ }
+
+ @Test
+ fun checkpointingFirstMakesTheDbFileAloneEnough() {
+ write("checkpointed")
+ checkpoint()
+ write("last write")
+
+ // What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
+ // that arrives without the sidecars.
+ checkpoint()
+ backUp(withSidecars = false)
+
+ assertThat(restore()).containsExactly("checkpointed", "last write")
+ }
+
+ // --- the moving parts -----------------------------------------------------
+
+ private fun open(name: String): TasksDatabase =
+ Room.databaseBuilder(context, TasksDatabase::class.java, name)
+ .allowMainThreadQueries()
+ .build()
+
+ private fun write(title: String) {
+ source.insertTask(TaskForm(title = title, listId = listId))
+ }
+
+ private fun journalMode(): String =
+ db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
+ cursor.moveToFirst()
+ cursor.getString(0).lowercase()
+ }
+
+ /** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
+ private fun checkpoint() {
+ db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
+ cursor.moveToFirst()
+ assertThat(cursor.getInt(0)).isEqualTo(0)
+ }
+ }
+
+ /** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
+ private fun backUp(withSidecars: Boolean) {
+ delete(BACKUP)
+ val live = context.getDatabasePath(LIVE)
+ val backup = context.getDatabasePath(BACKUP)
+ live.copyTo(backup, overwrite = true)
+ if (!withSidecars) return
+ SIDECARS.forEach { suffix ->
+ val from = File(live.path + suffix)
+ if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
+ }
+ }
+
+ /** Opens the copy as a fresh install would and reports the task titles that survived. */
+ private fun restore(): List {
+ restored?.close()
+ val database = open(BACKUP).also { restored = it }
+ return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
+ }
+
+ private fun delete(name: String) {
+ val base = context.getDatabasePath(name)
+ base.delete()
+ SIDECARS.forEach { File(base.path + it).delete() }
+ }
+
+ private companion object {
+ const val LIVE = "restore-live.db"
+ const val BACKUP = "restore-backup.db"
+ val SIDECARS = listOf("-wal", "-shm")
+ }
+}
diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt
new file mode 100644
index 0000000..6095383
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt
@@ -0,0 +1,274 @@
+package de.jeanlucmakiola.agendula.data.tasks.room
+
+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.domain.TaskStatus
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.time.Instant
+
+/**
+ * The schema, exercised through the DAOs. Instrumented rather than JVM because
+ * the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
+ * real SQLite.
+ */
+@RunWith(AndroidJUnit4::class)
+class TasksDatabaseTest {
+
+ private lateinit var db: TasksDatabase
+ private lateinit var lists: TaskListDao
+ private lateinit var tasks: TaskDao
+ private lateinit var alarms: TaskAlarmDao
+ private lateinit var accounts: AccountDao
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ TasksDatabase::class.java,
+ ).allowMainThreadQueries().build()
+ lists = db.taskLists()
+ tasks = db.tasks()
+ alarms = db.alarms()
+ accounts = db.accounts()
+ }
+
+ @After
+ fun tearDown() = db.close()
+
+ private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
+ lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
+
+ private fun newTask(
+ listId: Long,
+ uid: String = "uid-${counter++}",
+ title: String? = "Buy milk",
+ status: TaskStatus = TaskStatus.NEEDS_ACTION,
+ parentId: Long? = null,
+ masterId: Long? = null,
+ recurrenceId: Instant? = null,
+ ): Long = tasks.insert(
+ TaskEntity(
+ listId = listId,
+ uid = uid,
+ title = title,
+ status = status,
+ parentId = parentId,
+ masterId = masterId,
+ recurrenceId = recurrenceId,
+ ),
+ )
+
+ @Test
+ fun writesAndReadsAListWithItsTasks() {
+ val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
+ val listId = newList(accountId = accountId)
+ val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
+ val taskId = tasks.insert(
+ TaskEntity(
+ listId = listId,
+ uid = "uid-1",
+ title = "Buy milk",
+ description = "2%",
+ due = due,
+ priority = 3,
+ status = TaskStatus.IN_PROCESS,
+ percentComplete = 40,
+ ),
+ )
+
+ val list = lists.lists().single()
+ assertThat(list.list.id).isEqualTo(listId)
+ assertThat(list.list.name).isEqualTo("Groceries")
+ assertThat(list.accountDisplayName).isEqualTo("Fastmail")
+
+ val row = tasks.task(taskId)!!
+ assertThat(row.task.title).isEqualTo("Buy milk")
+ assertThat(row.task.due).isEqualTo(due)
+ // Stored raw: an off-bucket PRIORITY must come back as it went in.
+ assertThat(row.task.priority).isEqualTo(3)
+ assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
+ assertThat(row.task.percentComplete).isEqualTo(40)
+ assertThat(row.listName).isEqualTo("Groceries")
+ assertThat(row.accountDisplayName).isEqualTo("Fastmail")
+ }
+
+ @Test
+ fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
+ val a = newList("A")
+ val b = newList("B")
+ newTask(a, title = "open")
+ newTask(a, title = "done", status = TaskStatus.COMPLETED)
+ newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
+ newTask(b, title = "elsewhere")
+
+ assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
+ .containsExactly("open")
+ assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
+ assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
+ }
+
+ @Test
+ fun readsSubtasksByParent() {
+ val listId = newList()
+ val parent = newTask(listId, title = "parent")
+ newTask(listId, title = "child", parentId = parent)
+
+ assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
+ }
+
+ @Test
+ fun hidesTombstonesFromReadsAndExports() {
+ val listId = newList()
+ val taskId = newTask(listId)
+ tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
+
+ assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
+ assertThat(tasks.task(taskId)).isNull()
+ assertThat(tasks.exportTasks(listId)).isEmpty()
+ assertThat(tasks.entity(taskId)).isNotNull()
+ }
+
+ @Test
+ fun keepsOverridesOutOfTheMasterReads() {
+ val listId = newList()
+ val master = newTask(listId, uid = "series")
+ val override = newTask(
+ listId,
+ uid = "series",
+ masterId = master,
+ recurrenceId = Instant.fromEpochMilliseconds(5_000),
+ )
+
+ assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
+ .containsExactly(master)
+ assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
+ assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
+ assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
+ .isEqualTo(override)
+ assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
+ }
+
+ // --- cascades -------------------------------------------------------------
+
+ @Test
+ fun deletingAListDeletesItsTasks() {
+ val listId = newList()
+ val taskId = newTask(listId)
+
+ lists.delete(listId)
+
+ assertThat(tasks.entity(taskId)).isNull()
+ }
+
+ @Test
+ fun deletingASeriesDeletesItsOverrides() {
+ val listId = newList()
+ val master = newTask(listId, uid = "series")
+ val override = newTask(
+ listId,
+ uid = "series",
+ masterId = master,
+ recurrenceId = Instant.fromEpochMilliseconds(5_000),
+ )
+
+ tasks.delete(master)
+
+ assertThat(tasks.entity(override)).isNull()
+ }
+
+ @Test
+ fun deletingAParentPromotesItsSubtasks() {
+ val listId = newList()
+ val parent = newTask(listId, title = "parent")
+ val child = newTask(listId, title = "child", parentId = parent)
+
+ tasks.delete(parent)
+
+ val promoted = tasks.entity(child)
+ assertThat(promoted).isNotNull()
+ assertThat(promoted!!.parentId).isNull()
+ }
+
+ @Test
+ fun deletingATaskDeletesItsAlarms() {
+ val listId = newList()
+ val taskId = newTask(listId)
+ alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
+ assertThat(alarms.all()).hasSize(1)
+
+ tasks.delete(taskId)
+
+ assertThat(alarms.all()).isEmpty()
+ }
+
+ @Test
+ fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
+ val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
+ val listId = newList(accountId = accountId)
+
+ accounts.delete(accountId)
+
+ assertThat(lists.entity(listId)!!.accountId).isNull()
+ }
+
+ @Test
+ fun replacingAnAlarmLeavesOnlyTheNewOne() {
+ val listId = newList()
+ val taskId = newTask(listId)
+ alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
+ alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
+
+ assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
+ assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
+
+ alarms.replaceForTask(taskId, null)
+ assertThat(alarms.forTask(taskId)).isEmpty()
+ }
+
+ // --- the unique index -----------------------------------------------------
+
+ @Test
+ fun anOverrideMayShareItsMastersUid() {
+ val listId = newList()
+ val master = newTask(listId, uid = "series")
+ newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
+ newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
+
+ assertThat(tasks.overrides(master)).hasSize(2)
+ }
+
+ @Test
+ fun rejectsTwoOverridesOfTheSameOccurrence() {
+ val listId = newList()
+ val master = newTask(listId, uid = "series")
+ val at = Instant.fromEpochMilliseconds(1)
+ newTask(listId, uid = "series", masterId = master, recurrenceId = at)
+
+ val failure = runCatching {
+ newTask(listId, uid = "series", masterId = master, recurrenceId = at)
+ }.exceptionOrNull()
+
+ assertThat(failure).isNotNull()
+ assertThat(failure!!.message).contains("UNIQUE")
+ }
+
+ @Test
+ fun theSameUidMayExistInAnotherList() {
+ val a = newList("A")
+ val b = newList("B")
+ newTask(a, uid = "shared")
+ newTask(b, uid = "shared")
+
+ assertThat(tasks.byUid(a, "shared")).isNotNull()
+ assertThat(tasks.byUid(b, "shared")).isNotNull()
+ }
+
+ private companion object {
+ var counter = 0
+ }
+}
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..874fc6f
--- /dev/null
+++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImportTest.kt
@@ -0,0 +1,324 @@
+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.
+ *
+ * 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 setReminders(taskId: Long, reminders: List) = 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 setCancelled(taskId: Long, cancelled: Boolean) = unused()
+ override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
+ override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
+ override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
+ override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
+ override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = 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/debug/java/de/jeanlucmakiola/agendula/data/demo/DemoSeedModule.kt b/app/src/debug/java/de/jeanlucmakiola/agendula/data/demo/DemoSeedModule.kt
new file mode 100644
index 0000000..85006e1
--- /dev/null
+++ b/app/src/debug/java/de/jeanlucmakiola/agendula/data/demo/DemoSeedModule.kt
@@ -0,0 +1,20 @@
+package de.jeanlucmakiola.agendula.data.demo
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+import de.jeanlucmakiola.agendula.data.di.LaunchHook
+import javax.inject.Provider
+
+/** Sample data on `am start … --ez agendula_seed true`; debug builds only. */
+@Module
+@InstallIn(SingletonComponent::class)
+object DemoSeedModule {
+ @Provides
+ @IntoSet
+ fun demoSeedHook(seeder: Provider): LaunchHook = LaunchHook { intent ->
+ if (intent.getBooleanExtra("agendula_seed", false)) seeder.get().seed()
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt b/app/src/debug/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt
similarity index 97%
rename from app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt
rename to app/src/debug/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt
index f3234ff..a9b8f6a 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt
+++ b/app/src/debug/java/de/jeanlucmakiola/agendula/data/demo/DemoSeeder.kt
@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
- repository.setCompleted(done, completed = true)
+ repository.setCompleted(done, occurrenceStart = null, completed = true)
}
private companion object {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6d37b60..997cb36 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,20 +2,48 @@
-
+ runtime by the permission flow, and only once the user has actually
+ selected External mode. Both are dangerous-level.
+
+ StorageMode.OWN needs nothing here: it is a Room database in our own data
+ directory. Agendula publishes no ContentProvider and declares no
+ permissions of its own. -->
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
stored transition is past, which the launch sync below covers.
+ val started = AtomicBoolean(false)
+ entryPoint.providerResolver().onModeChanged {
+ if (started.get()) {
+ scope.launch { runCatching { scheduler.sync() } }
+ runCatching { entryPoint.taskNotifier().refreshChannel() }
+ }
+ }
+ startupGate.start()
+ refreshNotificationChannels()
+ ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
+ scope.launch {
+ // Wait for the stored mode and the import to land first. Rescheduling
+ // alarms against whichever store autoMode happens to pick would arm
+ // them off the wrong one — or off an empty one, mid-import.
+ runCatching {
+ startupGate.awaitReady()
+ started.set(true)
+ scheduler.sync()
+ }
+ runCatching { entryPoint.taskWidgetUpdater().start() }
+ runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
}
}
- @EntryPoint
+ override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
+ super.onConfigurationChanged(newConfig)
+ refreshNotificationChannels()
+ // Language or dark mode changed: the widget's strings and list colours follow.
+ runCatching {
+ EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java).taskWidgetUpdater().requestRefresh()
+ }
+ }
+
+ /** Channel names are stored by the system in whatever language created them. */
+ private fun refreshNotificationChannels() {
+ val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
+ runCatching {
+ entryPoint.taskNotifier().refreshChannel()
+ entryPoint.syncNoticeNotifier().refreshChannel()
+ }
+ }
+
+ @EntryPoint
@InstallIn(SingletonComponent::class)
- interface ReminderEntryPoint {
+ interface AppEntryPoint {
fun reminderScheduler(): ReminderScheduler
+ fun startupGate(): StartupGate
+ fun providerResolver(): ProviderResolver
+
+ @ApplicationScope
+ fun applicationScope(): CoroutineScope
+ fun databaseCheckpoint(): DatabaseCheckpoint
+ fun taskNotifier(): TaskNotifier
+ fun syncNoticeNotifier(): SyncNoticeNotifier
+ fun taskWidgetUpdater(): TaskWidgetUpdater
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
index 0d18703..cb68472 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
@@ -3,23 +3,40 @@ package de.jeanlucmakiola.agendula
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import android.text.format.DateFormat
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.net.toUri
+import de.jeanlucmakiola.agendula.data.prefs.is24Hour
+import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek
+import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
+import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
-import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
+import de.jeanlucmakiola.agendula.data.di.LaunchHook
+import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
+import de.jeanlucmakiola.agendula.domain.SmartList
+import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
+import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
+import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
+import de.jeanlucmakiola.agendula.data.sync.AccountRepository
+import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
+import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
+import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
@@ -30,19 +47,30 @@ import javax.inject.Inject
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
- * (replaceable) functional scaffold over the real data layer. Task-detail intent
- * routing for reminder taps lands with the full UI.
+ * (replaceable) functional scaffold over the real data layer. Notification taps
+ * arrive as a [NavRequest] (see [navRequestOf]).
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
- @Inject lateinit var demoSeeder: DemoSeeder
+ @Inject lateinit var launchHooks: Set<@JvmSuppressWildcards LaunchHook>
+
+ @Inject lateinit var accounts: AccountRepository
+
+ @Inject lateinit var syncTrigger: SyncTrigger
+
+ @Inject lateinit var pendingLoginFlows: PendingLoginFlowStore
+
+ @Inject lateinit var push: PushRegistrar
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf(null)
+ // A notification tap's destination, handed to the nav host and cleared once taken.
+ private var navRequest by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -56,14 +84,44 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
+ AppShortcuts.publish(this)
+
+ // Only on a fresh launch: after a rotation or process restore the intent
+ // is the same old one and the back stack already holds its destination.
+ val fromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
+ if (savedInstanceState == null && !fromHistory) navRequest = navRequestOf(intent)
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
- // Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
- // local (non-syncing) demo list once; no-op without the extra.
- if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
- lifecycleScope.launch { runCatching { demoSeeder.seed() } }
+ // Sync hard on app open: the periodic worker's interval is a floor, and
+ // in the `rare` and `restricted` App Standby buckets it may not have run
+ // at all. `KEEP` makes rescheduling idempotent, so this also repairs a
+ // schedule lost to "clear app data" or to a restore.
+ //
+ // ⚠️ Only on a genuine open. This activity declares no `configChanges`,
+ // so onCreate runs again on every rotation, theme switch, locale change
+ // and font-scale change — each of which would otherwise start a fresh
+ // network sync the moment the previous one finished.
+ if (savedInstanceState == null) {
+ lifecycleScope.launch {
+ runCatching {
+ accounts.rescheduleAll()
+ accounts.syncable().forEach { syncTrigger.enqueue(it.displayName) }
+ // A login flow the previous process died in the middle of.
+ // Its password, if the user approved, exists nowhere else.
+ pendingLoginFlows.reclaim()
+ }
+ // Last and on its own: it waits on the network, and must not
+ // hold up the reclaim above. Re-registering on open is what
+ // the connector recommends.
+ runCatching { push.updateAll() }
+ }
+ }
+
+ // Variant hooks: the demo seeder in debug builds, nothing in release.
+ if (savedInstanceState == null) {
+ lifecycleScope.launch { launchHooks.forEach { runCatching { it.onLaunch(intent) } } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
@@ -73,26 +131,44 @@ class MainActivity : ComponentActivity() {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
+ val context = LocalContext.current
+ val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
+ val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
- RootScreen(modifier = Modifier.fillMaxSize())
- pendingCrashReport?.let { report ->
- CrashReportDialog(
- report = report,
- onSend = {
- submitCrashReport(this@MainActivity, report)
- CrashReporter.clearReport(this@MainActivity)
- pendingCrashReport = null
- },
- onDismiss = {
- CrashReporter.dismissPrompt(this@MainActivity)
- pendingCrashReport = null
- },
+ CompositionLocalProvider(
+ LocalUse24HourFormat provides use24Hour,
+ LocalFirstDayOfWeek provides firstDayOfWeek,
+ ) {
+ RootScreen(
+ modifier = Modifier.fillMaxSize(),
+ navRequest = navRequest,
+ onNavRequestConsumed = { navRequest = null },
)
+ pendingCrashReport?.let { report ->
+ CrashReportDialog(
+ report = report,
+ onSend = {
+ submitCrashReport(this@MainActivity, report)
+ CrashReporter.clearReport(this@MainActivity)
+ pendingCrashReport = null
+ },
+ onDismiss = {
+ CrashReporter.dismissPrompt(this@MainActivity)
+ pendingCrashReport = null
+ },
+ )
+ }
}
}
}
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ navRequestOf(intent)?.let { navRequest = it }
+ }
+
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
@@ -102,13 +178,79 @@ class MainActivity : ComponentActivity() {
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
- private const val EXTRA_SEED = "agendula_seed"
+ const val EXTRA_OCCURRENCE_START = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
+ private const val EXTRA_OPEN_ACCOUNTS = "de.jeanlucmakiola.agendula.extra.OPEN_ACCOUNTS"
+ const val ACTION_NEW_TASK = "de.jeanlucmakiola.agendula.action.NEW_TASK"
+ const val ACTION_TODAY = "de.jeanlucmakiola.agendula.action.TODAY"
+ private const val ACTION_OPEN_SMART = "de.jeanlucmakiola.agendula.action.OPEN_SMART"
+ private const val ACTION_OPEN_LIST = "de.jeanlucmakiola.agendula.action.OPEN_LIST"
+ private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
+ private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
+ private const val SHARED_TITLE_LIMIT = 500
+ private const val NO_OCCURRENCE = -1L
- /** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
- fun taskIntent(context: Context, taskId: Long): Intent =
+ /**
+ * Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
+ * picks the occurrence of a recurring task.
+ */
+ fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
+ putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
+
+ /** Opens Settings → Accounts, where the sync notice's detail lives. */
+ fun openIntent(context: Context): Intent =
+ Intent(context, MainActivity::class.java)
+ .putExtra(EXTRA_OPEN_ACCOUNTS, true)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ /** The launcher shortcut's and Quick Settings tile's "New task". */
+ fun newTaskIntent(context: Context): Intent =
+ Intent(ACTION_NEW_TASK, null, context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ fun todayIntent(context: Context): Intent =
+ Intent(ACTION_TODAY, null, context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ /** Opens one smart list; the data URI keeps each widget's PendingIntent apart. */
+ fun smartListIntent(context: Context, list: SmartList): Intent =
+ Intent(ACTION_OPEN_SMART, "agendula://smart/${list.name}".toUri(), context, MainActivity::class.java)
+ .putExtra(EXTRA_SMART_LIST, list.name)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ /** Opens one real list. */
+ fun listIntent(context: Context, listId: Long): Intent =
+ Intent(ACTION_OPEN_LIST, "agendula://list/$listId".toUri(), context, MainActivity::class.java)
+ .putExtra(EXTRA_LIST_ID, listId)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ internal fun navRequestOf(intent: Intent?): NavRequest? {
+ if (intent == null) return null
+ intent.getLongExtra(SyncNoticeNotifier.EXTRA_SIGN_IN_ACCOUNT_ID, -1L).takeIf { it > 0L }
+ ?.let { return NavRequest.OpenAccount(it) }
+ if (intent.getBooleanExtra(EXTRA_OPEN_ACCOUNTS, false)) return NavRequest.OpenAccounts
+ when (intent.action) {
+ ACTION_NEW_TASK -> return NavRequest.NewTask()
+ ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
+ ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
+ ?.let { name -> SmartList.entries.firstOrNull { it.name == name } }
+ ?.let { return NavRequest.OpenSmart(it) }
+ ACTION_OPEN_LIST -> intent.getLongExtra(EXTRA_LIST_ID, -1L).takeIf { it > 0L }
+ ?.let { return NavRequest.OpenList(it) }
+ }
+ importIntentUri(intent)?.let { return NavRequest.Import(it) }
+ if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
+ val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
+ ?: intent.getStringExtra(Intent.EXTRA_TEXT)
+ return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
+ }
+ val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
+ val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
+ .takeIf { it != NO_OCCURRENCE }
+ return NavRequest.OpenTask(taskId, occurrence)
+ }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt
index 7e668e7..38a327c 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
+import androidx.room.Room
+import androidx.room.RoomDatabase
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
@@ -10,29 +12,111 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import de.jeanlucmakiola.agendula.data.sync.AccountCreator
+import de.jeanlucmakiola.agendula.data.sync.AccountRepository
+import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
+import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
+import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
+import de.jeanlucmakiola.agendula.data.sync.SyncOnEdit
+import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
+import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
+import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
+import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
+import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
+import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
+import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
+import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import javax.inject.Provider
import javax.inject.Singleton
+/**
+ * ⚠️ Every one of these needs a corruption handler, and without one a truncated
+ * `preferences_pb` is a **crash at every launch**: `DataStore.data` throws
+ * `CorruptionException` on collection, and the collectors here are root
+ * coroutines in a scope with no handler. A file half-written by a kill during
+ * `edit` is the ordinary way to get one.
+ *
+ * Starting empty is the only recovery available and it is a mild one: the
+ * settings store falls back to defaults, the sync-state store to "never
+ * reconciled", and the credential store to accounts that ask to be signed in
+ * again — all states the app already knows how to be in, unlike a launch loop.
+ */
+private fun replaceCorrupted() = ReplaceFileCorruptionHandler { emptyPreferences() }
+
private val Context.agendulaDataStore: DataStore by preferencesDataStore(
name = "agendula_prefs",
+ corruptionHandler = replaceCorrupted(),
)
+/** See [CredentialsDataStore] for why this is a separate file. */
+private val Context.credentialsDataStore: DataStore by preferencesDataStore(
+ name = CREDENTIALS_DATASTORE,
+ corruptionHandler = replaceCorrupted(),
+)
+
+/** See [SyncStateDataStore] for why this is a separate file. */
+private val Context.syncStateDataStore: DataStore by preferencesDataStore(
+ name = SYNC_STATE_DATASTORE,
+ corruptionHandler = replaceCorrupted(),
+)
+
+/**
+ * Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
+ * exclude `datastore/$CREDENTIALS_DATASTORE.preferences_pb` by this name.
+ */
+const val CREDENTIALS_DATASTORE = "agendula_credentials"
+
+/**
+ * Named here and in `backup_rules.xml` / `data_extraction_rules.xml`, which
+ * exclude `datastore/$SYNC_STATE_DATASTORE.preferences_pb` by this name.
+ */
+const val SYNC_STATE_DATASTORE = "agendula_sync_state"
+
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindModule {
@Binds
@Singleton
- abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
+ abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
@Binds
@Singleton
- abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
+ abstract fun bindProviderEnvironment(impl: AndroidProviderEnvironment): ProviderEnvironment
+
+ @Binds
+ @Singleton
+ abstract fun bindCalDavGateway(impl: OkHttpCalDavGateway): CalDavGateway
+
+ @Binds
+ @Singleton
+ abstract fun bindAccountCreator(impl: AccountRepository): AccountCreator
+
+ @Binds
+ @Singleton
+ abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
+
+ @Binds
+ @Singleton
+ abstract fun bindLocalWriteListener(impl: SyncOnEdit): LocalWriteListener
+
+ // Deliberately unqualified-free of the routing above: this is the external
+ // store itself, for the one caller that has to read it while another store is
+ // the active one.
+ @Binds
+ @Singleton
+ @ExternalStore
+ abstract fun bindExternalTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
}
@Module
@@ -44,7 +128,53 @@ object DataProvideModule {
fun provideDataStore(@ApplicationContext context: Context): DataStore =
context.agendulaDataStore
+ @Provides
+ @Singleton
+ @CredentialsDataStore
+ fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore =
+ context.credentialsDataStore
+
+ @Provides
+ @Singleton
+ @SyncStateDataStore
+ fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore =
+ context.syncStateDataStore
+
+ @Provides
+ @Singleton
+ fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
+ Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
+ // Room's default, stated rather than assumed: Auto Backup copies files
+ // without checkpointing, so a `-wal` sidecar can hold writes the
+ // backed-up `.db` does not. The backup rules carry all three files and
+ // the app checkpoints on ON_STOP.
+ .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
+ .build()
+
+ /**
+ * The active store, chosen by [StorageMode].
+ *
+ * Resolved per injection point rather than bound once, because the mode is a
+ * user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
+ * can change while the process lives. Both implementations are singletons, so
+ * this picks between two long-lived objects rather than building either.
+ */
+ @Provides
+ @Singleton
+ fun provideTasksDataSource(
+ resolver: ProviderResolver,
+ room: Provider,
+ external: Provider,
+ ): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
+
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
+
+ @Provides
+ @Singleton
+ @ApplicationScope
+ fun provideApplicationScope(): CoroutineScope =
+ // SupervisorJob so one failing collector can't take the others down with it.
+ CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt
new file mode 100644
index 0000000..2fc4e52
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/LaunchHook.kt
@@ -0,0 +1,23 @@
+package de.jeanlucmakiola.agendula.data.di
+
+import android.content.Intent
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.Multibinds
+
+/**
+ * Something a build variant wants to run when the app is launched with [Intent].
+ * Release builds contribute none; the debug source set adds the demo seeder, so
+ * that class never ships.
+ */
+fun interface LaunchHook {
+ suspend fun onLaunch(intent: Intent)
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class LaunchHookModule {
+ @Multibinds
+ abstract fun launchHooks(): Set
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt
index 6be87bc..2ee0ee1 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt
@@ -6,3 +6,58 @@ import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
+
+/**
+ * Marks the process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that
+ * outlives any screen and has nothing to be cancelled by, such as keeping the
+ * selected storage mode mirrored out of DataStore. It is never cancelled, so
+ * don't launch anything unbounded in it.
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class ApplicationScope
+
+/**
+ * Marks the DataStore holding **only** the Keystore-encrypted app passwords.
+ *
+ * A separate file from `agendula_prefs` on purpose: Auto Backup includes
+ * `datastore/`, and a restored ciphertext is permanently undecryptable because
+ * Keystore keys are non-exportable. Its own file is what lets the backup rules
+ * exclude the credentials and nothing else — excluding the whole database or
+ * all of DataStore would trade a latent bug for a live one.
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class CredentialsDataStore
+
+/**
+ * Marks the DataStore holding per-device **sync bookkeeping** — the quarantine
+ * counters and the full-reconciliation clock.
+ *
+ * Its own file for the same reason the credentials have one: Auto Backup
+ * includes `datastore/`, and every value in here is a statement about *this*
+ * device's conversation with a server. Restored onto a new install they are all
+ * lies, and two of them are dangerous — a restored "reconciled recently" makes
+ * the engine trust a sync token for another day, which is precisely the silently
+ * pruned change log the full path exists to catch, and a restored quarantine
+ * count silently skips resources that were never tried here.
+ *
+ * Not user data, so nothing is lost by excluding it.
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class SyncStateDataStore
+
+/**
+ * Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
+ * .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
+ * whichever store the active mode selects.
+ *
+ * Only the one-time copy into our own store needs to name a store this way;
+ * everything else goes through the routed source and must keep doing so. Having
+ * it as a binding rather than depending on the concrete class is also what lets
+ * that copy be tested against a fake.
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class ExternalStore
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
new file mode 100644
index 0000000..849dd0e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/ExportWriter.kt
@@ -0,0 +1,133 @@
+package de.jeanlucmakiola.agendula.data.export
+
+import android.content.Context
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.domain.export.ExportDocument
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Where an export ended up, for the UI to report. */
+data class ExportResult(val fileCount: Int, val taskListNames: List)
+
+/**
+ * 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,
+ FOLDER_NOT_WRITABLE,
+ CANNOT_CREATE_FILE,
+ LOST_ACCESS,
+ WRITE_FAILED,
+}
+
+/** The export could not be written. */
+class ExportFailedException(
+ val failure: ExportFailure,
+ cause: Throwable? = null,
+) : IOException(failure.name, cause)
+
+/**
+ * Writes [ExportDocument]s to a user-chosen location through the Storage Access
+ * Framework.
+ *
+ * No storage permission anywhere: SAF hands us a `Uri` the user picked
+ * themselves, which is both the modern approach and the only one that still works
+ * on scoped storage. The caller owns launching `ACTION_CREATE_DOCUMENT` (for
+ * [writeZip]) or `ACTION_OPEN_DOCUMENT_TREE` (for [writeToTree]) and passes the
+ * result here.
+ *
+ * floret-kit material — the plumbing is
+ * not task-domain and Calendula will want the same thing. Kept app-local for now
+ * on the kit's own stated principle of not extracting until a second consumer
+ * actually exists; the seam is here, so moving it later is a file move.
+ */
+@Singleton
+class ExportWriter @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+
+ /**
+ * Writes every document into [treeUri], a directory the user picked.
+ *
+ * A same-named file is truncated and rewritten in place rather than deleted
+ * and recreated: SAF would otherwise append " (1)" and turn the folder into
+ * an unusable pile of snapshots, and a delete that is not followed by a
+ * successful create loses the previous export outright.
+ *
+ * The directory is listed once. `DocumentFile.findFile` queries the whole
+ * tree per call, so looking each name up in the loop is one full
+ * cross-process directory scan per list.
+ */
+ suspend fun writeToTree(treeUri: Uri, documents: List): ExportResult =
+ withContext(io) {
+ runCatching {
+ val tree = DocumentFile.fromTreeUri(context, treeUri)
+ ?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
+ if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
+ val existing = tree.listFiles().associateBy { it.name }
+
+ documents.forEach { document ->
+ val file = existing[document.fileName]
+ ?: tree.createFile(MIME_ICALENDAR, document.fileName)
+ ?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
+ write(file.uri, document.content)
+ }
+ }.getOrElse { throw asExportFailure(it) }
+ ExportResult(documents.size, documents.map { it.fileName })
+ }
+
+ /**
+ * Writes every document into a single zip at [target].
+ *
+ * The one-file form, for sharing or for a backup the user filed somewhere
+ * themselves — one attachment rather than one per list.
+ */
+ suspend fun writeZip(target: Uri, documents: List): ExportResult =
+ withContext(io) {
+ runCatching {
+ context.contentResolver.openOutputStream(target, "wt")?.use { raw ->
+ ZipOutputStream(raw.buffered()).use { zip ->
+ documents.forEach { document ->
+ zip.putNextEntry(ZipEntry(document.fileName))
+ zip.write(document.content)
+ zip.closeEntry()
+ }
+ }
+ } ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
+ }.getOrElse { throw asExportFailure(it) }
+ ExportResult(documents.size, documents.map { it.fileName })
+ }
+
+ private fun write(target: Uri, bytes: ByteArray) {
+ runCatching {
+ // "wt" truncates. Without it a shorter export leaves the tail of the
+ // previous, longer one behind and produces a corrupt file.
+ context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
+ ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
+ }.getOrElse { throw asExportFailure(it) }
+ }
+
+ private fun asExportFailure(cause: Throwable): Throwable = when (cause) {
+ is ExportFailedException -> cause
+ // A SAF grant can be revoked between the picker and the write (the volume
+ // was unmounted, the provider's process died, the user cleared the grant).
+ is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
+ is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
+ else -> cause
+ }
+
+ private companion object {
+ const val MIME_ICALENDAR = "text/calendar"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt
new file mode 100644
index 0000000..dcf40a8
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/export/TaskExporter.kt
@@ -0,0 +1,76 @@
+package de.jeanlucmakiola.agendula.data.export
+
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
+import de.jeanlucmakiola.agendula.domain.export.ExportDocument
+import de.jeanlucmakiola.agendula.domain.export.ExportList
+import de.jeanlucmakiola.agendula.domain.export.ICalendarWriter
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Turns the user's task lists into `.ics` documents.
+ *
+ * Export is a v1 feature rather than a nicety because of where the data now
+ * lives: our own provider is inside the app's private storage, so in Local mode a
+ * user's tasks exist in exactly one place and uninstalling deletes them. On Play,
+ * where most people will never have a sync engine, that is the majority case.
+ *
+ * **One document per list**, because a list is a CalDAV collection and that is the
+ * unit every other client understands. Bundling everything into a single file
+ * would flatten the lists away, and list membership is not recoverable from a
+ * VTODO afterwards.
+ */
+@Singleton
+class TaskExporter @Inject constructor(
+ private val dataSource: TasksDataSource,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+
+ /**
+ * Serialises [listIds] — every visible list when null.
+ *
+ * A list with no tasks still produces a document. An empty `.ics` is a real
+ * answer ("this list is empty"), whereas a missing file is indistinguishable
+ * from the export having gone wrong.
+ */
+ suspend fun export(listIds: Set? = null): List = withContext(io) {
+ dataSource.taskLists()
+ .filter { listIds == null || it.id in listIds }
+ .map { list ->
+ val document = ExportList(
+ listId = list.id,
+ name = list.name,
+ accountName = list.accountName,
+ tasks = dataSource.exportTasks(list.id),
+ )
+ ExportDocument(
+ fileName = fileNameFor(list.name, list.id),
+ content = ICalendarWriter.write(document).toByteArray(Charsets.UTF_8),
+ )
+ }
+ }
+
+ companion object {
+
+ /**
+ * A file name derived from the list name, safe on every filesystem the
+ * user might pick through SAF (including FAT32 on an SD card).
+ *
+ * The list id is appended rather than trusted to be redundant: two lists on
+ * different accounts may share a name, and two exports landing on the same
+ * file would silently lose one of them.
+ */
+ fun fileNameFor(listName: String, listId: Long): String {
+ val safe = listName
+ .map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '-' }
+ .joinToString("")
+ .trim('-')
+ .take(60)
+ .ifBlank { "list" }
+ return "$safe-$listId.ics"
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt
index 4508baf..733745d 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt
@@ -8,25 +8,59 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
+import de.jeanlucmakiola.agendula.data.tasks.StorageMode
+import de.jeanlucmakiola.agendula.domain.Task
+import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
+import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
+import de.jeanlucmakiola.floret.reminders.normalizeReminders
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
import kotlinx.coroutines.flow.Flow
+import java.time.DayOfWeek
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
+/** Clock convention: AUTO follows the device's 24-hour switch. */
+enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
+
+fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
+ TimeFormatPref.AUTO -> systemIs24Hour
+ TimeFormatPref.TWELVE_HOUR -> false
+ TimeFormatPref.TWENTY_FOUR_HOUR -> true
+}
+
+const val DEFAULT_SNOOZE_MINUTES = 10
+
+/** The snooze lengths Settings offers. */
+val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
+
+/** Minutes between background syncs; 0 = only when asked. */
+const val DEFAULT_SYNC_INTERVAL_MINUTES = 240
+
+/** The sync intervals Settings offers; 15 minutes is WorkManager's floor. */
+val SYNC_INTERVAL_PRESETS = listOf(15, 30, 60, 120, 240, 720, 1_440, 0)
+
+/** 09:00. */
+const val DEFAULT_ALL_DAY_REMINDER_MINUTE = 9 * 60
+
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
- /** Default minutes before due to remind; 0 = at due time. */
- val reminderLeadMinutes: Int = 0,
+ /** Default reminders, as minutes before due (0 = at due time); empty = none. */
+ val defaultReminderMinutes: List = listOf(0),
+ /** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
+ val defaultAllDayReminderMinutes: List = listOf(0),
+ /** Local time (minutes from midnight) an all-day task's reminder counts back from. */
+ val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
/** Master switch for due reminders; off clears every scheduled alarm. */
val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
@@ -38,24 +72,52 @@ data class Settings(
*/
val bottomAddBar: Boolean = false,
/**
- * Per-list overrides of [reminderLeadMinutes]: a list present in the map
+ * Per-list overrides of [defaultReminderMinutes]: a list present in the map
* overrides the global default (an empty list = no reminder); absent =
- * inherit. Agendula offers a single reminder, so each override is a
- * one-element (or empty) list.
+ * inherit.
*/
val perListReminderOverride: Map> = emptyMap(),
+ /** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
+ val perListAllDayReminderOverride: Map> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set = emptySet(),
+ val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
+ /** How long a reminder's "Snooze" action puts it off. */
+ val snoozeMinutes: Int = DEFAULT_SNOOZE_MINUTES,
+ val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
+ /** The first day of the week; `null` follows the locale. */
+ val weekStart: DayOfWeek? = null,
+ /** Put the cursor in the title (and raise the keyboard) when a new task opens. */
+ val autofocusTitle: Boolean = true,
+ /** Minutes between background syncs of every account; 0 = manual only. */
+ val syncIntervalMinutes: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
+ /** Take server changes by push when a UnifiedPush distributor is installed. */
+ val pushEnabled: Boolean = true,
+ /** Lists of the current store whose tasks the smart lists (and their counts) leave out. */
+ val hiddenFromSmartLists: Set = emptySet(),
) {
- /** The lead time for a task in [listId]: its override if set, else the global default. */
- fun reminderLeadFor(listId: Long): Int? =
- perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
+ /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
+ fun visibleIn(filter: TaskFilter, tasks: List): List =
+ if (filter is TaskFilter.Smart && hiddenFromSmartLists.isNotEmpty()) {
+ tasks.filter { it.listId !in hiddenFromSmartLists }
+ } else {
+ tasks
+ }
+
+ /** The lead times for a task in [listId]: its override if set, else the global default. */
+ fun reminderLeadsFor(listId: Long): List =
+ perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
+
+ /** The lead times for an all-day task in [listId]. */
+ fun allDayReminderLeadsFor(listId: Long): List =
+ perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore,
+ private val resolver: ProviderResolver,
) {
val settings: Flow = dataStore.data.map { p ->
Settings(
@@ -63,29 +125,108 @@ class SettingsPrefs @Inject constructor(
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
- reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
+ // The single lead of earlier versions carries over until a list is saved.
+ defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
+ ?: listOf(p[REMINDER_LEAD] ?: 0),
+ defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
+ allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
+ ?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
+ perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
+ sortOrder = p[SORT_ORDER]?.let { runCatching { TaskSortOrder.valueOf(it) }.getOrNull() }
+ ?: TaskSortOrder.DUE,
+ snoozeMinutes = p[SNOOZE_MINUTES]?.takeIf { it > 0 } ?: DEFAULT_SNOOZE_MINUTES,
+ timeFormat = p[TIME_FORMAT]?.let { runCatching { TimeFormatPref.valueOf(it) }.getOrNull() }
+ ?: TimeFormatPref.AUTO,
+ autofocusTitle = p[AUTOFOCUS_TITLE] ?: true,
+ syncIntervalMinutes = p[SYNC_INTERVAL]?.takeIf { it == 0 || it >= 15 } ?: DEFAULT_SYNC_INTERVAL_MINUTES,
+ weekStart = p[WEEK_START]?.let { runCatching { DayOfWeek.valueOf(it) }.getOrNull() },
+ pushEnabled = p[PUSH_ENABLED] ?: true,
+ hiddenFromSmartLists = modeOf(p).name.let { mode ->
+ p[SMART_LIST_HIDDEN].orEmpty()
+ .mapNotNull { entry -> entry.substringAfter("$mode:", "").toLongOrNull() }
+ .toSet()
+ },
)
}
+ suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
+
+ suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
+
+ suspend fun setSyncIntervalMinutes(minutes: Int) = dataStore.edit { it[SYNC_INTERVAL] = minutes }
+
+ suspend fun setPushEnabled(enabled: Boolean) = dataStore.edit { it[PUSH_ENABLED] = enabled }
+
+ suspend fun setAutofocusTitle(enabled: Boolean) = dataStore.edit { it[AUTOFOCUS_TITLE] = enabled }
+
+ suspend fun setTimeFormat(pref: TimeFormatPref) = dataStore.edit { it[TIME_FORMAT] = pref.name }
+
+ suspend fun setWeekStart(day: DayOfWeek?) = dataStore.edit {
+ if (day == null) it.remove(WEEK_START) else it[WEEK_START] = day.name
+ }
+
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
- suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
+ suspend fun setDefaultReminderMinutes(minutes: List) = dataStore.edit {
+ it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
+ }
- /** One-time reminder onboarding gate; false until the step has been shown. */
- val reminderOnboardingDone: Flow = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
+ suspend fun setDefaultAllDayReminderMinutes(minutes: List) = dataStore.edit {
+ it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
+ }
- suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
+ suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
+ dataStore.edit { it[ALL_DAY_REMINDER_MINUTE] = minuteOfDay.coerceIn(0, 24 * 60 - 1) }
+
+ /**
+ * Which task store backs the app, or `null` while the user has not chosen —
+ * which is the normal state, since most people never open Settings.
+ *
+ * Kept out of [Settings] on purpose. Everything in there is a rendering
+ * preference collected by the UI; this one selects an authority in the data
+ * layer, is read on paths that must not wait for a whole settings object, and
+ * `null` genuinely means "undecided" rather than "default" — the difference
+ * matters, because undecided is what lets `ProviderResolver.autoMode` keep an
+ * upgrading Posture A user pointed at the provider that holds their data.
+ */
+ val storageMode: Flow = dataStore.data.map { p -> storedMode(p[STORAGE_MODE]) }
+
+ private fun storedMode(stored: String?): StorageMode? = when (stored) {
+ null -> null
+ // 0.3.x's value for the bundled dmfs provider. That store is gone and
+ // its data was imported into OWN, so read it as OWN rather than
+ // letting it fall through to autoMode — someone who chose local
+ // storage explicitly would otherwise be sent to an external provider.
+ "LOCAL" -> StorageMode.OWN
+ else -> runCatching { StorageMode.valueOf(stored) }.getOrNull()
+ }
+
+ /** The store list ids belong to; each store numbers its lists on its own. */
+ private fun modeOf(p: Preferences): StorageMode = storedMode(p[STORAGE_MODE]) ?: resolver.autoMode()
+
+ suspend fun setStorageMode(mode: StorageMode) = dataStore.edit { it[STORAGE_MODE] = mode.name }
+
+ /**
+ * One-time first-run gate; false until the flow has been walked through.
+ *
+ * The key still says `reminder_onboarding_done` — it gated a single reminder
+ * step before the flow grew around it, and renaming it would drag every
+ * existing install back through onboarding.
+ */
+ val onboardingDone: Flow = dataStore.data.map { it[ONBOARDING_DONE] ?: false }
+
+ suspend fun setOnboardingDone() = dataStore.edit { it[ONBOARDING_DONE] = true }
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
@@ -100,6 +241,19 @@ class SettingsPrefs @Inject constructor(
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
+ /** Set (or clear) a list's all-day reminder override. */
+ suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
+ val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
+ current.applyReminderOverride(listId, override)
+ p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
+ }
+
+ suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
+ val entry = "${modeOf(p).name}:$listId"
+ val current = p[SMART_LIST_HIDDEN].orEmpty()
+ p[SMART_LIST_HIDDEN] = if (hidden) current + entry else current - entry
+ }
+
suspend fun setDefaultEditFields(fields: Set) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -109,12 +263,25 @@ class SettingsPrefs @Inject constructor(
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
+ val DEFAULT_REMINDERS = stringPreferencesKey("default_reminder_minutes")
+ val DEFAULT_ALL_DAY_REMINDERS = stringPreferencesKey("default_all_day_reminder_minutes")
+ val LIST_ALL_DAY_REMINDER_OVERRIDE = stringPreferencesKey("list_all_day_reminder_override")
+ val ALL_DAY_REMINDER_MINUTE = intPreferencesKey("all_day_reminder_minute")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
- val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
+ val ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
+ val STORAGE_MODE = stringPreferencesKey("storage_mode")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
+ val SORT_ORDER = stringPreferencesKey("sort_order")
+ val SNOOZE_MINUTES = intPreferencesKey("snooze_minutes")
+ val TIME_FORMAT = stringPreferencesKey("time_format")
+ val WEEK_START = stringPreferencesKey("week_start")
+ val AUTOFOCUS_TITLE = booleanPreferencesKey("autofocus_title")
+ val SYNC_INTERVAL = intPreferencesKey("sync_interval_minutes")
+ val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
+ val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
}
}
@@ -124,3 +291,7 @@ class SettingsPrefs @Inject constructor(
* data migration.
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT
+
+/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
+private fun parseMinutes(stored: String): List =
+ stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/BootReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/BootReceiver.kt
index 242e894..7019503 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/BootReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/BootReceiver.kt
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.data.reminders
+import android.app.AlarmManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -10,20 +11,46 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
-/** Re-arms all reminder alarms after a reboot (alarms don't survive it). */
+/**
+ * Re-arms reminder alarms after a reboot (alarms don't survive it), an app update, a clock or
+ * zone change, or an exact-alarm grant — which only upgrades alarms set after it, so the whole
+ * set is cancelled and armed again.
+ */
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler
+ @Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) {
- if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
+ if (intent.action == AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED) {
+ val pending = goAsync()
+ scope.launch {
+ try {
+ runCatching { scheduler.sync(rearmAll = true) }
+ } finally {
+ pending.finish()
+ }
+ }
+ return
+ }
+ val afterReboot = when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED -> true
+ // All-day reminders fire at a local wall-clock time, so their instant moves with the zone.
+ Intent.ACTION_MY_PACKAGE_REPLACED,
+ Intent.ACTION_TIMEZONE_CHANGED,
+ Intent.ACTION_TIME_CHANGED,
+ -> false
+ else -> return
+ }
+ ReminderMaintenanceWorker.schedule(context)
val pending = goAsync()
scope.launch {
try {
- scheduler.sync()
+ runCatching { scheduler.sync(afterReboot = afterReboot) }
+ if (afterReboot) runCatching { snoozeScheduler.rearm() }
} finally {
pending.finish()
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt
index dba31d7..36995e0 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/DueReminderReceiver.kt
@@ -3,9 +3,12 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
+import de.jeanlucmakiola.agendula.domain.Task
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -24,6 +27,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
+ @Inject lateinit var scheduler: ReminderScheduler
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -33,9 +37,15 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync()
scope.launch {
try {
- if (!settingsPrefs.settings.first().remindersEnabled) return@launch
- val task = runCatching { dataSource.task(taskId) }.getOrNull()
- if (task != null && !task.isClosed) notifier.postDue(task)
+ val triggerAt = intent.getLongExtra(EXTRA_TRIGGER_AT, -1L)
+ val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE, ScheduledReminder.NO_OCCURRENCE)
+ if (triggerAt >= 0L) {
+ runCatching { scheduler.markFired(ScheduledReminder(taskId, triggerAt, occurrence)) }
+ }
+ val settings = settingsPrefs.settings.first()
+ if (!settings.remindersEnabled) return@launch
+ val task = runCatching { dataSource.occurrence(taskId, occurrence) }.getOrNull()
+ if (task != null && !task.isClosed) notifier.postDue(task, settings)
} finally {
pending.finish()
}
@@ -44,8 +54,37 @@ class DueReminderReceiver : BroadcastReceiver() {
companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
+ private const val EXTRA_TRIGGER_AT = "de.jeanlucmakiola.agendula.extra.TRIGGER_AT"
+ private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
- fun intent(context: Context, taskId: Long): Intent =
- Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)
+ /**
+ * The trigger rides in the intent *data*, not just an extra: PendingIntent
+ * identity ignores extras, so two occurrences of the same recurring task
+ * would otherwise collapse into one alarm under FLAG_UPDATE_CURRENT.
+ */
+ fun intent(context: Context, reminder: ScheduledReminder): Intent =
+ Intent(context, DueReminderReceiver::class.java)
+ .setData("agendula://reminder/${reminder.taskId}/${reminder.triggerAt}".toUri())
+ .putExtra(EXTRA_TASK_ID, reminder.taskId)
+ .putExtra(EXTRA_TRIGGER_AT, reminder.triggerAt)
+ .putExtra(EXTRA_OCCURRENCE, reminder.occurrenceStart)
}
}
+
+/**
+ * The occurrence of [taskId] anchored at [occurrenceStart] (epoch millis), or the
+ * task's current one when there is no anchor or it can no longer be found.
+ * [TasksDataSource.task] alone resolves a series to whichever occurrence is
+ * current, which is not necessarily the one a reminder was armed for.
+ */
+internal fun TasksDataSource.occurrence(taskId: Long, occurrenceStart: Long): Task? {
+ val current = task(taskId) ?: return null
+ if (occurrenceStart == ScheduledReminder.NO_OCCURRENCE ||
+ current.occurrenceStart?.toEpochMilliseconds() == occurrenceStart
+ ) {
+ return current
+ }
+ return tasks(TaskQuery(listId = current.listId, includeCompleted = true))
+ .firstOrNull { it.taskId == taskId && it.occurrenceStart?.toEpochMilliseconds() == occurrenceStart }
+ ?: current
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt
index 82c491b..c0c74e9 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ProviderChangeReceiver.kt
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.os.SystemClock
import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -20,10 +22,25 @@ import javax.inject.Inject
class ProviderChangeReceiver : BroadcastReceiver() {
@Inject lateinit var scheduler: ReminderScheduler
+ @Inject lateinit var providerResolver: ProviderResolver
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onReceive(context: Context, intent: Intent) {
+ // The receiver has to stay exported to hear the provider's broadcast, and
+ // the sender holds no permission we could require — so validate the
+ // broadcast itself. Without this, any installed app can spam a full
+ // re-sync (an unbounded provider read) by firing a matching intent.
+ if (intent.action != Intent.ACTION_PROVIDER_CHANGED) return
+ val authority = providerResolver.resolve()?.authority ?: return
+ if (intent.data?.host != authority) return
+ // External sync can fire these in bursts; one re-sync per burst is plenty.
+ val now = SystemClock.elapsedRealtime()
+ synchronized(Companion) {
+ if (now - lastSyncAt < MIN_SYNC_INTERVAL_MS) return
+ lastSyncAt = now
+ }
+
val pending = goAsync()
scope.launch {
try {
@@ -33,4 +50,11 @@ class ProviderChangeReceiver : BroadcastReceiver() {
}
}
}
+
+ private companion object {
+ const val MIN_SYNC_INTERVAL_MS = 10_000L
+
+ @Volatile
+ var lastSyncAt = -MIN_SYNC_INTERVAL_MS
+ }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt
new file mode 100644
index 0000000..368c32f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderActionReceiver.kt
@@ -0,0 +1,99 @@
+package de.jeanlucmakiola.agendula.data.reminders
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.agendula.data.tasks.StartupGate
+import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
+import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Instant
+
+/**
+ * The "Done" and "Snooze" buttons on a reminder, plus the re-show when a snooze
+ * elapses. All app-internal intents, so the receiver is not exported.
+ *
+ * - **Done** completes the task — only the reminded occurrence, for a series.
+ * - **Snooze** hides the notification and arms [ReminderSnoozeScheduler]'s
+ * alarm, which lives outside [ScheduledReminderStore] so no scheduler pass
+ * can cancel it.
+ * - **Show** re-posts it, if the task is still open by then.
+ */
+@AndroidEntryPoint
+class ReminderActionReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var notifier: TaskNotifier
+ @Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
+ @Inject lateinit var reminderScheduler: ReminderScheduler
+ @Inject lateinit var repository: TasksRepository
+ @Inject lateinit var dataSource: TasksDataSource
+ @Inject lateinit var settingsPrefs: SettingsPrefs
+ @Inject lateinit var startupGate: StartupGate
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return
+ val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE, ScheduledReminder.NO_OCCURRENCE)
+ val action = intent.action ?: return
+ if (action == ACTION_DONE || action == ACTION_SNOOZE) notifier.cancel(taskId)
+ val pending = goAsync()
+ scope.launch {
+ try {
+ when (action) {
+ ACTION_DONE -> runCatching {
+ startupGate.awaitReady()
+ val anchor = occurrence.takeIf { it != ScheduledReminder.NO_OCCURRENCE }
+ ?.let(Instant::fromEpochMilliseconds)
+ repository.setCompleted(taskId, anchor, completed = true)
+ reminderScheduler.sync()
+ }
+
+ ACTION_SNOOZE -> runCatching {
+ val minutes = settingsPrefs.settings.first().snoozeMinutes
+ snoozeScheduler.schedule(taskId, occurrence, System.currentTimeMillis() + minutes * 60_000L)
+ }
+
+ ACTION_SHOW -> runCatching {
+ snoozeScheduler.clear(taskId, occurrence)
+ val settings = settingsPrefs.settings.first()
+ if (!settings.remindersEnabled) return@runCatching
+ startupGate.awaitReady()
+ val task = dataSource.occurrence(taskId, occurrence)
+ if (task != null && !task.isClosed) notifier.postDue(task, settings)
+ }
+ }
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+
+ companion object {
+ const val ACTION_DONE = "de.jeanlucmakiola.agendula.reminders.DONE"
+ const val ACTION_SNOOZE = "de.jeanlucmakiola.agendula.reminders.SNOOZE"
+ const val ACTION_SHOW = "de.jeanlucmakiola.agendula.reminders.SHOW"
+
+ private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
+ private const val EXTRA_OCCURRENCE = "de.jeanlucmakiola.agendula.extra.OCCURRENCE_START"
+
+ /**
+ * The data URI is what keeps two reminders' PendingIntents apart —
+ * `filterEquals` never compares extras.
+ */
+ fun intent(context: Context, action: String, taskId: Long, occurrenceStart: Long): Intent =
+ Intent(context, ReminderActionReceiver::class.java)
+ .setAction(action)
+ .setData("agendula://reminder-action/$taskId/$occurrenceStart".toUri())
+ .putExtra(EXTRA_TASK_ID, taskId)
+ .putExtra(EXTRA_OCCURRENCE, occurrenceStart)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt
new file mode 100644
index 0000000..117b982
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiff.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.agendula.data.reminders
+
+/**
+ * What one scheduler pass does to the armed set.
+ *
+ * `previous` is only what the store *believes* is armed. AlarmManager forgets
+ * everything on reboot and force-stop, and a restore brings the store back on a
+ * device that never armed any of it, so each entry is checked against `isArmed`
+ * first. `fired` entries already went off and count as live, so they are
+ * neither re-armed nor re-fired.
+ */
+internal data class ReminderDiff(
+ val cancel: Set,
+ val arm: Set,
+ val keep: Set,
+) {
+ companion object {
+ fun of(
+ previous: Set,
+ desired: Set,
+ fired: Set = emptySet(),
+ isArmed: (ScheduledReminder) -> Boolean,
+ ): ReminderDiff {
+ val live = previous.filterTo(HashSet()) { it in fired || isArmed(it) }
+ return ReminderDiff(
+ cancel = live - desired,
+ arm = desired - live,
+ keep = desired intersect live,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderMaintenanceWorker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderMaintenanceWorker.kt
new file mode 100644
index 0000000..79f7b71
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderMaintenanceWorker.kt
@@ -0,0 +1,43 @@
+package de.jeanlucmakiola.agendula.data.reminders
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import java.util.concurrent.TimeUnit
+
+/**
+ * A daily re-sync under the alarms. The scheduler only arms a rolling window,
+ * so without a pass now and then a user who never opens the app runs off its
+ * far edge; it also repairs alarms a device dropped without a reboot to say so.
+ */
+@HiltWorker
+class ReminderMaintenanceWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted params: WorkerParameters,
+ private val scheduler: ReminderScheduler,
+) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ runCatching { scheduler.sync() }
+ return Result.success()
+ }
+
+ companion object {
+ private const val WORK_NAME = "reminder-maintenance"
+
+ /** Idempotent; every launch may call it. */
+ fun schedule(context: Context) {
+ val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS)
+ .setInitialDelay(1, TimeUnit.DAYS)
+ .build()
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt
new file mode 100644
index 0000000..d341a72
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlanner.kt
@@ -0,0 +1,101 @@
+package de.jeanlucmakiola.agendula.data.reminders
+
+import de.jeanlucmakiola.agendula.data.prefs.Settings
+import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
+import de.jeanlucmakiola.agendula.domain.Task
+import de.jeanlucmakiola.agendula.domain.calendarDate
+import java.time.LocalTime
+import java.time.ZoneId
+import kotlin.time.Instant
+
+/** Which alarms the scheduler wants armed, from the tasks and settings alone. */
+internal object ReminderPlanner {
+
+ const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
+
+ /** How long after its trigger a missed reminder is still worth firing. */
+ const val MISSED_GRACE_MS = 6L * 60 * 60 * 1000 // 6 hours
+
+ /**
+ * Alarms this app will hold at once.
+ *
+ * Android 12+ throws at 500 per uid. Well under it, because the count is
+ * per *uid* and this is not the only thing in the process that can arm
+ * one — and because the alarms nearest in time are the ones that matter,
+ * while the far edge of the window is re-armed by the next sync.
+ */
+ const val MAX_ALARMS = 400
+
+ fun plan(
+ tasks: List,
+ perTask: Map>,
+ settings: Settings,
+ now: Long,
+ zone: ZoneId,
+ ): Set = tasks
+ // One reminder per *occurrence* and lead: a recurring series yields a row
+ // per occurrence, all sharing a taskId, so this is a Set rather than a
+ // taskId-keyed Map.
+ .filter { !it.isClosed && it.due != null }
+ .flatMap { task ->
+ // Reminders set on the task itself win — every one of them, not just
+ // the one the editor shows; otherwise the task's list may override the
+ // global lead, or opt out entirely (override = null). All-day tasks
+ // have their own day-scale defaults.
+ val reminders = perTask[task.taskId].orEmpty().ifEmpty {
+ val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
+ else settings.reminderLeadsFor(task.listId)
+ leads.map { TaskReminder(it) }
+ }
+ reminders.map { reminder ->
+ // A stored reminder says what it counts back from. Ours are always
+ // before due, but an imported dmfs alarm or another client's can be
+ // before *start*.
+ val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
+ ScheduledReminder(
+ taskId = task.taskId,
+ triggerAt = triggerAt(
+ anchor,
+ task.isAllDay,
+ reminder.minutesBefore,
+ settings.allDayReminderMinuteOfDay,
+ zone,
+ ),
+ occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
+ )
+ }
+ }
+ // The lower bound trails `now` so a reminder missed while the device was
+ // off still fires once on boot instead of being silently dropped.
+ .filter { it.triggerAt in (now - MISSED_GRACE_MS)..(now + WINDOW_MS) }
+ // ⚠️ Bounded, soonest first: Android 12+ throws at 500 concurrent exact
+ // alarms per app. What falls off is the far edge of the window, which the
+ // next sync arms as it comes closer.
+ .sortedBy { it.triggerAt }
+ .take(MAX_ALARMS)
+ .toSet()
+
+ /**
+ * When a reminder [leadMinutes] before [anchor] fires.
+ *
+ * ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
+ * storage convention and not a moment — firing off it rang at 02:00 in
+ * Berlin and the evening before in New York. It is read as its calendar date
+ * at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
+ */
+ fun triggerAt(
+ anchor: Instant,
+ allDay: Boolean,
+ leadMinutes: Int,
+ allDayMinuteOfDay: Int,
+ zone: ZoneId,
+ ): Long {
+ val base = if (allDay) {
+ val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
+ anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
+ } else {
+ anchor.toEpochMilliseconds()
+ }
+ return base - leadMinutes.coerceAtLeast(0) * 60_000L
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt
index 4cea316..b1d54ff 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderScheduler.kt
@@ -12,15 +12,19 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
- * The self-scheduled due-reminder engine. Tasks providers don't deliver
- * reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
- * alarm each, within a rolling window. Re-run on app start, boot and provider
- * change; it diffs against [ScheduledReminderStore] so only changed alarms move.
+ * The self-scheduled due-reminder engine. Nothing else delivers task reminders —
+ * not the platform, not a tasks provider — so Agendula reads upcoming due tasks
+ * and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
+ * on app start, on boot, on a store switch, and on an external provider change;
+ * it diffs against [ScheduledReminderStore] so only changed alarms move.
*/
@Singleton
class ReminderScheduler @Inject constructor(
@@ -29,71 +33,127 @@ class ReminderScheduler @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val store: ScheduledReminderStore,
private val providerResolver: ProviderResolver,
+ private val snoozeScheduler: ReminderSnoozeScheduler,
@IoDispatcher private val io: CoroutineDispatcher,
) {
- suspend fun sync() = withContext(io) {
- val provider = providerResolver.resolve()
+ private val syncLock = Mutex()
+
+ /**
+ * Diff the armed alarms against the store and move only what changed.
+ *
+ * Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
+ * and callers overlap (a store switch fires this while the launch sync may
+ * still be running). Two interleaved runs would each write their own set as
+ * the whole truth, leaving the other's alarms armed but unrecorded — never
+ * cancelled, and firing against the wrong store's task ids.
+ */
+ suspend fun sync(afterReboot: Boolean = false, rearmAll: Boolean = false) =
+ withContext(io) { syncLock.withLock { syncLocked(afterReboot, rearmAll) } }
+
+ /** Record that [reminder] went off, so a later pass neither re-arms nor re-fires it. */
+ suspend fun markFired(reminder: ScheduledReminder) =
+ withContext(io) { syncLock.withLock { store.markFired(reminder) } }
+
+ /**
+ * @param afterReboot every alarm is gone, so nothing in the store is armed;
+ * skips asking the system about each one.
+ * @param rearmAll cancel every recorded alarm and arm the set afresh — after an
+ * exact-alarm grant, which leaves alarms set before it inexact.
+ */
+ private suspend fun syncLocked(afterReboot: Boolean, rearmAll: Boolean) {
val settings = settingsPrefs.settings.first()
- if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
+ // Gate on whether the store is readable, not on whether a provider
+ // resolves: our own store deliberately resolves to no provider, so the
+ // latter clears every reminder in the default mode.
+ if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
clearAll()
- return@withContext
+ return
}
- val now = System.currentTimeMillis()
- val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
- .getOrElse { return@withContext }
+ .getOrElse { return }
+ // Per-task leads. One query for all of them.
+ val perTask = runCatching { dataSource.reminders() }.getOrElse { emptyMap() }
+ val desired = ReminderPlanner.plan(
+ tasks = tasks,
+ perTask = perTask,
+ settings = settings,
+ now = System.currentTimeMillis(),
+ zone = ZoneId.systemDefault(),
+ )
- val desired = tasks
- .filter { !it.isClosed && it.due != null }
- .mapNotNull { task ->
- // The task's list may override the global lead, or opt out entirely
- // (override = null), in which case it gets no reminder at all.
- val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
- task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
- }
- .toMap()
- .filterValues { it in now..horizon }
-
- val previous = store.all()
- (previous.keys - desired.keys).forEach { cancel(it) }
- desired.forEach { (taskId, triggerAt) ->
- if (previous[taskId] != triggerAt) schedule(taskId, triggerAt)
+ // ⚠️ Revoking exact alarms cancels them without telling us, and a grant
+ // leaves the old ones inexact — either way the armed set can't be trusted.
+ val canExact = canScheduleExact()
+ val rearm = rearmAll || store.armedExact() != canExact
+ if (rearm) {
+ store.all().forEach { cancel(it) }
+ runCatching { snoozeScheduler.rearm() }
}
- store.replace(desired)
+
+ // ⚠️ The store survives reboot, force-stop and restore; the alarms don't.
+ // Trusting it alone left every reminder after a reboot unarmed for good.
+ val fired = store.fired()
+ val diff = ReminderDiff.of(store.all(), desired, fired) { !afterReboot && !rearm && isArmed(it) }
+ diff.cancel.forEach { cancel(it) }
+ // ⚠️ Only what was actually armed. A throw mid-loop used to skip the
+ // write below entirely, so every alarm set on that pass went unrecorded
+ // — uncancellable, and firing for tasks that no longer exist — and the
+ // exception escaped into BootReceiver's goAsync().
+ val armed = diff.arm.filter { schedule(it) }
+ store.replace(diff.keep + armed, fired = fired intersect desired, exact = canExact)
}
+ /** Whether the system still holds this alarm's PendingIntent. */
+ private fun isArmed(reminder: ScheduledReminder): Boolean = pendingIntent(reminder, create = false) != null
+
private fun alarmManager(): AlarmManager = context.getSystemService(AlarmManager::class.java)
- private fun pendingIntent(taskId: Long, create: Boolean): PendingIntent? {
+ private fun canScheduleExact(): Boolean =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager().canScheduleExactAlarms()
+
+ private fun pendingIntent(reminder: ScheduledReminder, create: Boolean): PendingIntent? {
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
PendingIntent.FLAG_IMMUTABLE
- return PendingIntent.getBroadcast(context, taskId.toInt(), DueReminderReceiver.intent(context, taskId), flags)
+ return PendingIntent.getBroadcast(
+ context,
+ reminder.requestCode,
+ DueReminderReceiver.intent(context, reminder),
+ flags,
+ )
}
- private fun schedule(taskId: Long, triggerAt: Long) {
- val pi = pendingIntent(taskId, create = true) ?: return
+ /** @return whether the alarm is now armed, and so worth recording. */
+ private fun schedule(reminder: ScheduledReminder): Boolean {
+ val triggerAt = reminder.triggerAt
+ val pi = pendingIntent(reminder, create = true) ?: return false
val am = alarmManager()
- val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
- if (canExact) {
- am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
- } else {
- am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
+ val canExact = canScheduleExact()
+ return try {
+ if (canExact) {
+ am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
+ } else {
+ am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi)
+ }
+ true
+ } catch (_: IllegalStateException) {
+ // The concurrent-alarm ceiling, which [ReminderPlanner.MAX_ALARMS] keeps us under —
+ // but the count is per uid and nothing here owns all of it.
+ false
+ } catch (_: SecurityException) {
+ // Exact-alarm permission revoked between the check and the call.
+ false
}
}
- private fun cancel(taskId: Long) {
- pendingIntent(taskId, create = false)?.let {
+ private fun cancel(reminder: ScheduledReminder) {
+ pendingIntent(reminder, create = false)?.let {
alarmManager().cancel(it)
it.cancel()
}
}
private suspend fun clearAll() {
- store.all().keys.forEach { cancel(it) }
- store.replace(emptyMap())
- }
-
- private companion object {
- const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
+ store.all().forEach { cancel(it) }
+ store.replace(emptySet(), fired = emptySet())
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderSnoozeScheduler.kt
new file mode 100644
index 0000000..2e3154d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ReminderSnoozeScheduler.kt
@@ -0,0 +1,77 @@
+package de.jeanlucmakiola.agendula.data.reminders
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.os.Build
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * A one-off alarm that re-shows a snoozed reminder. Deliberately separate from
+ * [ReminderScheduler]'s diffed set: the reminder it re-shows has already fired,
+ * so the next scheduler pass would otherwise treat it as stale and cancel it.
+ *
+ * Pending snoozes are persisted, since alarms do not survive a reboot; [rearm]
+ * puts them back, and one whose time passed while the device was off fires at once.
+ */
+@Singleton
+class ReminderSnoozeScheduler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val dataStore: DataStore,
+) {
+ suspend fun schedule(taskId: Long, occurrenceStart: Long, triggerAtMillis: Long) {
+ dataStore.edit { p ->
+ p[KEY] = p[KEY].orEmpty().filterNot { it.startsWith("$taskId|$occurrenceStart|") }.toSet() +
+ "$taskId|$occurrenceStart|$triggerAtMillis"
+ }
+ arm(taskId, occurrenceStart, triggerAtMillis)
+ }
+
+ /** Forget a snooze once it has re-shown. */
+ suspend fun clear(taskId: Long, occurrenceStart: Long) {
+ dataStore.edit { p ->
+ p[KEY] = p[KEY].orEmpty().filterNot { it.startsWith("$taskId|$occurrenceStart|") }.toSet()
+ }
+ }
+
+ suspend fun rearm() {
+ val now = System.currentTimeMillis()
+ dataStore.data.first()[KEY].orEmpty().forEach { entry ->
+ val parts = entry.split('|').map { it.toLongOrNull() }
+ val (taskId, occurrence, triggerAt) = parts.takeIf { it.size == 3 && null !in it } ?: return@forEach
+ arm(taskId!!, occurrence!!, maxOf(triggerAt!!, now))
+ }
+ }
+
+ private fun arm(taskId: Long, occurrenceStart: Long, triggerAtMillis: Long) {
+ val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ taskId.toInt(),
+ ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, taskId, occurrenceStart),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
+ try {
+ if (canExact) {
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
+ } else {
+ alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
+ }
+ } catch (_: SecurityException) {
+ alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
+ }
+ }
+
+ private companion object {
+ /** `taskId|occurrenceStart|triggerAt` per pending snooze. */
+ val KEY = stringSetPreferencesKey("pending_snoozes")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt
index 1331b0a..1edd1be 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/ScheduledReminderStore.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
@@ -9,29 +10,80 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
- * Remembers which task reminders are currently scheduled (taskId → trigger time),
- * so [ReminderScheduler] can diff against a fresh computation and cancel only the
- * alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
+ * One armed alarm. A recurring task has many occurrences sharing a [taskId], so
+ * the trigger time is part of the identity — keying by task alone would collapse
+ * a daily task down to a single reminder.
+ */
+data class ScheduledReminder(
+ val taskId: Long,
+ val triggerAt: Long,
+ /** The occurrence's `RECURRENCE-ID` anchor in epoch millis, or [NO_OCCURRENCE]. */
+ val occurrenceStart: Long = NO_OCCURRENCE,
+) {
+ /**
+ * Request code for this alarm's PendingIntent. Derived from both fields so
+ * sibling occurrences don't share (and overwrite) one alarm slot.
+ */
+ val requestCode: Int get() = (taskId * 31 + triggerAt).hashCode()
+
+ companion object {
+ const val NO_OCCURRENCE = -1L
+ }
+}
+
+/**
+ * Remembers which task reminders are currently armed, so [ReminderScheduler] can
+ * diff against a fresh computation and touch only the alarms that changed, and
+ * which of them already fired. Persisted in DataStore as sets of
+ * `taskId|trigger|occurrence` strings.
*/
@Singleton
class ScheduledReminderStore @Inject constructor(
private val dataStore: DataStore,
) {
- suspend fun all(): Map =
- dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
- val parts = entry.split('|')
- val id = parts.getOrNull(0)?.toLongOrNull()
- val at = parts.getOrNull(1)?.toLongOrNull()
- if (id != null && at != null) id to at else null
- }.toMap()
+ suspend fun all(): Set = read(KEY)
- suspend fun replace(scheduled: Map) {
+ /** Reminders that went off, and so must not be armed again while still desired. */
+ suspend fun fired(): Set = read(FIRED_KEY)
+
+ /** Whether the last pass could arm exact alarms; null before the first pass that recorded it. */
+ suspend fun armedExact(): Boolean? = dataStore.data.first()[EXACT_KEY]
+
+ suspend fun replace(
+ scheduled: Set,
+ fired: Set? = null,
+ exact: Boolean? = null,
+ ) {
dataStore.edit { prefs ->
- prefs[KEY] = scheduled.entries.map { "${it.key}|${it.value}" }.toSet()
+ prefs[KEY] = encode(scheduled)
+ if (fired != null) prefs[FIRED_KEY] = encode(fired)
+ if (exact != null) prefs[EXACT_KEY] = exact
}
}
- private companion object {
- val KEY = stringSetPreferencesKey("scheduled_reminders")
+ suspend fun markFired(reminder: ScheduledReminder) {
+ dataStore.edit { prefs ->
+ prefs[FIRED_KEY] = prefs[FIRED_KEY].orEmpty() + encode(setOf(reminder))
+ }
+ }
+
+ private suspend fun read(key: Preferences.Key>): Set =
+ dataStore.data.first()[key].orEmpty().mapNotNull(::decode).toSet()
+
+ internal companion object {
+ private val KEY = stringSetPreferencesKey("scheduled_reminders")
+ private val FIRED_KEY = stringSetPreferencesKey("fired_reminders")
+ private val EXACT_KEY = booleanPreferencesKey("reminders_armed_exact")
+
+ fun encode(set: Set): Set =
+ set.mapTo(HashSet()) { "${it.taskId}|${it.triggerAt}|${it.occurrenceStart}" }
+
+ fun decode(entry: String): ScheduledReminder? {
+ val parts = entry.split('|')
+ val id = parts.getOrNull(0)?.toLongOrNull() ?: return null
+ val at = parts.getOrNull(1)?.toLongOrNull() ?: return null
+ val occ = parts.getOrNull(2)?.toLongOrNull() ?: ScheduledReminder.NO_OCCURRENCE
+ return ScheduledReminder(id, at, occ)
+ }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt
index 4780f1c..d7f087d 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/reminders/TaskNotifier.kt
@@ -3,36 +3,59 @@ package de.jeanlucmakiola.agendula.data.reminders
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
+import android.text.format.DateFormat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
+import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
+import de.jeanlucmakiola.agendula.data.prefs.Settings
+import de.jeanlucmakiola.agendula.data.prefs.is24Hour
+import de.jeanlucmakiola.agendula.data.di.ApplicationScope
+import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
+import de.jeanlucmakiola.agendula.data.tasks.StartupGate
+import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.domain.Task
+import de.jeanlucmakiola.agendula.domain.calendarDate
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
import java.time.Instant as JInstant
+import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
+import java.time.temporal.ChronoUnit
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
+import kotlin.time.Instant
/**
- * Posts one notification per due task on a dedicated channel. The tag is the
- * task id, so a re-fired alarm replaces rather than duplicates. Tapping opens
- * the app (later: the task's detail screen — see [MainActivity]).
+ * Posts one notification per due task on its list's channel, so each list can
+ * have its own sound and importance. The tag is the task id, so a re-fired alarm
+ * replaces rather than duplicates. Tapping opens the task's detail screen (see
+ * [MainActivity.taskIntent]).
*/
@Singleton
class TaskNotifier @Inject constructor(
@ApplicationContext private val context: Context,
+ private val resolver: ProviderResolver,
+ private val dataSource: TasksDataSource,
+ private val startupGate: StartupGate,
+ @ApplicationScope private val scope: CoroutineScope,
) {
+ private val manager get() = context.getSystemService(NotificationManager::class.java)
+ private val channelPrefs get() = context.getSharedPreferences(CHANNEL_PREFS, Context.MODE_PRIVATE)
+
fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -42,20 +65,32 @@ class TaskNotifier @Inject constructor(
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
@SuppressLint("MissingPermission")
- fun postDue(task: Task) {
+ fun postDue(task: Task, settings: Settings) {
if (!canPost()) return
- ensureChannel()
+ val channelId = ensureListChannel(task.listId, task.listName)
val title = task.title.ifBlank { context.getString(R.string.task_untitled) }
val text = task.due?.let { due ->
- context.getString(R.string.reminder_due_at, formatDue(due.toEpochMilliseconds(), task.isAllDay))
+ context.getString(
+ R.string.reminder_due_at,
+ formatReminderDue(
+ due.toEpochMilliseconds(),
+ task.isAllDay,
+ is24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context)),
+ relative = RelativeDays(
+ today = context.getString(R.string.reminder_day_today),
+ tomorrow = context.getString(R.string.reminder_day_tomorrow),
+ yesterday = context.getString(R.string.reminder_day_yesterday),
+ ),
+ ),
+ )
}
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
- MainActivity.taskIntent(context, task.taskId),
+ MainActivity.taskIntent(context, task.taskId, task.occurrenceStart?.toEpochMilliseconds()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.apply { if (text != null) setContentText(text) }
@@ -63,36 +98,145 @@ class TaskNotifier @Inject constructor(
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(tapIntent)
+ .addAction(0, context.getString(R.string.reminder_action_done), actionIntent(task, ReminderActionReceiver.ACTION_DONE))
+ .addAction(
+ 0,
+ context.getString(R.string.reminder_action_snooze, settings.snoozeMinutes),
+ actionIntent(task, ReminderActionReceiver.ACTION_SNOOZE),
+ )
.build()
NotificationManagerCompat.from(context).notify(task.taskId.toString(), NOTIFICATION_ID, notification)
}
- private fun formatDue(millis: Long, allDay: Boolean): String {
- val zone = ZoneId.systemDefault()
- val style = if (allDay) {
- DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
- } else {
- DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
- }
- return JInstant.ofEpochMilli(millis).atZone(zone)
- .format(style.withLocale(Locale.getDefault()))
+ fun cancel(taskId: Long) {
+ NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
}
- private fun ensureChannel() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
- val manager = context.getSystemService(NotificationManager::class.java)
- if (manager.getNotificationChannel(CHANNEL_ID) != null) return
+ private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
+ context,
+ task.taskId.toInt(),
+ ReminderActionReceiver.intent(
+ context,
+ action,
+ task.taskId,
+ task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
+ ),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+
+ /**
+ * Creates [listId]'s channel, or renames it after the list, and returns its id.
+ * A new channel starts at the importance the old shared channel had, so
+ * a user who silenced reminders before per-list channels stays silenced.
+ */
+ fun ensureListChannel(listId: Long, listName: String?): String {
+ ensureGroup()
+ val id = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_$listId"
+ val importance = manager.getNotificationChannel(id)?.importance ?: legacyImportance()
manager.createNotificationChannel(
NotificationChannel(
- CHANNEL_ID,
- context.getString(R.string.reminder_channel_name),
- NotificationManager.IMPORTANCE_HIGH,
- ).apply { description = context.getString(R.string.reminder_channel_desc) },
+ id,
+ listName?.takeIf { it.isNotBlank() } ?: context.getString(R.string.reminder_channel_name),
+ importance,
+ ).apply { group = GROUP_ID },
+ )
+ return id
+ }
+
+ /** The old shared channel's importance, remembered past its deletion for lists made later. */
+ private fun legacyImportance(): Int =
+ manager.getNotificationChannel(LEGACY_CHANNEL_ID)?.importance
+ ?: channelPrefs.getInt(KEY_LEGACY_IMPORTANCE, NotificationManager.IMPORTANCE_HIGH)
+
+ private fun ensureGroup() {
+ manager.createNotificationChannelGroup(
+ NotificationChannelGroup(GROUP_ID, context.getString(R.string.reminder_channel_name))
+ .apply { description = context.getString(R.string.reminder_channel_desc) },
)
}
- private companion object {
- const val CHANNEL_ID = "task_reminders"
- const val NOTIFICATION_ID = 1
+ /**
+ * Brings the channels in line with the current store's lists: one per list,
+ * named after it and the group in the current language, none for a list that
+ * is gone. Retires the old shared channel once its lists have theirs.
+ */
+ fun refreshChannel() {
+ scope.launch {
+ runCatching {
+ startupGate.awaitReady()
+ val lists = dataSource.taskLists()
+ lists.forEach { ensureListChannel(it.id, it.name) }
+ val prefix = "$LIST_CHANNEL_PREFIX${resolver.mode().name.lowercase()}_"
+ val live = lists.map { "$prefix${it.id}" }.toSet()
+ manager.notificationChannels
+ .filter { it.id.startsWith(prefix) && it.id !in live }
+ .forEach { manager.deleteNotificationChannel(it.id) }
+ manager.getNotificationChannel(LEGACY_CHANNEL_ID)?.let { legacy ->
+ channelPrefs.edit { putInt(KEY_LEGACY_IMPORTANCE, legacy.importance) }
+ manager.deleteNotificationChannel(LEGACY_CHANNEL_ID)
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val GROUP_ID = "task_reminders"
+ private const val LEGACY_CHANNEL_ID = "task_reminders"
+ private const val LIST_CHANNEL_PREFIX = "reminders_"
+ private const val NOTIFICATION_ID = 1
+ private const val CHANNEL_PREFS = "reminder_channels"
+ private const val KEY_LEGACY_IMPORTANCE = "legacy_importance"
+
+ /**
+ * Whether a reminder can sound anywhere: the group is not blocked and at
+ * least one reminder channel is on. No channel yet counts as on.
+ */
+ fun remindersAudible(manager: NotificationManager): Boolean {
+ if (manager.getNotificationChannelGroup(GROUP_ID)?.isBlocked == true) return false
+ val channels = manager.notificationChannels.filter { it.group == GROUP_ID || it.id == LEGACY_CHANNEL_ID }
+ return channels.isEmpty() || channels.any { it.importance != NotificationManager.IMPORTANCE_NONE }
+ }
+ }
+}
+
+/** The words a reminder uses for the days either side of today. */
+internal class RelativeDays(val today: String, val tomorrow: String, val yesterday: String)
+
+/**
+ * The due line of a reminder. An all-day due is UTC midnight of its date, so it
+ * is read as that date rather than in the device zone, where it lands on the
+ * previous day west of Greenwich. With [relative], yesterday to tomorrow read as
+ * words and the rest of the coming week as its weekday.
+ */
+internal fun formatReminderDue(
+ millis: Long,
+ allDay: Boolean,
+ zone: ZoneId = ZoneId.systemDefault(),
+ locale: Locale = Locale.getDefault(),
+ is24Hour: Boolean? = null,
+ relative: RelativeDays? = null,
+ today: LocalDate = LocalDate.now(zone),
+): String {
+ val at = JInstant.ofEpochMilli(millis).atZone(zone)
+ val date = if (allDay) Instant.fromEpochMilliseconds(millis).calendarDate(allDay = true) else at.toLocalDate()
+ val day = relative?.let { relativeDay(date, today, it, locale) }
+ ?: date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale))
+ if (allDay) return day
+ val time = when (is24Hour) {
+ null -> at.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale))
+ else -> at.format(DateTimeFormatter.ofPattern(if (is24Hour) "HH:mm" else "h:mm a", locale))
+ }
+ return "$day, $time"
+}
+
+private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays, locale: Locale): String? {
+ val days = ChronoUnit.DAYS.between(today, date)
+ return when {
+ days == 0L -> words.today
+ days == 1L -> words.tomorrow
+ days == -1L -> words.yesterday
+ days in 2..6 -> date.format(DateTimeFormatter.ofPattern("EEEE", locale))
+ else -> null
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt
new file mode 100644
index 0000000..c77a6d5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt
@@ -0,0 +1,604 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
+import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import de.jeanlucmakiola.caldav.CalDavDiscovery
+import de.jeanlucmakiola.caldav.TaskCollection
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The one thing the sign-in flow needs from [AccountRepository].
+ *
+ * A seam, so the flow's state machine can be tested without a database, a
+ * Keystore or an `AccountManager` — the three things that make the rest of this
+ * class Android-only.
+ */
+interface AccountCreator {
+ suspend fun create(
+ displayName: String,
+ username: String,
+ appPassword: String,
+ found: CalDavDiscovery.Outcome.Found,
+ selected: Set,
+ /** An existing account to sign in again, instead of creating one. */
+ reauthenticating: Long? = null,
+ ): AccountRepository.Outcome
+}
+
+/**
+ * Turns a finished sign-in into an account that exists in all three places it
+ * has to: the Room `accounts` row, the encrypted credential, and the
+ * system-visible `AccountManager` entry.
+ *
+ * The order matters. The Room row comes first because its id keys the
+ * credential, and the system account comes last because it is the one thing a
+ * user can see — an entry in Settings for an account whose credential failed to
+ * store would be a sync that silently never works.
+ */
+@Singleton
+class AccountRepository @Inject constructor(
+ private val database: TasksDatabase,
+ private val credentials: CredentialStore,
+ private val accounts: CalDavAccounts,
+ private val syncTrigger: SyncTrigger,
+ private val cadence: SyncCadenceStore,
+ private val accountState: AccountStateStore,
+ private val quarantine: QuarantineStore,
+ private val notices: SyncNoticeStore,
+ private val collectionSupport: CollectionSupportStore,
+ private val gateway: CalDavGateway,
+ private val availability: SyncAvailability,
+ private val settings: SettingsPrefs,
+ private val push: PushRegistrar,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) : AccountCreator {
+
+ private suspend fun syncInterval(): Int = settings.settings.first().syncIntervalMinutes
+
+ /** What went wrong, in words a user can act on. */
+ sealed interface Outcome {
+ data class Created(val accountId: Long) : Outcome
+ data object AlreadyExists : Outcome
+
+ /** External storage mode is on, and nothing would ever show what this account syncs. */
+ data object ExternalStorage : Outcome
+ data class CredentialFailed(val cause: Cause, val detail: String = "") : Outcome
+
+ /**
+ * Why an account could not be saved, in a form the UI can translate.
+ *
+ * ⚠️ The UI renders *this*, never [CredentialFailed.detail] — which is
+ * a `Throwable.message` and so an untranslated, often unreadable string.
+ */
+ enum class Cause {
+ /** The Keystore refused to hold the password. */
+ KEYSTORE_REFUSED,
+
+ /** Anything else that stopped the write. */
+ NOT_SAVED,
+ }
+ }
+
+ suspend fun all(): List = withContext(io) { database.accounts().all() }
+
+ /** The accounts, observed, so a sync landing updates a screen that is open. */
+ fun observeAll(): Flow> = database.accounts().observeAll()
+
+ /** Every account's synced lists. */
+ fun observeSyncedLists(): Flow> = database.taskLists().observeSynced()
+
+ /**
+ * Creates an account and the task lists the user chose.
+ *
+ * [selected] is a subset of what discovery found; a collection the user did
+ * not tick is simply not created, and can be added later without touching
+ * anything else — `task_lists.account_id` is a nullable FK, so attaching is
+ * an `UPDATE`.
+ */
+ override suspend fun create(
+ displayName: String,
+ username: String,
+ appPassword: String,
+ found: CalDavDiscovery.Outcome.Found,
+ selected: Set,
+ reauthenticating: Long?,
+ ): Outcome = withContext(io) {
+ if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
+ // "Sign in again" names its account, so a login name the server spells
+ // differently from last time still lands on it rather than beside it.
+ // Only on the same server: anything else is a different account.
+ val target = reauthenticating?.let { database.accounts().account(it) }
+ if (target != null && sameServer(target, found)) {
+ return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
+ }
+ // Both stores, not just one. There is no unique index on
+ // accounts.display_name and nothing prunes Room when the system account
+ // disappears, so "removed from system Settings, re-added here" would
+ // otherwise leave a second Room row and a duplicate of every list.
+ val systemAccount = accounts.find(displayName)
+ val existing = database.accounts().all().firstOrNull { it.displayName == displayName }
+
+ // ⚠️ Re-authentication, not a duplicate. An account stopped by a 401 has
+ // no other way back: `create` is the only path that writes a credential,
+ // and refusing it here left the user with "that account is already set
+ // up" and no option but to remove the account — discarding the choice to
+ // keep its lists attached. Narrow on purpose: a *healthy* account of the
+ // same name is still a duplicate, so this can never silently overwrite a
+ // working credential.
+ if (existing != null && accountState.needsSignIn(existing.id)) {
+ return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
+ }
+
+ if (existing != null) return@withContext Outcome.AlreadyExists
+
+ // ⚠️ In the system, not in Room: an orphan, not a duplicate. `remove()`
+ // ignores whether the AccountManager entry actually went (and `find`
+ // returns null while the device is locked), so this state is reachable —
+ // and refusing here left the user with an account that cannot be removed
+ // from inside the app at all, since the accounts screen is driven off
+ // Room. Clearing it up is kinder than refusing for ever.
+ systemAccount?.let { accounts.remove(it) }
+
+ // ⚠️ Uncancellable as a whole. The row, its lists, the credential and
+ // the system entry are four stores that cannot share a transaction, and
+ // the caller is a viewModelScope tied to the Settings destination — a
+ // back gesture during "Adding the account" would otherwise leave the row
+ // and its lists with no credential and no system account: the rollback
+ // never runs, `needsSignIn` is false so re-auth will not fire, and every
+ // retry answers AlreadyExists. `remove()` documents the same hazard.
+ withContext(NonCancellable) {
+ val inserted = mutableListOf()
+ val accountId = database.runInTransaction {
+ val id = database.accounts().insert(
+ AccountEntity(
+ displayName = displayName,
+ // Persist where a 301/308 actually put us — dav4jvm#209 exists
+ // precisely so this is knowable, and re-following the redirect
+ // on every sync is what not persisting it costs.
+ principalUrl = (found.movedTo ?: found.principal).toString(),
+ // The principal's own home set. `resolve("./")` on a collection
+ // URL is a no-op — CalDAV hrefs already end in "/" — so the
+ // old version stored the first collection's own URL, and that
+ // collection may not even be from the account's own home set.
+ homeSetUrl = found.homeSets.firstOrNull()?.toString(),
+ username = username,
+ ),
+ )
+ inserted += attach(id, selected)
+ id
+ }
+
+ if (!credentials.put(accountId, appPassword)) {
+ // Never leave a half-made account behind: without a credential it
+ // would sit in Settings failing to sync with nothing to explain it.
+ rollback(accountId, inserted)
+ return@withContext Outcome.CredentialFailed(
+ Outcome.Cause.KEYSTORE_REFUSED,
+ "the device keystore would not store the password",
+ )
+ }
+
+ if (!accounts.add(displayName, accountId)) {
+ credentials.clear(accountId)
+ rollback(accountId, inserted)
+ return@withContext Outcome.AlreadyExists
+ }
+
+ // On the schedule from the moment it exists, and syncing immediately —
+ // an account that shows up empty until the first periodic window looks
+ // broken.
+ syncTrigger.schedule(displayName, syncInterval())
+ syncTrigger.enqueue(displayName)
+
+ Outcome.Created(accountId)
+ }
+ }
+
+ /**
+ * Points [selected] at [accountId], re-attaching what a previous removal
+ * left behind rather than inserting a second copy.
+ *
+ * ⚠️ `remove()` leaves the lists as device-only ones on purpose, so a plain
+ * insert gives the user their old "Personal" full of tasks *and* a freshly
+ * synced "Personal" holding the same tasks from the server. The row keeps
+ * its name, colour, ordering and tasks — the user's, not the server's — and
+ * loses only its cursor, because the account it was reconciled against is
+ * gone.
+ *
+ * ⚠️ On a re-authentication this also has to see the account's *own* lists,
+ * not just detached ones — the re-auth path walks the same picker, so a
+ * lookup that missed them would insert a second copy of every list the
+ * account already syncs. On a create the id was minted a statement earlier,
+ * so nothing is attached to it yet and only detached rows can match.
+ *
+ * @return the ids of the lists this call *created*, which are the only ones
+ * a rollback may delete.
+ */
+ private fun attach(accountId: Long, selected: Set): List {
+ val known = database.taskLists().attachable(accountId).associateBy { it.href }
+ val inserted = mutableListOf()
+ selected.forEach { collection ->
+ val href = collection.url.toString()
+ val current = known[href]
+ if (current == null) {
+ inserted += database.taskLists().insert(
+ TaskListEntity(
+ name = collection.displayName ?: collection.url.pathSegments
+ .lastOrNull { it.isNotEmpty() }
+ .orEmpty(),
+ color = collection.color ?: DEFAULT_LIST_COLOR,
+ accountId = accountId,
+ isReadOnly = collection.readOnly,
+ href = href,
+ ),
+ )
+ } else {
+ // Changing hands, as opposed to re-authenticating the account
+ // that already owns it: a cursor from the previous owner says
+ // nothing about this one, while the current owner's is still
+ // good and throwing it away costs a full reconciliation.
+ val changingHands = current.accountId != accountId
+ database.taskLists().update(
+ current.copy(
+ accountId = accountId,
+ isReadOnly = collection.readOnly,
+ syncToken = current.syncToken.takeUnless { changingHands },
+ ctag = current.ctag.takeUnless { changingHands },
+ ),
+ )
+ }
+ }
+ return inserted
+ }
+
+ /** What the server offers an existing account, beside what it already syncs. */
+ sealed interface Collections {
+ data class Found(
+ val collections: List,
+ /** Hrefs of the collections this account already syncs. */
+ val attached: Set,
+ ) : Collections
+
+ /** The account is stopped, or has no credential we can use. */
+ data object NeedsSignIn : Collections
+
+ data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : Collections
+ }
+
+ /**
+ * Re-runs discovery for [accountId], so lists added on the server after
+ * setup can be picked up and ones that are synced can be dropped.
+ */
+ suspend fun collections(accountId: Long): Collections = withContext(io) {
+ if (accountState.needsSignIn(accountId)) return@withContext Collections.NeedsSignIn
+ val account = database.accounts().account(accountId)
+ ?: return@withContext Collections.Failed(null)
+ val username = account.username ?: return@withContext Collections.NeedsSignIn
+ val principal = account.principalUrl?.toHttpUrlOrNull()
+ ?: return@withContext Collections.Failed(null)
+ val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
+ ?: return@withContext Collections.NeedsSignIn
+ val attached = database.taskLists().syncedForAccount(accountId)
+ .mapNotNull { it.href }
+ .toSet()
+ when (
+ val outcome = gateway.discover(
+ principal.toString(),
+ CalDavGateway.Credentials(username, password, principal),
+ )
+ ) {
+ is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
+ is CalDavDiscovery.Outcome.NeedsAuthentication,
+ CalDavDiscovery.Outcome.Unauthenticated,
+ -> Collections.NeedsSignIn
+ is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
+ is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
+ }
+ }
+
+ /**
+ * Makes [selected] the synced subset of [offered] for [accountId].
+ *
+ * Newly ticked collections are attached and synced. Unticked ones that were
+ * synced go the way an account removal takes its lists: detached as
+ * device-only lists when [keepUnticked], deleted from this device otherwise.
+ * Nothing is deleted on the server either way.
+ */
+ suspend fun setSyncedCollections(
+ accountId: Long,
+ offered: List,
+ selected: Set,
+ keepUnticked: Boolean,
+ ) = withContext(io) {
+ val account = database.accounts().account(accountId) ?: return@withContext
+ withContext(NonCancellable) {
+ val change = collectionChange(
+ offered,
+ selected,
+ database.taskLists().syncedForAccount(accountId),
+ )
+ database.runInTransaction {
+ attach(accountId, change.attach)
+ change.drop.forEach { list ->
+ if (keepUnticked) {
+ database.taskLists().setAccount(list.id, null)
+ } else {
+ database.taskLists().delete(list.id)
+ }
+ }
+ }
+ val dropped = change.drop.map { it.id }.toSet()
+ runCatching { push.forgetLists(accountId, dropped) }
+ cadence.forget(dropped)
+ quarantine.forget(dropped)
+ change.drop.forEach { notices.forgetList(accountId, it.name) }
+ if (change.attach.isNotEmpty()) syncTrigger.enqueue(account.displayName, expedited = true)
+ }
+ }
+
+ /**
+ * Gives a quarantined resource another go: its failure count is cleared and
+ * the account synced. If it fails again it is counted afresh, and the notice
+ * comes back once it reaches the threshold again.
+ */
+ suspend fun retryQuarantined(accountId: Long, key: String) = withContext(io) {
+ val account = database.accounts().account(accountId) ?: return@withContext
+ val listIds = database.taskLists().syncedForAccount(accountId).map { it.id }.toSet()
+ quarantine.release(listIds, key)
+ notices.forgetQuarantined(accountId, key)
+ syncTrigger.enqueue(account.displayName, expedited = true)
+ }
+
+ /**
+ * Replaces the credential of an account the server had stopped accepting.
+ *
+ * The tasks stay exactly as they are — the password was the only thing that
+ * went stale. Everything the user was asked for on the way here is applied
+ * all the same:
+ *
+ * ⚠️ This branch used to take [appPassword] and drop [found] and [selected]
+ * on the floor. The user walked the whole add flow, ticked collections, and
+ * was shown Done — while no newly-ticked list was created, no unticked one
+ * was detached, and a moved principal or a corrected username was discarded,
+ * so a relocated account could never be repaired. It also never called
+ * `accounts.add`, so an account the user had deleted in Android Settings —
+ * nothing listens for `LOGIN_ACCOUNTS_CHANGED` — stayed absent from Settings
+ * for ever while syncing happily via WorkManager, and every later add
+ * answered AlreadyExists.
+ */
+ private suspend fun reauthenticate(
+ existing: AccountEntity,
+ displayName: String,
+ username: String,
+ appPassword: String,
+ found: CalDavDiscovery.Outcome.Found,
+ selected: Set,
+ ): Outcome {
+ val accountId = existing.id
+ if (!credentials.put(accountId, appPassword)) {
+ return Outcome.CredentialFailed(
+ Outcome.Cause.KEYSTORE_REFUSED,
+ "the device keystore would not store the password",
+ )
+ }
+ withContext(NonCancellable) {
+ database.runInTransaction {
+ database.accounts().update(
+ existing.copy(
+ // Where discovery just found it, which is the only way a
+ // principal that has moved can ever be corrected.
+ principalUrl = (found.movedTo ?: found.principal).toString(),
+ homeSetUrl = found.homeSets.firstOrNull()?.toString()
+ ?: existing.homeSetUrl,
+ username = username,
+ ),
+ )
+ attach(accountId, selected)
+ }
+ // ⚠️ Ticked lists are attached; unticked ones are left alone. The
+ // picker pre-ticks everything *writable*, not everything already
+ // attached, so detaching what is unticked would silently stop
+ // syncing a read-only share the account has synced for months —
+ // over a default the user never chose. Detaching belongs here the
+ // day the picker knows what this account already holds.
+ // The Room row is the account as far as this app is concerned, so a
+ // missing system entry is re-registered rather than left behind.
+ if (accounts.find(displayName) == null) accounts.add(displayName, accountId)
+ accountState.setNeedsSignIn(accountId, false)
+ database.accounts().recordSync(accountId, at = null, error = null)
+ syncTrigger.schedule(displayName, syncInterval())
+ syncTrigger.enqueue(displayName)
+ }
+ return Outcome.Created(accountId)
+ }
+
+ /**
+ * Undoes a half-made account.
+ *
+ * ⚠️ The lists this attempt *created* have to go explicitly.
+ * `task_lists.account_id` is `ON DELETE SET NULL` — deliberately, so
+ * removing a working account never destroys tasks — which means deleting the
+ * account row alone would leave a set of empty device-only lists behind.
+ *
+ * ⚠️ And only those. A list [attach] re-attached was already on the device
+ * and holds the user's tasks; `SET NULL` returns it to being device-only,
+ * which is exactly where it came from.
+ */
+ private fun rollback(accountId: Long, inserted: List) = database.runInTransaction {
+ inserted.forEach { database.taskLists().delete(it) }
+ database.accounts().delete(accountId)
+ }
+
+ /**
+ * Puts every existing account back on the periodic schedule.
+ *
+ * Cheap and idempotent — `KEEP` means an already-scheduled account is left
+ * exactly as it is — so calling it on app open costs nothing and repairs the
+ * one case WorkManager cannot: a schedule lost to "clear app data" or to a
+ * restore onto a device that never ran the account-add flow.
+ */
+ suspend fun rescheduleAll(intervalChanged: Boolean = false) = withContext(io) {
+ val stopped = accountState.needingSignIn()
+ database.accounts().all().forEach { account ->
+ // ⚠️ A stopped account must not come back on the timer. `KEEP` only
+ // keeps work that is unfinished, and CANCELLED counts as finished —
+ // so rescheduling would re-enqueue the very request a 401 removed,
+ // and the next app open would put a dead app password back on a
+ // four-hour loop against a server that throttles by IP.
+ //
+ // This is also where the cancellation happens at all: the engine
+ // cannot cancel from inside the worker it is running in.
+ if (account.id in stopped) {
+ syncTrigger.cancel(account.displayName)
+ } else {
+ syncTrigger.schedule(account.displayName, syncInterval(), intervalChanged)
+ }
+ }
+ }
+
+ /** The accounts a caller may sync right now — stopped ones excluded. */
+ suspend fun syncable(): List = withContext(io) {
+ val stopped = accountState.needingSignIn()
+ database.accounts().all().filterNot { it.id in stopped }
+ }
+
+ /**
+ * Removes an account and everything that keys off it.
+ *
+ * The lists are **not** deleted: `task_lists.account_id` is `ON DELETE SET
+ * NULL`, so they become device-only lists. Removing an account is not an
+ * instruction to destroy the tasks it held.
+ */
+ suspend fun remove(accountId: Long, displayName: String, deleteLocalData: Boolean = false) =
+ withContext(io) {
+ syncTrigger.cancel(displayName)
+ // Before the revocation: the subscriptions can only be removed with
+ // the credential that is about to stop working.
+ withContext(NonCancellable) { runCatching { push.forgetAccount(accountId) } }
+ revokeAppPassword(accountId)
+
+ // ⚠️ Uncancellable from here. Everything below is destructive and
+ // spread over four stores that cannot share a transaction, and the
+ // caller is a viewModelScope tied to the Settings destination — the
+ // user taps Remove, the screen slides away, and a couple of back
+ // gestures kill the scope mid-sequence. Only the DataStore writes can
+ // observe cancellation (every Room DAO here is blocking), so the
+ // realistic landing point is `cadence.forget`: the app password is
+ // already revoked server-side while the row survives holding it, and
+ // the account reads "sign in again" for a credential we ourselves
+ // invalidated. Land further in and the tasks are gone with the row
+ // still there. The tail is three DataStore writes, two deletes and an
+ // AccountManager call — bounded and sub-second, so finishing it is
+ // strictly better than stopping anywhere inside it.
+ withContext(NonCancellable) {
+ // The lists survive as device-only lists, so their cursors must
+ // not: a re-added account would otherwise inherit a "reconciled
+ // recently" that was true of a different account's data.
+ val listIds = database.taskLists().syncedForAccount(accountId)
+ .map { it.id }
+ .toSet()
+ cadence.forget(listIds)
+ // ⚠️ And the quarantine counts, which are keyed the same way and
+ // are just as global. A list re-attached to a new account would
+ // otherwise inherit them, and a resource already at THRESHOLD is
+ // skipped for ever — it never succeeds, so it never clears.
+ quarantine.forget(listIds)
+ // Play's Account Deletion policy does not apply to us — there is
+ // no Agendula account to delete — but "I want it gone from this
+ // device too" is a reasonable thing to want, and it is the only
+ // way to get the tasks off the device without also uninstalling.
+ if (deleteLocalData) database.taskLists().deleteForAccount(accountId)
+
+ accountState.setNeedsSignIn(accountId, false)
+ // Keyed by account id, exactly like the flag above, and just as
+ // orphaned once the row goes: ids are AUTOINCREMENT so they are
+ // never reused, but nothing would ever read or clear these again.
+ notices.dismiss(accountId)
+ // The same reasoning, for what the server said it would let us
+ // create: keyed by an id nothing will ever mention again.
+ collectionSupport.forget(accountId)
+ credentials.clear(accountId)
+ database.accounts().delete(accountId)
+ accounts.find(displayName)?.let { accounts.remove(it) }
+ }
+ }
+
+ /**
+ * Best effort, and before the credential is cleared — it is the credential.
+ *
+ * A failure here is never allowed to stop the removal: the user asked for the
+ * account to go, and a server that is unreachable, or was never a Nextcloud,
+ * is not a reason to keep it.
+ */
+ private suspend fun revokeAppPassword(accountId: Long) {
+ val account = database.accounts().account(accountId) ?: return
+ val username = account.username ?: return
+ val origin = account.principalUrl?.toHttpUrlOrNull() ?: return
+ val password = (credentials.get(accountId) as? CredentialStore.Secret.Present)?.value
+ ?: return
+ // ⚠️ The budget lives on the request itself, in AppPassword.revoke.
+ // Wrapping this in withTimeoutOrNull only *looked* bounded: the call
+ // parks on a socket read that no cancellation can break, and withContext
+ // returns when its block does, so the deadline passed and we waited
+ // anyway — minutes, on a multi-homed host that stalls.
+ try {
+ gateway.revokeAppPassword(
+ CalDavGateway.Credentials(username, password, origin),
+ )
+ } catch (_: CancellationException) {
+ // Deliberately swallowed. If the caller went away mid-revoke we still
+ // want the removal to finish rather than stop half-done; the tail
+ // below runs uncancellable for the same reason.
+ }
+ }
+
+ /** What [setSyncedCollections] has to do: collections to attach, lists to let go. */
+ internal data class CollectionChange(
+ val attach: Set,
+ val drop: List,
+ )
+
+ private fun sameServer(account: AccountEntity, found: CalDavDiscovery.Outcome.Found): Boolean {
+ val stored = account.principalUrl?.toHttpUrlOrNull() ?: return true
+ return stored.host.equals((found.movedTo ?: found.principal).host, ignoreCase = true)
+ }
+
+ internal companion object {
+ /** M3 primary-ish blue; the user recolours a list from its own screen. */
+ const val DEFAULT_LIST_COLOR = 0xFF4C6FFF.toInt()
+
+ /**
+ * Only collections the server still offers are judged. A synced list
+ * missing from [offered] — a share revoked a minute ago, a flaky listing —
+ * is not the user unticking it, and is left for sync to sort out.
+ */
+ fun collectionChange(
+ offered: List,
+ selected: Set,
+ synced: List,
+ ): CollectionChange {
+ val syncedHrefs = synced.mapNotNull { it.href }.toSet()
+ val offeredHrefs = offered.map { it.url.toString() }.toSet()
+ val selectedHrefs = selected.map { it.toString() }.toSet()
+ return CollectionChange(
+ attach = offered.filter {
+ it.url in selected && it.url.toString() !in syncedHrefs
+ }.toSet(),
+ drop = synced.filter { it.href in offeredHrefs && it.href !in selectedHrefs },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt
new file mode 100644
index 0000000..f7414fb
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStore.kt
@@ -0,0 +1,72 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Which accounts the server has stopped accepting.
+ *
+ * Separate from `accounts.last_sync_error` because the two mean different
+ * things to the user and to the engine: an error is "this did not work, we will
+ * try again", while this is "**we have stopped trying** and only you can change
+ * that". Conflating them is how a client ends up retrying a revoked app password
+ * on a timer.
+ *
+ * Lives with the other per-device sync state, and is therefore excluded from
+ * backup — see [SyncStateDataStore]. That is also correct on its own terms: the
+ * credential does not survive a restore either, so a restored "needs sign-in" is
+ * at best redundant and at worst stale.
+ */
+@Singleton
+class AccountStateStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+) {
+
+ suspend fun needingSignIn(): Set = observeNeedingSignIn().first()
+
+ /** Observed, so a 401 during a background sync reaches an open screen. */
+ fun observeNeedingSignIn(): Flow> = dataStore.data.map { prefs ->
+ prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
+ }
+
+ suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
+
+ suspend fun setNeedsSignIn(accountId: Long, needed: Boolean) {
+ dataStore.edit { prefs ->
+ val current = prefs[KEY].orEmpty().toMutableSet()
+ if (needed) current += accountId.toString() else current -= accountId.toString()
+ prefs[KEY] = current
+ // A later stop is news again.
+ if (!needed) prefs[NOTIFIED] = prefs[NOTIFIED].orEmpty() - accountId.toString()
+ }
+ }
+
+ /**
+ * Records that the user has been told [accountId] needs signing in to.
+ *
+ * @return true the first time per stop, so the notification is posted once
+ * rather than on every run the stopped account refuses.
+ */
+ suspend fun markSignInNotified(accountId: Long): Boolean {
+ var fresh = false
+ dataStore.edit { prefs ->
+ val notified = prefs[NOTIFIED].orEmpty()
+ fresh = accountId.toString() !in notified
+ if (fresh) prefs[NOTIFIED] = notified + accountId.toString()
+ }
+ return fresh
+ }
+
+ private companion object {
+ val KEY = stringSetPreferencesKey("accounts_needing_sign_in")
+ val NOTIFIED = stringSetPreferencesKey("accounts_sign_in_notified")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt
new file mode 100644
index 0000000..71039fb
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavAccounts.kt
@@ -0,0 +1,103 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.content.ContentResolver
+import android.content.Context
+import android.os.Bundle
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.agendula.BuildConfig
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Identifiers shared between Kotlin and the two XML descriptors.
+ *
+ * Derived from `applicationId`, so the debug and `releaseTest` builds get their
+ * own account type and authority and can be installed alongside the real app
+ * without their accounts colliding. ⚠️ `res/xml/authenticator.xml` and
+ * `res/xml/sync_adapter.xml` cannot read `BuildConfig`, so they use string
+ * resources generated by `resValue` in `app/build.gradle.kts` — the two must be
+ * changed together.
+ */
+object SyncContract {
+ val ACCOUNT_TYPE: String = BuildConfig.APPLICATION_ID + ".caldav"
+ val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".sync"
+}
+
+/**
+ * Agendula's CalDAV accounts, as the system sees them.
+ *
+ * The Room `accounts` table is the source of truth for everything about an
+ * account; this is only the system-visible half — the entry in Settings, and the
+ * handle the sync framework needs to trigger us.
+ */
+@Singleton
+class CalDavAccounts @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ private val accountManager get() = AccountManager.get(context)
+
+ fun all(): List =
+ accountManager.getAccountsByType(SyncContract.ACCOUNT_TYPE).toList()
+
+ fun find(name: String): Account? = all().firstOrNull { it.name == name }
+
+ /**
+ * Registers [name] with the system and turns sync on for it.
+ *
+ * No password is handed to `AccountManager`: it stores them as plain `TEXT`.
+ * The app password goes to [CredentialStore], keyed by the Room account id.
+ *
+ * @return false if an account with this name already exists
+ */
+ fun add(name: String, roomAccountId: Long): Boolean {
+ val account = Account(name, SyncContract.ACCOUNT_TYPE)
+ val userData = Bundle().apply { putString(KEY_ROOM_ACCOUNT_ID, roomAccountId.toString()) }
+ if (!accountManager.addAccountExplicitly(account, null, userData)) return false
+
+ // All three are among the calls that return silently with no registered
+ // sync adapter — see SyncAdapterService. They work because we register one.
+ ContentResolver.setIsSyncable(account, SyncContract.AUTHORITY, 1)
+ ContentResolver.setSyncAutomatically(account, SyncContract.AUTHORITY, true)
+ return true
+ }
+
+ /**
+ * The Room account id for [account], or null.
+ *
+ * ⚠️ `getUserData` returns null while the device is locked, so a
+ * boot-triggered sync has to wait for unlock rather than treat this as
+ * "account gone".
+ */
+ fun roomAccountId(account: Account): Long? =
+ accountManager.getUserData(account, KEY_ROOM_ACCOUNT_ID)?.toLongOrNull()
+
+ /**
+ * Removes the system-visible account.
+ *
+ * `removeAccountExplicitly` works because we own the account type. The Room
+ * row and the credential are removed by [AccountRepository]; pruning must be
+ * driven by this call and by `AccountManager`'s account-removed broadcast,
+ * never by "absent from the visible set" — `getAccountsByType` returns
+ * nothing while the device is locked, and treating that as removal is how a
+ * restore silently deletes the user's lists.
+ */
+ fun remove(account: Account): Boolean = accountManager.removeAccountExplicitly(account)
+
+ fun requestSync(account: Account) {
+ ContentResolver.requestSync(
+ account,
+ SyncContract.AUTHORITY,
+ Bundle().apply {
+ putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
+ putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
+ },
+ )
+ }
+
+ private companion object {
+ const val KEY_ROOM_ACCOUNT_ID = "roomAccountId"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt
new file mode 100644
index 0000000..05a7bed
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CalDavGateway.kt
@@ -0,0 +1,127 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.caldav.AppPassword
+import de.jeanlucmakiola.caldav.CalDavDiscovery
+import de.jeanlucmakiola.caldav.CalDavHttp
+import de.jeanlucmakiola.caldav.DnsJavaResolver
+import de.jeanlucmakiola.caldav.NextcloudLoginFlow
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The network side of adding an account, behind one interface.
+ *
+ * It exists so the sign-in state machine can be tested. That machine decides
+ * which of five outcomes leads where, when a one-shot app password is spent, and
+ * which host a credential is scoped to — all of which are exactly the sort of
+ * thing that goes wrong quietly, and none of which should require a server to
+ * exercise.
+ */
+interface CalDavGateway {
+
+ /** Discovery against [target], optionally carrying credentials. */
+ suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
+
+ /** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
+ suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
+
+ suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
+
+ /**
+ * Hands an app password back to the server, best effort.
+ *
+ * Without it, uninstalling never revokes anything — the credential we minted
+ * outlives the app in the user's device list.
+ */
+ suspend fun revokeAppPassword(credentials: Credentials): Boolean
+
+ /**
+ * The same, for a password the login flow just minted.
+ *
+ * ⚠️ Separate because [Credentials.origin] means something different here:
+ * the *server root* the flow reported, not a principal URL. Sending it
+ * through [revokeAppPassword] would derive the OCS root as though it were a
+ * principal, and a subpath install's `https://host/nextcloud/` would collapse
+ * to `https://host/` — a DELETE that 404s on every one of them.
+ */
+ suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
+
+ /** Credentials, and the origin whose registrable domain they are scoped to. */
+ data class Credentials(val username: String, val password: String, val origin: HttpUrl)
+}
+
+@Singleton
+class OkHttpCalDavGateway @Inject constructor(
+ @IoDispatcher private val io: CoroutineDispatcher,
+) : CalDavGateway {
+
+ /**
+ * Becomes the app password's **name** in Nextcloud's Settings → Security →
+ * Devices & sessions. OkHttp's default would show `okhttp/4.12.0`, leaving
+ * the user unable to tell what to revoke — which defeats the whole point of
+ * using an app password.
+ */
+ private val userAgent = "Agendula (Android)"
+
+ override suspend fun discover(
+ target: String,
+ credentials: CalDavGateway.Credentials?,
+ ): CalDavDiscovery.Outcome = withContext(io) {
+ val client = credentials?.let {
+ CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
+ } ?: CalDavHttp.anonymous(userAgent)
+ CalDavDiscovery(client, DnsJavaResolver()).discover(target)
+ }
+
+ override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
+ withContext(io) {
+ NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
+ .start(server, now())
+ .getOrNull()
+ }
+
+ /**
+ * ⚠️ The budget lives on the request, as it does for the revocation.
+ * `execute()` parks on a socket read that no cancellation can break, so the
+ * four places that cancel the poll job only stop the *next* request — and
+ * the shared client's ceiling is sized for a multiget, not for a two-second
+ * poll loop against a server that answered a moment ago.
+ */
+ override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
+ withContext(io) {
+ val client = CalDavHttp.anonymous(userAgent).newBuilder()
+ .callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+ NextcloudLoginFlow(client, userAgent).poll(flow, now())
+ }
+
+ override suspend fun revokeAppPassword(
+ credentials: CalDavGateway.Credentials,
+ ): Boolean = withContext(io) {
+ val client = CalDavHttp.authenticated(
+ userAgent, credentials.username, credentials.password, credentials.origin,
+ )
+ AppPassword.revoke(client, credentials.origin)
+ }
+
+ override suspend fun revokeIssuedAppPassword(
+ credentials: CalDavGateway.Credentials,
+ ): Boolean = withContext(io) {
+ val client = CalDavHttp.authenticated(
+ userAgent, credentials.username, credentials.password, credentials.origin,
+ )
+ AppPassword.revokeAt(client, credentials.origin)
+ }
+
+ private fun now() = System.currentTimeMillis() / 1000
+
+ private companion object {
+ /** One poll of a 2s loop. Long enough for a homelab, short enough to cancel. */
+ const val POLL_TIMEOUT_SECONDS = 15L
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt
new file mode 100644
index 0000000..dea78e7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStore.kt
@@ -0,0 +1,72 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import de.jeanlucmakiola.caldav.CollectionSupport
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * What each account's home set answered to OPTIONS, last time we asked.
+ *
+ * ⚠️ A cache, never the answer. The "new task list" affordance is *hidden*
+ * where neither MKCALENDAR nor extended
+ * MKCOL exists, and a picker that has to make a network round trip before it can
+ * draw a row is a picker that stutters — so the last answer is what it draws
+ * with, and [RemoteListRepository] re-asks before it actually writes. A server
+ * that gained the capability in an upgrade, or lost it in a config change, is
+ * then wrong for exactly one glance rather than for ever.
+ */
+@Singleton
+class CollectionSupportStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+) {
+
+ suspend fun get(accountId: Long): CollectionSupport =
+ decode(dataStore.data.first()[KEY].orEmpty())[accountId] ?: CollectionSupport.NONE
+
+ /** Asks [ask], records what it said, and hands it back. */
+ suspend fun refresh(accountId: Long, ask: () -> CollectionSupport): CollectionSupport {
+ val answer = ask()
+ dataStore.edit { prefs ->
+ // Re-read inside `edit`, which DataStore serialises: two accounts
+ // can be asked at once and a snapshot taken outside would drop one.
+ val current = decode(prefs[KEY].orEmpty()).toMutableMap()
+ current[accountId] = answer
+ prefs[KEY] = current.map { (id, support) -> encode(id, support) }.toSet()
+ }
+ return answer
+ }
+
+ suspend fun forget(accountId: Long) {
+ dataStore.edit { prefs ->
+ prefs[KEY] = decode(prefs[KEY].orEmpty())
+ .filterKeys { it != accountId }
+ .map { (id, support) -> encode(id, support) }
+ .toSet()
+ }
+ }
+
+ private fun encode(accountId: Long, support: CollectionSupport): String =
+ "$accountId|${support.mkCalendar}|${support.extendedMkCol}"
+
+ private fun decode(entries: Set): Map =
+ entries.mapNotNull { entry ->
+ val parts = entry.split('|')
+ if (parts.size != FIELDS) return@mapNotNull null
+ val accountId = parts[0].toLongOrNull() ?: return@mapNotNull null
+ accountId to CollectionSupport(
+ mkCalendar = parts[1].toBooleanStrictOrNull() ?: return@mapNotNull null,
+ extendedMkCol = parts[2].toBooleanStrictOrNull() ?: return@mapNotNull null,
+ )
+ }.toMap()
+
+ private companion object {
+ val KEY = stringSetPreferencesKey("collection_support")
+ const val FIELDS = 3
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt
new file mode 100644
index 0000000..4b46d42
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncer.kt
@@ -0,0 +1,1082 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
+import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
+import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
+import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
+import at.bitfire.dav4jvm.exception.UnauthorizedException
+import de.jeanlucmakiola.caldav.ChangeSet
+import de.jeanlucmakiola.caldav.DeleteOutcome
+import de.jeanlucmakiola.caldav.PutOutcome
+import de.jeanlucmakiola.caldav.RemoteCalendar
+import de.jeanlucmakiola.caldav.RemoteRef
+import de.jeanlucmakiola.caldav.ResourceNames
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+/**
+ * Reconciles one task list against one remote collection.
+ *
+ * The order is deliberate and is the whole design:
+ *
+ * 1. **Deletions**, so a tombstone never races the download of the resource it
+ * is about to remove.
+ * 2. **Uploads**, so local work reaches the server before anything can overwrite
+ * it, and so a conflict is discovered while the local edit still exists.
+ * 3. **Downloads**, including everything the write phase decided we now need a
+ * fresh copy of.
+ * 4. **Sweep**, which is the only step that may delete a row it did not see fail
+ * — and so it runs last, on a listing taken before any of the writes. Its
+ * local side, unlike that listing, is read *after* the download.
+ *
+ * ⚠️ **A failed resource must not fail the collection**, the twin of "a failed
+ * collection must not fail the account". Every per-resource outcome below either
+ * counts against [QuarantineStore.THRESHOLD] or is recorded in the report; none
+ * of them abandon the run.
+ */
+class CollectionSyncer(
+ private val store: SyncStore,
+ private val now: () -> Instant = { Clock.System.now() },
+) {
+
+ /**
+ * @param quarantine failure counts keyed by [QuarantineStore.key], mutated in
+ * place so the caller can persist one map for the whole account.
+ */
+ fun sync(
+ list: TaskListEntity,
+ remote: RemoteCalendar,
+ quarantine: MutableMap,
+ fullReconciliationDue: Boolean = true,
+ ): SyncReport {
+ val run = Run(list, remote, quarantine, fullReconciliationDue)
+ return try {
+ run.execute()
+ } catch (e: Exception) {
+ // The collection is lost, the account is not.
+ run.report.copy(failure = e.toString())
+ }
+ }
+
+ /** One collection's pass. Mutable state lives here rather than in the class. */
+ private inner class Run(
+ val list: TaskListEntity,
+ val remote: RemoteCalendar,
+ val quarantine: MutableMap,
+ val fullReconciliationDue: Boolean,
+ ) {
+ var report = SyncReport(listId = list.id, listName = list.name)
+
+ /** Hrefs the write phase wants a fresh copy of. */
+ val refetch = mutableSetOf()
+
+ /**
+ * Edits that will be discarded **once the replacement actually arrives**.
+ *
+ * ⚠️ Reported from [apply], not from the write phase. Announcing the
+ * discard at the moment of the 412 would claim a loss that has not
+ * happened yet — and clearing `is_dirty` there would make it happen for
+ * real if the download then failed, with nothing left to retry.
+ */
+ val pendingDiscard = mutableMapOf()
+
+ /** Hrefs this run wrote, and which the sweep must therefore not remove. */
+ val touched = mutableSetOf()
+
+ /**
+ * Hrefs the write phase gave up on this run, and which must therefore
+ * not be downloaded.
+ *
+ * ⚠️ A resource deferred mid-create still has `href == null` locally, so
+ * [downloadPhase]'s `byHref` lookup cannot see it and its dirty-row guard
+ * cannot fire. Without this the same run downloads the server's copy,
+ * [apply] matches it by UID and overwrites the local edit — silently,
+ * with an empty `discardedEdits` and no failure. Deferring the write only
+ * helps if the read defers with it.
+ */
+ val deferred = mutableSetOf()
+
+ /**
+ * `uid -> parent uid`, resolved to row ids once every row exists.
+ *
+ * The value is nullable and every downloaded resource records one: a
+ * *removed* `RELATED-TO` must clear the link, and a map that only holds
+ * present parents leaves the stale `parent_id` in place — which
+ * [parentUidOf] then resolves back into a `RELATED-TO` and re-uploads.
+ */
+ val parents = mutableMapOf()
+
+ var writable = true
+ var shared = false
+
+ /**
+ * The token seen in the PROPFIND at the start of this run.
+ *
+ * Captured **before** anything is read, and adopted only after a full
+ * reconciliation completes. Taking it afterwards would silently swallow
+ * every change made while we were reading; taking it before merely
+ * re-reports those next time, which costs one ETag comparison.
+ */
+ var pendingToken: String? = null
+
+ fun execute(): SyncReport {
+ val state = remote.state().getOrElse {
+ return report.copy(
+ failure = "collection unavailable: $it",
+ authFailure = it is UnauthorizedException,
+ )
+ }
+ report = report.copy(collectionRead = true, pushSupport = state.collection.push)
+ writable = !state.collection.readOnly
+ shared = state.collection.isShared
+ persistCollectionState(state.collection.readOnly)
+ pendingToken = state.syncToken
+
+ // Local work first, in both paths. A tombstone must never race the
+ // download of the resource it is about to remove, and a conflict has
+ // to be discovered while the local edit still exists.
+ val pending = localResources()
+ deletePhase(pending)
+ uploadPhase(pending)
+
+ val cursor = list.syncToken?.takeIf {
+ // ⚠️ A hint, not a contract — Radicale advertised the report for
+ // years without implementing it.
+ state.collection.supportsSyncCollection && !fullReconciliationDue
+ }
+ if (cursor == null || !runIncremental(cursor)) {
+ // ⚠️ Whatever the incremental attempt recorded is a note about a
+ // path we did not end up using. Leaving it as *the collection's*
+ // failure refuses the new token, skips the cadence record and
+ // shows the user "last sync didn't finish" — on a run that
+ // reconciled the collection perfectly.
+ val attempt = report.failure
+ report = report.copy(failure = null)
+ runFull()
+ if (report.failure == null && attempt != null) {
+ report = report.copy(incrementalNote = attempt)
+ }
+ } else {
+ // The write phase asked for fresh copies the change log may never
+ // mention — a server does not have to echo our own writes back to
+ // us — so they are fetched explicitly rather than hoped for.
+ fetchAndApply(refetch.filterNot { isQuarantined(it) || it in deferred })
+ }
+
+ resolveParents()
+ return report
+ }
+
+ /**
+ * The full path: a complete listing, an ETag diff, and a sweep.
+ *
+ * ⚠️ Not a fallback — a **permanent safety net**. A token the server
+ * accepts over a change log it has already pruned answers 207 with zero
+ * changes and no error, and RFC 6578 offers no way to detect it. Running
+ * this on a slow cadence regardless of the token is the only mitigation
+ * there is.
+ */
+ private fun runFull() {
+ val refs = remote.list().getOrElse {
+ report = report.copy(
+ failure = "listing failed: $it",
+ authFailure = it is UnauthorizedException,
+ )
+ return
+ }
+ // ⚠️ Only strong tags are carried forward. A weak one cannot serve
+ // as `If-Match`, so recording it would make every later write look
+ // conditional while silently not being one.
+ val remoteETags: Map = refs.associate { ref ->
+ ref.href.toString() to ref.eTag?.takeIf { it.usable }?.value
+ }
+
+ downloadPhase(localResources(), remoteETags)
+ // ⚠️ Re-read. The download just ran and may have re-pointed a row's
+ // href; the pre-download list still names the vacated one.
+ sweepPhase(localResources(), remoteETags.keys)
+
+ report = report.copy(reconciledInFull = true)
+ // ⚠️ Adopted only now, and taken from the PROPFIND that *preceded*
+ // the listing. A token minted after the read would silently swallow
+ // anything that changed during it; one minted before merely re-reports
+ // it next time, and a re-report costs an ETag comparison.
+ if (report.failure == null) store.setSyncToken(list.id, pendingToken)
+ }
+
+ /**
+ * The incremental path.
+ *
+ * @return true when the collection is fully reconciled by change log
+ * alone; false to fall back to [runFull] this run.
+ */
+ private fun runIncremental(token: String): Boolean {
+ var cursor = token
+ repeat(MAX_SYNC_PAGES) {
+ val page = when (val changes = remote.changes(cursor)) {
+ is ChangeSet.Page -> changes
+
+ ChangeSet.TokenInvalid -> {
+ // Clear it, so a crash before the full run below does not
+ // leave a token we already know the server rejects.
+ store.setSyncToken(list.id, null)
+ return false
+ }
+
+ ChangeSet.Unsupported -> return false
+
+ is ChangeSet.Failed -> {
+ report = report.copy(failure = "sync-collection failed: ${changes.reason}")
+ return false
+ }
+ }
+
+ if (!removalsArePlausible(page.removed)) return false
+
+ // ⚠️ Bodies before removals, unlike the phase order above. A
+ // delete-and-recreate arrives as `removed: one.ics` +
+ // `changed: two.ics` in one page, and purging first destroys the
+ // row the download would have re-pointed — it comes back with
+ // `sortOrder = 0`, no colour and no parent, and `deletedLocally`
+ // is reported for a task nobody deleted. Downloading first lets
+ // `apply` move the row, after which `one.ics` names nothing and
+ // the removal is the no-op it should be. Nothing is lost the
+ // other way: RFC 6578 reports each resource once, so a page
+ // cannot both change and remove the same href, and a href this
+ // run wrote is already guarded by `touched`.
+ downloadChanged(page.changed)
+ applyRemovals(page.removed)
+
+ // ⚠️ After the bodies, and only if they actually landed. A
+ // network drop mid-multiget would otherwise commit a cursor past
+ // changes that were never downloaded — a permanent hole in the
+ // collection, invisible until the next full reconciliation a day
+ // later. `runFull` has always had this guard; this path did not.
+ if (report.failure != null) return false
+ store.setSyncToken(list.id, page.token)
+
+ val next = page.token
+ when {
+ // A 207 with no token at all. The changes were real; the
+ // cursor is gone, so the next read has to be a full one.
+ next == null -> return false
+
+ !page.truncated -> return true
+
+ // ⚠️ The RFC never requires the token to advance, so a server
+ // that returns the same one forever would loop until the cap.
+ next == cursor -> {
+ report = report.copy(
+ failure = "the server truncated without advancing its sync token",
+ )
+ return false
+ }
+
+ else -> cursor = next
+ }
+ }
+ report = report.copy(failure = "sync-collection did not finish in $MAX_SYNC_PAGES pages")
+ return false
+ }
+
+ /**
+ * ⚠️ ACL churn can arrive as a mass removal.
+ *
+ * A calendar whose share is revoked, or whose permissions change, can be
+ * reported as every resource in it disappearing at once. Acting on that
+ * deletes the user's data on the strength of a change log; reconciling
+ * against a real listing instead costs one PROPFIND.
+ */
+ private fun removalsArePlausible(removed: List): Boolean {
+ if (removed.size < MIN_REMOVALS_TO_QUESTION) return true
+ val known = store.rowsIn(list.id).mapNotNull { it.href }.distinct().size
+ if (known == 0 || removed.size * 2 <= known) return true
+ report = report.copy(
+ failure = "the change log removed ${removed.size} of $known resources at once — " +
+ "reconciled against a full listing instead",
+ )
+ return false
+ }
+
+ private fun applyRemovals(removed: List) {
+ if (removed.isEmpty()) return
+ val byHref = localResources().filter { it.href != null }.associateBy { it.href!! }
+ removed.forEach { url ->
+ val href = url.toString()
+ // ⚠️ An unknown href is a no-op, not an error. A resource created
+ // and deleted between two syncs is reported as removed without
+ // ever having been reported as added.
+ val local = byHref[href] ?: return@forEach
+ // ⚠️ The change log describes the past. If this run has already
+ // written to that href, the entry predates our write and acting on
+ // it deletes the row we just uploaded — leaving an orphan on the
+ // server and nothing here.
+ if (href in touched) return@forEach
+
+ if (local.isDirty) discard(local, DiscardedEdit.Cause.DELETED_ON_SERVER)
+ purge(local)
+ report = report.copy(deletedLocally = report.deletedLocally + 1)
+ }
+ }
+
+ private fun downloadChanged(changed: List) {
+ if (changed.isEmpty()) return
+ val byHref = localResources().filter { it.href != null }.associateBy { it.href!! }
+ val wanted = changed.filterNot { ref ->
+ val href = ref.href.toString()
+ // The write phase gave up on it this run. Checked before the
+ // `byHref` lookup, because the case it exists for is a row whose
+ // href is still null — invisible to that map, and so past the
+ // dirty-row guard below.
+ if (href in deferred) return@filterNot true
+ val local = byHref[href] ?: return@filterNot false
+ // ⚠️ A row still dirty after the write phase is an edit that never
+ // reached the server — the collection was demoted to read-only, or
+ // the upload was refused. Downloading over it destroys the user's
+ // work silently, with nothing in the report.
+ //
+ // Except when the write phase *asked* for the fresh copy: that is
+ // the 412 path, where the row is deliberately left dirty until its
+ // replacement lands.
+ if ((local.isDirty || local.isDeleted) && href !in refetch) return@filterNot true
+ val eTag = ref.eTag?.takeIf { it.usable }?.value
+ // Unchanged only when both sides have a strong tag and they agree.
+ local.eTag != null && eTag != null && local.eTag == eTag
+ }.map { it.href.toString() }.filterNot { isQuarantined(it) }
+
+ fetchAndApply(wanted)
+ }
+
+ // ------------------------------------------------------------ phase 1
+
+ private fun deletePhase(locals: List) {
+ locals.filter { it.isDeleted }.forEach { local ->
+ val href = local.href
+ if (href == null) {
+ // ⚠️ Never uploaded, so there is nothing to DELETE. Sending
+ // one would 404 on every sync forever.
+ purge(local)
+ return@forEach
+ }
+ if (isQuarantined(local.key)) return@forEach
+
+ when (val outcome = remote.delete(url(href), local.eTag)) {
+ DeleteOutcome.Deleted -> {
+ purge(local)
+ touched += href
+ report = report.copy(deletedRemotely = report.deletedRemotely + 1)
+ }
+
+ DeleteOutcome.ServerNewer -> {
+ // The delete lost. Undo the tombstone and take the
+ // server's copy — decision 2, applied to a deletion.
+ clearTombstone(local)
+ refetch += href
+ touched += href
+ discard(local, DiscardedEdit.Cause.DELETE_LOST)
+ }
+
+ is DeleteOutcome.Rejected ->
+ fail(href, "DELETE refused: ${outcome.code} ${outcome.message}")
+
+ is DeleteOutcome.Failed ->
+ fail(href, "DELETE failed: ${outcome.reason}")
+ }
+ }
+ }
+
+ // ------------------------------------------------------------ phase 2
+
+ private fun uploadPhase(locals: List) {
+ locals.filter { it.isDirty && !it.isDeleted }.forEach { local ->
+ val href = local.href
+ // ⚠️ Keyed by UID when there is no href yet. A create that the
+ // server permanently rejects has nothing else to key on, and a
+ // counter nothing ever reads is a resource re-PUT on every sync
+ // forever — the exact failure quarantine exists to prevent.
+ if (isQuarantined(local.key)) return@forEach
+
+ if (!writable) {
+ skip(local.key, "the collection is read-only")
+ return@forEach
+ }
+
+ // ⚠️ The Nextcloud shared-calendar landmine. `CalendarObject::get()`
+ // reduces a CLASS:CONFIDENTIAL object from a share to a
+ // VEVENT-shaped whitelist — deleting DUE, STATUS, COMPLETED,
+ // PERCENT-COMPLETE, PRIORITY and RELATED-TO — while leaving the
+ // ETag untouched. Writing that back destroys the owner's task,
+ // and the ETag matches, so nothing stops it but this.
+ if (shared && href != null && local.master.classification == CLASS_CONFIDENTIAL) {
+ skip(href, "confidential task in a shared collection: the server may have served a reduced copy")
+ return@forEach
+ }
+
+ val body = serialize(local) ?: return@forEach
+ if (href == null) createResource(local, body) else updateResource(local, href, body)
+ }
+ }
+
+ /** Null when the resource was rejected before it left the device. */
+ private fun serialize(local: LocalResource): String? {
+ val todos = local.live.map { row ->
+ VTodoMapper.write(row, parentUidOf(row), now())
+ }
+ val calendar = CalendarResource.build(todos)
+
+ ResourceValidator.validate(calendar)?.let { rejection ->
+ // Retrying cannot help: the same bytes produce the same 415.
+ fail(local.key, "not uploadable — it ${rejection.reason}")
+ return null
+ }
+ return CalendarResource.serialize(todos)
+ }
+
+ private fun createResource(local: LocalResource, body: String) {
+ var name = ResourceNames.forUid(local.uid)
+ repeat(CREATE_ATTEMPTS) {
+ when (val outcome = remote.create(name, body)) {
+ is PutOutcome.Stored -> {
+ stored(local, outcome.href, outcome.eTag.value)
+ return
+ }
+
+ is PutOutcome.StoredNeedsRefetch -> {
+ stored(local, outcome.href, eTag = null)
+ refetch += outcome.href.toString()
+ return
+ }
+
+ is PutOutcome.NameTaken -> {
+ // Either a previous run's PUT whose answer we never saw,
+ // or an unrelated resource squatting the name. Only the
+ // UID in the body can tell them apart.
+ //
+ // ⚠️ A fetch that *failed* is not evidence of a different
+ // task. This 412 is most often our own PUT from a run
+ // whose answer never arrived, so taking a fresh name on a
+ // timeout writes one UID to a second resource — forbidden
+ // by RFC 4791 §4.1, duplicated in every client, and a row
+ // whose href flips between the two on every later sync.
+ // Stay dirty and try again next run. A fetch that
+ // *succeeded* and returned nothing, or something else, is
+ // a name we may safely walk away from.
+ val fetched = remote.fetch(listOf(outcome.href)).getOrElse {
+ // `skip`, not `fail`: a transport failure is nobody's
+ // fault and the condition is re-evaluated next run.
+ // Counting it would spend a THRESHOLD budget that,
+ // for a resource with no href yet, nothing can ever
+ // refund — `uploadPhase` returns early once it is
+ // quarantined, so neither `stored` nor `purge` can
+ // reach it to clear the count again.
+ deferred += outcome.href.toString()
+ skip(local.key, "create verification failed: $it")
+ return
+ }
+ // A refusal is not an answer either. It arrives as a
+ // *successful* multiget carrying a per-resource error, so
+ // without this the empty `resources` reads as "somebody
+ // else's" and we take a fresh name — the duplicate UID
+ // this branch exists to avoid.
+ if (fetched.failed.isNotEmpty()) {
+ deferred += outcome.href.toString()
+ val code = fetched.failed.first().code
+ skip(local.key, "create verification refused: $code")
+ return
+ }
+ val existing = fetched.resources.firstOrNull()
+ val sameTask = existing != null && uidOf(existing.iCalendar) == local.uid
+ if (sameTask) {
+ updateResource(local, outcome.href.toString(), body)
+ return
+ }
+ name = ResourceNames.random()
+ }
+
+ is PutOutcome.Rejected -> {
+ fail(local.key, "create refused: ${outcome.code} ${outcome.message}")
+ return
+ }
+
+ is PutOutcome.Failed -> {
+ fail(local.key, "create failed: ${outcome.reason}")
+ return
+ }
+
+ // Unreachable on create; If-None-Match cannot produce them.
+ PutOutcome.ServerNewer, PutOutcome.Vanished -> return
+ }
+ }
+ fail(local.key, "could not find a free name after $CREATE_ATTEMPTS attempts")
+ }
+
+ private fun updateResource(local: LocalResource, href: String, body: String) {
+ val url = url(href)
+ var eTag = local.eTag
+ if (eTag == null) {
+ // No usable validator on record. Ask for one before writing,
+ // rather than writing blind.
+ //
+ // ⚠️ A fetch that *failed* says we do not know, not that the
+ // server has none to offer. Falling through would PUT
+ // unconditionally over whatever a concurrent edit had left
+ // there, and book it under `unconditionalWrites` — whose whole
+ // meaning is that the server offered no validator. A success
+ // that yields nothing usable does mean that, and still writes.
+ //
+ // `deferred` is redundant on the direct call, where the row
+ // keeps its href and the dirty-row guards can see it. It is
+ // load-bearing on the delegated one from `createResource`, where
+ // the row's href is still null and only this suppresses a
+ // download that would overwrite the edit by UID.
+ val fetched = remote.fetch(listOf(url)).getOrElse {
+ deferred += href
+ skip(local.key, "update validator fetch failed: $it")
+ return
+ }
+ // Same distinction: refused is not "has none to offer".
+ if (fetched.failed.isNotEmpty()) {
+ deferred += href
+ skip(local.key, "validator fetch refused: ${fetched.failed.first().code}")
+ return
+ }
+ eTag = fetched.resources.firstOrNull()
+ ?.eTag?.takeIf { it.usable }?.value
+ if (eTag == null) {
+ report = report.copy(unconditionalWrites = report.unconditionalWrites + 1)
+ }
+ }
+
+ when (val outcome = remote.update(url, eTag, body)) {
+ is PutOutcome.Stored -> stored(local, outcome.href, outcome.eTag.value)
+
+ is PutOutcome.StoredNeedsRefetch -> {
+ stored(local, outcome.href, eTag = null)
+ refetch += outcome.href.toString()
+ }
+
+ PutOutcome.ServerNewer -> {
+ // Decision 2: the server wins and the local edit is thrown
+ // away — but only when its replacement is in hand. The row
+ // stays dirty until [apply] overwrites it, so a failed
+ // download costs a retry rather than the edit.
+ refetch += href
+ touched += href
+ pendingDiscard[href] = DiscardedEdit.Cause.SERVER_NEWER
+ }
+
+ PutOutcome.Vanished -> {
+ purge(local)
+ touched += href
+ discard(local, DiscardedEdit.Cause.DELETED_ON_SERVER)
+ report = report.copy(deletedLocally = report.deletedLocally + 1)
+ }
+
+ is PutOutcome.Rejected ->
+ fail(href, "upload refused: ${outcome.code} ${outcome.message}")
+
+ is PutOutcome.Failed ->
+ fail(href, "upload failed: ${outcome.reason}")
+
+ is PutOutcome.NameTaken ->
+ fail(href, "unexpected 412 on a conditional update")
+ }
+ }
+
+ private fun stored(local: LocalResource, href: HttpUrl, eTag: String?) {
+ val key = href.toString()
+ // ⚠️ Only the rows that were in the body. `serialize` writes
+ // `local.live`, so a tombstoned override was left out — the PUT *is*
+ // its deletion, and the row has no job left. Marking it synced
+ // instead strands `is_deleted = 1` behind a cleared `is_dirty`,
+ // where no phase can reach it: the ETag we just recorded matches the
+ // server's, so nothing re-downloads it and `apply`'s stale-override
+ // sweep never runs. The unique index on
+ // (list_id, uid, recurrence_id) then makes re-adding that occurrence
+ // throw for good.
+ //
+ // Overrides only. A tombstoned master no longer reaches this phase
+ // at all — `markDeleted` tombstones the whole series, so the
+ // resource reads as deleted and goes to `deletePhase` — but rows
+ // tombstoned by an older version of the app are still out there, and
+ // hard-deleting a master here would cascade its live overrides away
+ // via `master_id`.
+ val (buried, kept) = local.rows.partition { it.isDeleted && it.recurrenceId != null }
+ if (buried.isNotEmpty()) store.deleteAll(buried.map { it.id })
+ store.markSynced(kept.map { it.id }, key, eTag)
+ // Both, because a resource that failed as a create was counted under
+ // its UID and is now counted under its href. Clearing one would leak
+ // the other into the store forever.
+ succeeded(key)
+ succeeded(local.key)
+ touched += key
+ report = report.copy(uploaded = report.uploaded + 1)
+ }
+
+ // ------------------------------------------------------------ phase 3
+
+ private fun downloadPhase(
+ locals: List,
+ remoteETags: Map,
+ ) {
+ val byHref = locals.filter { it.href != null }.associateBy { it.href!! }
+ val wanted = linkedSetOf()
+
+ remoteETags.forEach { (href, eTag) ->
+ val local = byHref[href]
+ when {
+ // The write phase gave up on it this run; it holds an edit
+ // that no local row can be matched to yet.
+ href in deferred -> Unit
+ // Never seen it.
+ local == null -> wanted += href
+ // The write phase owns it this run.
+ local.isDirty || local.isDeleted -> Unit
+ // No validator to compare against, so we cannot know.
+ local.eTag == null || eTag == null -> wanted += href
+ eTag != local.eTag -> wanted += href
+ }
+ }
+ wanted += refetch
+ // ⚠️ Not on a full reconciliation. The download side has no refund:
+ // at THRESHOLD the href is stripped before the fetch, so `apply` —
+ // and with it `succeeded` — can never run to clear the count again,
+ // and a per-object ACL wrong for an afternoon hides that task for the
+ // life of the install. The upload side needs no equivalent, because
+ // `stored` and `purge` refund it there. One probe per periodic
+ // reconciliation is the expiry: it costs a single resource on a slow
+ // cadence, and a resource that answers this time clears its count.
+ if (!fullReconciliationDue) wanted.removeAll { isQuarantined(it) }
+
+ fetchAndApply(wanted)
+ }
+
+ /** Shared by both read paths: download these hrefs and write them down. */
+ private fun fetchAndApply(hrefs: Collection) {
+ if (hrefs.isEmpty()) return
+
+ // Both indexes built once. Re-reading the whole list per resource
+ // turns a first sync of a large collection into a quadratic scan on
+ // the sync thread.
+ val rows = store.rowsIn(list.id)
+ val index = RowIndex(
+ byUid = rows.groupBy { it.uid }.toMutableMap(),
+ byHref = rows.filter { it.href != null }.groupBy { it.href!! }.toMutableMap(),
+ )
+
+ hrefs.chunked(DOWNLOAD_BATCH).forEach { batch ->
+ val fetched = remote.fetch(batch.map(::url)).getOrElse { error ->
+ report = report.copy(failure = "download failed: $error")
+ return
+ }
+ fetched.resources.forEach { apply(it, index) }
+ // ⚠️ Listed by the query, no body from the multiget. It is in the
+ // listing, so the sweep leaves it alone, and it never reaches
+ // `apply`, so nothing counts it — without this it is re-requested
+ // on every sync for ever, which is the loop quarantine exists to
+ // break.
+ // ⚠️ When the server both omitted hrefs we asked for and
+ // volunteered hrefs we did not, "omitted" and "we failed to
+ // recognise its spelling" are indistinguishable — and only one
+ // of the two verdicts is reversible. A download-side count has
+ // no refund: at THRESHOLD the href is stripped before the fetch,
+ // so `apply` can never run to clear it. A wasteful re-request is
+ // recoverable; a permanent silent drop of a good task is not.
+ // ⚠️ Paired by path, not excused wholesale. Real servers answer
+ // with hrefs nobody asked for as a matter of course — a sibling,
+ // something from another collection — so "any stray at all"
+ // would mean a genuinely omitted resource is never counted and
+ // is re-requested for ever, which is the loop the count exists
+ // to break. Only a stray naming the *same path* is plausibly
+ // this href under a spelling we failed to recognise, and that is
+ // the one case where counting could permanently drop a resource
+ // that is really there.
+ val strayPaths = fetched.unsolicited.map { it.pathSegments }.toSet()
+ fetched.missing.forEach {
+ val href = it.toString()
+ if (it.pathSegments in strayPaths) {
+ skip(href, "answered under a spelling we did not recognise")
+ } else {
+ fail(href, "listed but not returned")
+ }
+ }
+ fetched.failed.forEach { failure ->
+ // ⚠️ Quarantining here also silences the upload phase and the
+ // sweep, because a row's quarantine key *is* its href. So the
+ // verdict has to distinguish what the server actually said.
+ val href = failure.href.toString()
+ when (failure.code) {
+ // Gone. The next listing omits it and the sweep purges the
+ // row — counting it would quarantine the resource out of
+ // the very sweep that would have cleaned it up.
+ HTTP_NOT_FOUND, HTTP_GONE ->
+ skip(href, "the server no longer has this resource: ${failure.code}")
+ // The server failing on its own stored object, or on the
+ // batch. A multiget carries no body of ours, so there is
+ // nothing here for a server to reject permanently.
+ 0, in 500..599 ->
+ skip(href, "the server could not return this resource: ${failure.code}")
+ // A judgement about this resource — a per-object ACL, or a
+ // share hiding one object. Deterministic, and nothing else
+ // breaks the loop.
+ else ->
+ fail(href, "the server refused this resource: ${failure.code}")
+ }
+ }
+ }
+ }
+
+ private fun apply(
+ resource: de.jeanlucmakiola.caldav.RemoteResource,
+ index: RowIndex,
+ ) {
+ val href = resource.href.toString()
+ val todos = runCatching {
+ CalendarResource.todosIn(CalendarResource.parse(resource.iCalendar))
+ }.getOrElse {
+ fail(href, "unreadable iCalendar: $it")
+ return
+ }
+ if (todos.isEmpty()) {
+ fail(href, "contains no VTODO")
+ return
+ }
+
+ val mapped = todos.map { VTodoMapper.read(it, list.id) }
+ if (mapped.any { it.uidWasMissing }) {
+ fail(href, "a component has no UID")
+ return
+ }
+ // RFC 4791 §4.1: one resource, one UID. More than one is unmappable
+ // to rows without inventing an identity the server does not share.
+ val uid = mapped.map { it.entity.uid }.distinct().singleOrNull() ?: run {
+ fail(href, "holds more than one UID")
+ return
+ }
+
+ // ⚠️ Only the ETag that arrived with *this* body, and only if strong.
+ // A tag from the listing paired with a body from here is not a
+ // matched pair, and a weak one cannot be used as `If-Match` at all.
+ val eTag = resource.eTag?.takeIf { it.usable }?.value
+
+ val existing = index.byUid[uid].orEmpty().associateBy { it.recurrenceId }
+
+ // ⚠️ Delete-then-recreate at the same URI is reported as a *change*,
+ // not as a removal followed by an addition — so the identity in the
+ // body is the only thing that says the old task is gone. Rows still
+ // holding this href under a different UID are that old task.
+ //
+ // Read before the writes below, which re-point this href's index
+ // entry at the rows we are about to create.
+ val displaced = index.byHref[href].orEmpty().filter { it.uid != uid }
+
+ // Captured before the overwrite, because that is what the report is
+ // about: the version the user is losing.
+ val losing = existing.values.firstOrNull { it.isDirty }
+
+ val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
+ val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag)
+ val masterId = masterRow.id
+ parents[uid] = master.parentUid
+
+ val written = mutableListOf(masterRow)
+ mapped.filter { it !== master }.forEach { override ->
+ written += upsert(
+ override.entity, existing[override.entity.recurrenceId], masterId, href, eTag,
+ )
+ }
+ val kept = written.map { it.recurrenceId }.toSet()
+
+ // Overrides the server no longer has. Cascade would take them with
+ // the master, but the master is still here.
+ val stale = existing.filterKeys { it !in kept }.values.map { it.id }
+ store.deleteAll(stale)
+
+ // Keep the hoisted index honest for the resources still to come.
+ index.byUid[uid] = written
+ index.byHref[href] = written
+ // ⚠️ Including the href these rows just left. A delete-and-recreate
+ // keeps the UID and changes the filename, so the old entry still
+ // names rows that now live here — and a later resource in the same
+ // batch reading that entry as `displaced` deletes them. The
+ // in-memory twin of the sweep's pre-download snapshot.
+ val moved = written.map { it.id }.toSet()
+ existing.values.mapNotNull { it.href }.toSet().minus(href).forEach { vacated ->
+ val left = index.byHref[vacated].orEmpty().filterNot { it.id in moved }
+ if (left.isEmpty()) index.byHref.remove(vacated) else index.byHref[vacated] = left
+ }
+
+ if (displaced.isNotEmpty()) {
+ val ids = displaced.map { it.id }
+ store.deleteAll(ids)
+ displaced.map { it.uid }.distinct().forEach { other ->
+ index.byUid[other] = index.byUid[other].orEmpty().filterNot { it.id in ids }
+ }
+ report = report.copy(deletedLocally = report.deletedLocally + 1)
+ }
+
+ pendingDiscard.remove(href)?.let { cause ->
+ report = report.copy(
+ discardedEdits = report.discardedEdits +
+ DiscardedEdit(uid, losing?.title, cause),
+ )
+ }
+
+ succeeded(href)
+ touched += href
+ report = report.copy(downloaded = report.downloaded + 1)
+ }
+
+ /** @return the row as it now stands, so the caller can index it. */
+ private fun upsert(
+ incoming: TaskEntity,
+ existing: TaskEntity?,
+ masterId: Long?,
+ href: String,
+ eTag: String?,
+ ): TaskEntity {
+ // Local-only columns the server has no opinion about. Taking the
+ // mapper's defaults here would silently reset the user's ordering and
+ // per-task colour on every download.
+ val row = incoming.copy(
+ id = existing?.id ?: 0L,
+ listId = list.id,
+ masterId = masterId,
+ parentId = existing?.parentId,
+ sortOrder = existing?.sortOrder ?: 0,
+ color = existing?.color,
+ href = href,
+ etag = eTag,
+ // Explicit, not defaulted: a downstream write that leaves this
+ // set uploads what was just downloaded.
+ isDirty = false,
+ isDeleted = false,
+ )
+ return if (existing == null) {
+ row.copy(id = store.insert(row))
+ } else {
+ store.update(row)
+ row
+ }
+ }
+
+ /**
+ * Turns the `RELATED-TO` UIDs collected during the download into row ids.
+ *
+ * Deferred to the end because a parent may arrive in a later batch than
+ * its child, and a forward reference resolved eagerly is a lost
+ * hierarchy.
+ */
+ private fun resolveParents() {
+ parents.forEach { (uid, parentUid) ->
+ val child = store.masterByUid(list.id, uid) ?: return@forEach
+ val parent = parentUid?.let { store.masterByUid(list.id, it) }
+ if (child.parentId != parent?.id) {
+ store.setParent(child.id, parent?.id)
+ }
+ }
+ }
+
+ // ------------------------------------------------------------ phase 4
+
+ /**
+ * @param locals must be read *after* [downloadPhase]. [apply] can re-point
+ * an existing row at a new href when the same UID reappears under a new
+ * filename, and [purge] deletes by row id — so a pre-download list sweeps
+ * away the row the download just repaired.
+ */
+ private fun sweepPhase(locals: List, remoteHrefs: Set) {
+ // ⚠️ An empty listing never sweeps. The sweep is the one phase that
+ // deletes rows it did not see fail, and its evidence is a
+ // `calendar-query` with a VTODO comp-filter — a filter some servers
+ // mishandle badly enough to answer with an empty *successful*
+ // multistatus, which is indistinguishable from an empty collection.
+ // Without this floor, one such answer hard-deletes every task the
+ // user has in that list, in a single pass, unrecoverably.
+ //
+ // Cost accepted: a collection genuinely emptied on the server keeps
+ // its local rows until one task reappears there. That is recoverable
+ // by hand. The other error is not.
+ if (remoteHrefs.isEmpty() && locals.any { it.href != null }) {
+ report = report.copy(
+ failure = "the server listed no tasks while ${locals.count { it.href != null }} " +
+ "are known here — nothing was deleted",
+ )
+ return
+ }
+
+ locals.forEach { local ->
+ val href = local.href ?: return@forEach
+ if (href in remoteHrefs || href in touched || isQuarantined(local.key)) return@forEach
+ if (local.isDeleted) return@forEach
+
+ // Present locally, absent from a full listing: deleted on the
+ // server. A dirty row here is a local edit that lost to that
+ // deletion, which the user is told about rather than left to
+ // discover.
+ if (local.isDirty) discard(local, DiscardedEdit.Cause.DELETED_ON_SERVER)
+ purge(local)
+ report = report.copy(deletedLocally = report.deletedLocally + 1)
+ }
+ }
+
+ // ------------------------------------------------------------- shared
+
+ private fun localResources(): List =
+ store.rowsIn(list.id)
+ .groupBy { it.uid }
+ .map { (uid, rows) -> LocalResource(uid, rows) }
+
+ private fun persistCollectionState(readOnly: Boolean) {
+ if (list.isReadOnly == readOnly) return
+ // ⚠️ ACL churn is silent: a share can be demoted to read-only with no
+ // notification, and a stale flag turns every upload into a 403 the
+ // user cannot act on.
+ //
+ // One column, not the whole row. `list` was captured before the sync
+ // started, so writing it back would silently revert a rename, a
+ // recolour or a visibility toggle the user made while it ran.
+ store.setListReadOnly(list.id, readOnly)
+ }
+
+ private fun parentUidOf(row: TaskEntity): String? =
+ row.parentId?.let { store.row(it)?.uid }
+
+ private fun uidOf(iCalendar: String): String? = runCatching {
+ CalendarResource.todosIn(CalendarResource.parse(iCalendar))
+ .firstNotNullOfOrNull { it.property("UID")?.value?.trim() }
+ }.getOrNull()
+
+ private fun url(href: String): HttpUrl =
+ href.toHttpUrlOrNull() ?: remote.url.resolve(href) ?: remote.url
+
+ private fun purge(local: LocalResource) {
+ store.deleteAll(local.rows.map { it.id })
+ // The rows are gone, so a counter about them is dead weight.
+ succeeded(local.key)
+ }
+
+ private fun clearTombstone(local: LocalResource) {
+ local.rows.forEach { store.update(it.copy(isDeleted = false, isDirty = false)) }
+ }
+
+ private fun discard(local: LocalResource, cause: DiscardedEdit.Cause) {
+ report = report.copy(
+ discardedEdits = report.discardedEdits +
+ DiscardedEdit(local.uid, local.master.title, cause),
+ )
+ }
+
+ /** A resource we cannot sync this run, but which is nobody's fault. */
+ private fun skip(href: String, reason: String) {
+ report = report.copy(
+ quarantined = report.quarantined + QuarantinedResource(href, reason, failures = 0),
+ )
+ }
+
+ /** A resource that failed. Counts towards [QuarantineStore.THRESHOLD]. */
+ private fun fail(href: String, reason: String) {
+ val key = QuarantineStore.key(list.id, href)
+ val failures = (quarantine[key] ?: 0) + 1
+ quarantine[key] = failures
+ report = report.copy(
+ quarantined = report.quarantined + QuarantinedResource(href, reason, failures),
+ )
+ }
+
+ private fun succeeded(href: String) {
+ quarantine.remove(QuarantineStore.key(list.id, href))
+ }
+
+ private fun isQuarantined(href: String): Boolean =
+ (quarantine[QuarantineStore.key(list.id, href)] ?: 0) >= QuarantineStore.THRESHOLD
+ }
+
+ /**
+ * The list's rows, indexed both ways [apply] needs them.
+ *
+ * Kept for the whole download phase and updated in place, because both
+ * lookups are per-resource: one to find the rows this UID already has, one to
+ * find rows that hold this href under a *different* UID.
+ */
+ private class RowIndex(
+ val byUid: MutableMap>,
+ val byHref: MutableMap>,
+ )
+
+ /**
+ * The rows that make up one calendar resource.
+ *
+ * A recurring task and its `RECURRENCE-ID` overrides are separate rows and
+ * one file: RFC 4791 §4.1 requires everything in a resource to share a UID.
+ * So href and ETag belong to the group, never to a row.
+ */
+ private data class LocalResource(val uid: String, val rows: List) {
+ val master: TaskEntity = rows.firstOrNull { it.recurrenceId == null } ?: rows.first()
+ val href: String? = rows.firstNotNullOfOrNull { it.href }
+ val eTag: String? = rows.firstNotNullOfOrNull { it.etag }
+ val isDirty: Boolean = rows.any { it.isDirty }
+
+ /**
+ * What quarantine counts this resource under.
+ *
+ * The href once it has one, and the UID before that — a resource that has
+ * never been uploaded still has to be countable, or a body the server
+ * refuses forever is retried forever.
+ */
+ val key: String = href ?: "uid:$uid"
+
+ /**
+ * The master's tombstone is the resource's: an override cannot outlive
+ * the series it belongs to. A deleted *override* is an edit.
+ *
+ * ⚠️ Not `rows.all { … }`. `markDeleted` now tombstones a series whole,
+ * but rows tombstoned by an older version left the master marked and its
+ * overrides live — which read as "partly deleted", went to the upload
+ * phase, and never sent the DELETE the user asked for. Reading the
+ * master repairs those on the next sync instead of leaving them stuck.
+ */
+ val isDeleted: Boolean = master.isDeleted
+
+ /** What gets serialised: a deleted override is simply absent. */
+ val live: List = rows.filterNot { it.isDeleted }
+ }
+
+ private companion object {
+ const val HTTP_NOT_FOUND = 404
+ const val HTTP_GONE = 410
+
+ /** RFC 5545 `CLASS:CONFIDENTIAL`, as `tasks.classification` stores it. */
+ const val CLASS_CONFIDENTIAL = 2
+
+ /**
+ * Fresh names tried before giving up on a create.
+ *
+ * Bounded because every 412 retry loop in this engine is bounded — an
+ * unbounded one against a server that 412s unconditionally is a sync that
+ * never finishes.
+ */
+ const val CREATE_ATTEMPTS = 3
+
+ const val DOWNLOAD_BATCH = 30
+
+ /**
+ * Pages of `sync-collection` before giving up and reconciling in full.
+ *
+ * A cap *and* a no-progress guard, because RFC 6578 never requires the
+ * token to advance — a server can legitimately truncate forever.
+ */
+ const val MAX_SYNC_PAGES = 50
+
+ /**
+ * Removals in one page below which the sanity threshold does not apply.
+ *
+ * Deleting a handful of tasks is ordinary; being told the whole
+ * collection vanished is what ACL churn looks like.
+ */
+ const val MIN_REMOVALS_TO_QUESTION = 10
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt
new file mode 100644
index 0000000..e9c6946
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/CredentialStore.kt
@@ -0,0 +1,171 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import android.security.keystore.KeyProperties
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.CredentialsDataStore
+import kotlinx.coroutines.flow.first
+import java.io.IOException
+import java.security.GeneralSecurityException
+import java.security.ProviderException
+import java.security.KeyStore
+import java.util.Base64
+import javax.crypto.AEADBadTagException
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * App passwords, encrypted with a hardware-backed Keystore key.
+ *
+ * `androidx.security:security-crypto` is **formally deprecated and terminal** —
+ * deprecated at 1.1.0-alpha07, shipped deprecated in stable 1.1.0, with release
+ * notes saying there will be no further releases — and its successor
+ * `datastore-tink` is alpha. So: Keystore `AES/GCM/NoPadding` directly, blob in
+ * DataStore.
+ *
+ * Be honest about what this buys. `AccountManager` stores passwords as plain
+ * `TEXT` — there is no encryption or hashing anywhere in AOSP — so file-based
+ * encryption plus a same-signature check is the whole boundary there. That is
+ * DAVx5's posture and it is defensible, but it is not secure storage. This is
+ * better, and the difference is worth the ~80 lines.
+ *
+ * Three deliberate non-choices:
+ * - `setUserAuthenticationRequired` is left at its default of `false`. Requiring
+ * a device unlock per decryption makes background sync impossible.
+ * - `setUnlockedDeviceRequired` is **not** set, for the same reason.
+ * - A failure to decrypt is *never* a crash. It means re-authenticate.
+ */
+@Singleton
+class CredentialStore @Inject constructor(
+ @CredentialsDataStore private val dataStore: DataStore,
+) {
+
+ /** What came back for an account. */
+ sealed interface Secret {
+ data class Present(val value: String) : Secret
+
+ data object Absent : Secret
+
+ /**
+ * The ciphertext exists but can no longer be decrypted, so the only
+ * recovery is to sign in again.
+ *
+ * Reached by a restored backup (Keystore keys are non-exportable, so a
+ * restored blob is permanently undecryptable — which is why the blob is
+ * excluded from backup), by the key being invalidated when the user
+ * changes their lock screen, or by corruption.
+ */
+ data class Unrecoverable(val reason: String) : Secret
+ }
+
+ /**
+ * Stores [appPassword] for [accountId].
+ *
+ * @return false when the Keystore could not be used at all. A wedged or
+ * degraded keystore throws [ProviderException], which is a `RuntimeException`
+ * and would otherwise take down the account-add flow — the same "never
+ * crash over this" rule [get] follows.
+ */
+ suspend fun put(accountId: Long, appPassword: String): Boolean = try {
+ val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key()) }
+ // The IV travels with the ciphertext. GCM requires a unique IV per
+ // encryption under the same key; letting the provider generate it is the
+ // only way to be sure of that.
+ val payload = cipher.iv + cipher.doFinal(appPassword.toByteArray(Charsets.UTF_8))
+ dataStore.edit { it[keyFor(accountId)] = Base64.getEncoder().encodeToString(payload) }
+ true
+ } catch (e: GeneralSecurityException) {
+ false
+ } catch (e: ProviderException) {
+ false
+ } catch (e: IOException) {
+ false
+ }
+
+ suspend fun get(accountId: Long): Secret {
+ val stored = dataStore.data.first()[keyFor(accountId)] ?: return Secret.Absent
+ return try {
+ val payload = Base64.getDecoder().decode(stored)
+ val cipher = Cipher.getInstance(TRANSFORMATION).apply {
+ init(
+ Cipher.DECRYPT_MODE,
+ key(),
+ GCMParameterSpec(TAG_BITS, payload, 0, IV_BYTES),
+ )
+ }
+ Secret.Present(
+ String(
+ cipher.doFinal(payload, IV_BYTES, payload.size - IV_BYTES),
+ Charsets.UTF_8,
+ ),
+ )
+ } catch (e: KeyPermanentlyInvalidatedException) {
+ // The lock screen changed, or the key was otherwise invalidated.
+ Secret.Unrecoverable(e.message ?: "the encryption key was invalidated")
+ } catch (e: AEADBadTagException) {
+ // Wrong key or tampered ciphertext — the restored-backup case.
+ Secret.Unrecoverable(e.message ?: "the stored credential could not be decrypted")
+ } catch (e: GeneralSecurityException) {
+ Secret.Unrecoverable(e.message ?: "the stored credential could not be read")
+ } catch (e: IllegalArgumentException) {
+ // Not valid Base64 at all — a truncated or hand-edited blob.
+ Secret.Unrecoverable(e.message ?: "the stored credential is malformed")
+ } catch (e: ProviderException) {
+ // ⚠️ AndroidKeyStore signals keystore-level failure ("Keystore
+ // operation failed", "Failed to load key") with this — a
+ // RuntimeException, so none of the catches above match it. On a
+ // device with a degraded keystore it would crash the sync worker
+ // instead of prompting a re-authentication.
+ Secret.Unrecoverable(e.message ?: "the device keystore is unavailable")
+ } catch (e: IOException) {
+ // KeyStore.load declares it.
+ Secret.Unrecoverable(e.message ?: "the device keystore could not be opened")
+ }
+ }
+
+ suspend fun clear(accountId: Long) {
+ dataStore.edit { it.remove(keyFor(accountId)) }
+ }
+
+ /** Every stored credential. Used when the last account goes away. */
+ suspend fun clearAll() {
+ dataStore.edit { it.clear() }
+ }
+
+ private fun keyFor(accountId: Long) = stringPreferencesKey("caldav_app_password_$accountId")
+
+ private fun key(): SecretKey {
+ val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE).apply {
+ init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ // Not calling setUserAuthenticationRequired /
+ // setUnlockedDeviceRequired is the point — see the class doc.
+ .build(),
+ )
+ }.generateKey()
+ }
+
+ private companion object {
+ const val KEYSTORE = "AndroidKeyStore"
+ const val KEY_ALIAS = "agendula.caldav.credentials"
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
+ const val IV_BYTES = 12
+ const val TAG_BITS = 128
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt
new file mode 100644
index 0000000..2f0280d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/PendingLoginFlowStore.kt
@@ -0,0 +1,145 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import de.jeanlucmakiola.caldav.NextcloudLoginFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Where a started login flow is written down, so it can outlive this process.
+ *
+ * A seam for the same reason [AccountCreator] and [CalDavGateway] are: the flow
+ * that decides *when* a one-shot password stops being ours has to be testable
+ * without a DataStore.
+ */
+interface LoginFlowRecord {
+
+ /** Called **before** the browser is handed the URL. */
+ suspend fun remember(flow: NextcloudLoginFlow.Flow)
+
+ /** The flow is over, however it ended. */
+ suspend fun forget()
+}
+
+/**
+ * The Nextcloud login flow that is currently out at a browser.
+ *
+ * ⚠️ [NextcloudLoginFlow.Flow]'s own doc says to persist it **before** launching
+ * the browser, because the flow outlives our process — and it did not. The
+ * browser is a separate task, so process death while the user is approving is
+ * ordinary rather than exotic, and it stranded a one-shot app password that
+ * nothing could then collect *or* revoke: the flow's poll token was the only way
+ * back to it, and it lived in a ViewModel field.
+ *
+ * The token is not a credential. It authorises exactly one poll of one flow the
+ * user is in the middle of approving, and it is useless past the twenty-minute
+ * window — so it belongs in the sync-state store rather than the Keystore.
+ *
+ * ⚠️ What this does **not** cover is the window *after* approval, where the
+ * password itself lives only in memory. That needs the wizard's own state to
+ * survive, which is a different piece of work.
+ */
+@Singleton
+class PendingLoginFlowStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+ private val gateway: CalDavGateway,
+) : LoginFlowRecord {
+
+ private val lock = Mutex()
+ private var reclaimed = false
+
+ /** Records [flow] so a process that dies mid-approval can still finish with it. */
+ override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
+ dataStore.edit { prefs ->
+ prefs[LOGIN_URL] = flow.loginUrl.toString()
+ prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
+ prefs[POLL_TOKEN] = flow.pollToken
+ prefs[DEADLINE] = flow.deadlineEpochSeconds
+ }
+ }
+
+ /** The flow is finished, one way or another. */
+ override suspend fun forget() {
+ dataStore.edit { prefs ->
+ prefs.remove(LOGIN_URL)
+ prefs.remove(POLL_ENDPOINT)
+ prefs.remove(POLL_TOKEN)
+ prefs.remove(DEADLINE)
+ }
+ }
+
+ /**
+ * Collects and hands back a password nobody is left to own.
+ *
+ * Revoked rather than used: the address the user typed, the collections they
+ * ticked and the account name are all gone with the process, so there is
+ * nothing to finish. What is left is a live app password in the user's
+ * device list, under the same name as every other attempt — which is exactly
+ * what they cannot tell apart, and so dare not prune.
+ *
+ * ⚠️ **Once per process.** This activity is recreated on every rotation,
+ * theme switch and locale change, and a second run against a flow the *live*
+ * wizard is still polling would consume its one-shot 200 and revoke the
+ * password it was about to be handed. A flow remembered after this has run
+ * belongs to a wizard that is alive to finish it.
+ */
+ suspend fun reclaim() {
+ lock.withLock {
+ if (reclaimed) return
+ reclaimed = true
+ }
+ val flow = pending() ?: return
+ when (val result = gateway.pollLoginFlow(flow)) {
+ is NextcloudLoginFlow.PollResult.Approved -> {
+ // Cleared first: a revocation that fails must not leave a token
+ // that would be polled again, and the 200 is already spent.
+ forget()
+ gateway.revokeIssuedAppPassword(
+ CalDavGateway.Credentials(
+ username = result.credentials.loginName,
+ password = result.credentials.appPassword,
+ origin = result.credentials.server,
+ ),
+ )
+ }
+
+ is NextcloudLoginFlow.PollResult.Expired -> forget()
+
+ // Still inside the window, or the server had a moment. Either way
+ // the token is still worth something, so it is left for the next
+ // open; a poll past the deadline answers Expired and clears it.
+ NextcloudLoginFlow.PollResult.Pending,
+ is NextcloudLoginFlow.PollResult.Failed,
+ -> Unit
+ }
+ }
+
+ private suspend fun pending(): NextcloudLoginFlow.Flow? {
+ val prefs = dataStore.data.first()
+ val endpoint = prefs[POLL_ENDPOINT]?.toHttpUrlOrNull() ?: return null
+ val token = prefs[POLL_TOKEN] ?: return null
+ val deadline = prefs[DEADLINE] ?: return null
+ return NextcloudLoginFlow.Flow(
+ loginUrl = prefs[LOGIN_URL]?.toHttpUrlOrNull() ?: endpoint,
+ pollEndpoint = endpoint,
+ pollToken = token,
+ deadlineEpochSeconds = deadline,
+ )
+ }
+
+ private companion object {
+ val LOGIN_URL = stringPreferencesKey("login_flow_url")
+ val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
+ val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
+ val DEADLINE = longPreferencesKey("login_flow_deadline")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt
new file mode 100644
index 0000000..c7cd53b
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/QuarantineStore.kt
@@ -0,0 +1,116 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * How many times each resource has failed, and therefore which ones to skip.
+ *
+ * ⚠️ Deliberately **not** a backoff. A backoff assumes the failure is transient
+ * and asks "how long until I try again"; the failures that matter here are
+ * permanent — a body sabre answers 415 for, a contradictory `RRULE`/`EXDATE`
+ * pair Nextcloud answers 500 for forever, a 507 the spec forbids retrying at
+ * all. The question worth asking is "how many times before I leave this one
+ * alone and finish the collection", and the answer is [THRESHOLD].
+ *
+ * Counts are cleared the moment a resource succeeds, so a genuinely transient
+ * failure costs nothing beyond the runs it actually failed in.
+ */
+@Singleton
+class QuarantineStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+) {
+
+ /** Current failure counts, keyed by [key]. */
+ suspend fun counts(): Map = decode(dataStore.data.first()[KEY].orEmpty())
+
+ private fun decode(entries: Set): Map = entries.mapNotNull { entry ->
+ val separator = entry.lastIndexOf(COUNT_SEPARATOR)
+ if (separator <= 0) return@mapNotNull null
+ val count = entry.substring(separator + 1).toIntOrNull() ?: return@mapNotNull null
+ entry.substring(0, separator) to count
+ }.toMap()
+
+ /**
+ * Applies one account's changes without disturbing anyone else's.
+ *
+ * ⚠️ Not a whole-map replace. The counts are global — keyed by list, not by
+ * account — while `SyncWorker`'s uniqueness is only *per account*, so two
+ * accounts can sync at once. Each would snapshot the same global map and the
+ * later writer would discard the other's increments and resurrect the
+ * counters it had cleared. Re-reading inside `edit`, which DataStore
+ * serialises, keeps the read-modify-write atomic.
+ *
+ * @param updates counts to set, replacing any current value for those keys.
+ * @param cleared keys to remove outright, whatever they currently hold.
+ */
+ suspend fun merge(updates: Map, cleared: Set) {
+ dataStore.edit { prefs ->
+ val current = decode(prefs[KEY].orEmpty()).toMutableMap()
+ current -= cleared
+ current += updates.filterValues { it > 0 }
+ prefs[KEY] = current
+ .map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
+ .toSet()
+ }
+ }
+
+ /**
+ * Forgets every count belonging to [listIds].
+ *
+ * ⚠️ The keys are global, exactly like the cadence cursors cleared beside
+ * them. A list detached from a removed account and re-attached to a new one
+ * would otherwise inherit its old counters — and a resource already at
+ * [THRESHOLD] is skipped for ever, since a quarantined resource never
+ * succeeds and so never clears.
+ */
+ suspend fun forget(listIds: Set) {
+ if (listIds.isEmpty()) return
+ val prefixes = listIds.map { "$it|" }
+ dataStore.edit { prefs ->
+ val current = decode(prefs[KEY].orEmpty())
+ .filterKeys { key -> prefixes.none(key::startsWith) }
+ prefs[KEY] = current
+ .map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
+ .toSet()
+ }
+ }
+
+ /**
+ * Clears the count for one resource in any of [listIds], so the next sync
+ * tries it again — the user's "retry" on a quarantined task.
+ */
+ suspend fun release(listIds: Set, href: String) {
+ val keys = listIds.map { key(it, href) }.toSet()
+ dataStore.edit { prefs ->
+ val current = decode(prefs[KEY].orEmpty())
+ if (current.keys.none { it in keys }) return@edit
+ prefs[KEY] = (current - keys)
+ .map { (key, count) -> "$key$COUNT_SEPARATOR$count" }
+ .toSet()
+ }
+ }
+
+ companion object {
+ /**
+ * Attempts before a resource is left alone.
+ *
+ * Three rather than one: a 502 from a reverse proxy mid-restart and a
+ * permanently malformed body arrive as the same outcome, and burning two
+ * extra runs is cheaper than quarantining a resource that would have
+ * worked.
+ */
+ const val THRESHOLD = 3
+
+ fun key(listId: Long, href: String) = "$listId|$href"
+
+ private const val COUNT_SEPARATOR = '#'
+ private val KEY = stringSetPreferencesKey("sync_quarantine")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt
new file mode 100644
index 0000000..ea0bfc4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/RemoteListRepository.kt
@@ -0,0 +1,312 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.data.sync.push.PushStore
+import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
+import de.jeanlucmakiola.agendula.data.tasks.StorageMode
+import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import de.jeanlucmakiola.caldav.CalDavHttp
+import de.jeanlucmakiola.caldav.CollectionAdmin
+import de.jeanlucmakiola.caldav.CollectionOutcome
+import de.jeanlucmakiola.caldav.CollectionSupport
+import de.jeanlucmakiola.caldav.DavCollectionAdmin
+import de.jeanlucmakiola.caldav.ResourceNames
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Task lists that live on a server: making them, renaming them, recolouring
+ * them and deleting them.
+ *
+ * ⚠️ Every write here is **server first, Room second**, and that ordering is the
+ * whole design. The other way round gives the user a list that exists on their
+ * phone and nowhere else, and nothing to tell them so — `task_lists.is_dirty`
+ * was already set by a rename and read by nobody, which is precisely that
+ * failure with the evidence discarded. A refused write leaves the local row
+ * exactly as it was, so what is on screen is what is on the server.
+ *
+ * Device-only lists are not this class's business: they have no href, no
+ * account and nothing to ask permission of. [de.jeanlucmakiola.agendula.data.tasks.TasksRepository]
+ * keeps them.
+ */
+@Singleton
+class RemoteListRepository @Inject constructor(
+ private val database: TasksDatabase,
+ private val credentials: CredentialStore,
+ private val support: CollectionSupportStore,
+ private val syncTrigger: SyncTrigger,
+ private val cadence: SyncCadenceStore,
+ private val notices: SyncNoticeStore,
+ private val quarantine: QuarantineStore,
+ private val accountState: AccountStateStore,
+ private val resolver: ProviderResolver,
+ private val push: PushStore,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+
+ /** Why a collection write did not happen, in a form the UI can translate. */
+ sealed interface Outcome {
+ data object Done : Outcome
+
+ /** The server said no, and will say no again. */
+ data class Refused(val code: Int) : Outcome
+
+ /** The server could not be reached. Worth another try. */
+ data object Unreachable : Outcome
+
+ /** This account cannot make collections at all — iCloud, Posteo, Google. */
+ data object Unsupported : Outcome
+
+ /** Ours is a read-only share; the write belongs to whoever owns it. */
+ data object ReadOnly : Outcome
+
+ /** The account is gone, stopped, or has no credential we can decrypt. */
+ data object NoAccount : Outcome
+
+ /**
+ * The server answered something this call cannot make sense of.
+ *
+ * ⚠️ Not [Unreachable]. `CollectionOutcome` is one type across create,
+ * update and delete, so each of them has branches the other's method
+ * can return and its own cannot — and mapping those to [Unreachable]
+ * told someone sitting on wifi that they were offline. Unreachable is a
+ * claim about the network, and this is not one.
+ */
+ data object Unexpected : Outcome
+ }
+
+ /**
+ * The accounts a new list may be created on, freshest answer first.
+ *
+ * ⚠️ Re-asked rather than cached for ever. `CollectionSupportStore` holds
+ * the last answer so a picker can draw immediately, but a server that gained
+ * the capability in an upgrade — or lost it in a config change — must be
+ * able to say so, and the only moment that costs nothing is while the user
+ * is looking at the picker.
+ */
+ suspend fun creatableAccounts(): List = withContext(io) {
+ // ⚠️ Empty in External mode, whatever the accounts table holds. The
+ // lists on screen then come from a third-party provider, so a row
+ // inserted into ours would exist, sync, and be visible to nobody.
+ if (resolver.mode() != StorageMode.OWN) return@withContext emptyList()
+ val stopped = accountState.needingSignIn()
+ val candidates = database.accounts().all().filter {
+ it.homeSetUrl?.toHttpUrlOrNull() != null && it.id !in stopped
+ }
+ // ⚠️ Together, not one after another. Each probe is a blocking OPTIONS,
+ // so three accounts with one server on a slow link held the "Where" row
+ // off the sheet for the sum of all three — with the sheet already drawn.
+ coroutineScope {
+ candidates.map { account -> async { account to supportFor(account) } }
+ .awaitAll()
+ .filter { (_, support) -> support.canCreate }
+ .map { (account, _) -> account }
+ }
+ }
+
+ /**
+ * Makes a collection on [accountId]'s home set and a row pointing at it.
+ *
+ * @return the new list's local id, or why there is none.
+ */
+ suspend fun create(
+ accountId: Long,
+ name: String,
+ color: Int,
+ ): Outcome = withContext(io) {
+ // ⚠️ Re-checked here, not only in `creatableAccounts`. The picker's list
+ // is a StateFlow that outlives one opening of the sheet, so a mode that
+ // flips between the list being built and Save being tapped would
+ // otherwise create the collection on the server and file the row in a
+ // store External mode never reads.
+ if (resolver.mode() != StorageMode.OWN) return@withContext Outcome.NoAccount
+ val account = database.accounts().account(accountId) ?: return@withContext Outcome.NoAccount
+ val homeSet = account.homeSetUrl?.toHttpUrlOrNull()
+ ?: return@withContext Outcome.NoAccount
+ val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
+ val capabilities = support.refresh(accountId) { admin.support(homeSet) }
+ if (!capabilities.canCreate) return@withContext Outcome.Unsupported
+
+ // ⚠️ A second attempt, but only for the one refusal a different name
+ // can fix. Nextcloud's trashbin *renames* a deleted collection rather
+ // than removing it, so re-creating under a segment used before answers
+ // 403 for ever — and "Shopping" is exactly the name someone deletes and
+ // remakes. Retrying anything else spends a second authenticated write
+ // that will fail the same way, and worse: a 401 is a second hit on the
+ // brute-force counter, and a 507 retried reports the wrong code back,
+ // since the caller only ever sees the *last* attempt's.
+ val first = ResourceNames.forCollection(name)
+ var created = admin.create(homeSet, first, name, color, capabilities)
+ if (created is CollectionOutcome.Refused && created.code in NAME_REFUSALS) {
+ created = admin.create(homeSet, ResourceNames.randomCollection(), name, color, capabilities)
+ }
+
+ when (created) {
+ is CollectionOutcome.Created -> {
+ // ⚠️ Uncancellable. The collection exists on the server from
+ // here on, and a cancellation between that and the row would
+ // leave one the app has no record of and no way to reach —
+ // visible only on the next full account re-add.
+ withContext(NonCancellable) {
+ database.taskLists().insert(
+ TaskListEntity(
+ name = name,
+ color = color,
+ accountId = accountId,
+ href = created.url.toString(),
+ ),
+ )
+ }
+ // The server has it and we do not; a sync is how the two agree
+ // on a ctag and a token rather than reconciling in full later.
+ syncTrigger.enqueue(account.displayName, expedited = true)
+ Outcome.Done
+ }
+
+ is CollectionOutcome.Refused -> Outcome.Refused(created.code)
+ is CollectionOutcome.Failed -> Outcome.Unreachable
+ CollectionOutcome.Unsupported -> Outcome.Unsupported
+ CollectionOutcome.Updated -> Outcome.Unexpected
+ }
+ }
+
+ /**
+ * Renames and recolours [listId] on the server, then locally.
+ *
+ * ⚠️ Refuses a read-only collection rather than discovering it at write
+ * time. A share the owner has made read-only answers 403 to a PROPPATCH, and
+ * a row that has already been renamed locally by then reads as a rename that
+ * worked and then quietly reverted on the next sync.
+ */
+ suspend fun rename(listId: Long, name: String, color: Int): Outcome = withContext(io) {
+ val list = database.taskLists().entity(listId) ?: return@withContext Outcome.NoAccount
+ if (list.isReadOnly) return@withContext Outcome.ReadOnly
+ val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
+ val account = list.accountId?.let { database.accounts().account(it) }
+ ?: return@withContext Outcome.NoAccount
+ val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
+
+ when (val outcome = admin.updateProperties(url, displayName = name, color = color)) {
+ CollectionOutcome.Updated -> {
+ withContext(NonCancellable) {
+ // Read again inside the write: a sync running alongside this
+ // may have refreshed the ACL flag or the cursor, and writing
+ // back the entity we read before the network call would
+ // revert it.
+ val current = database.taskLists().entity(listId) ?: return@withContext
+ database.taskLists().update(
+ // isDirty stays false: the server already has this. The
+ // flag existed for a PROPPATCH that never happened.
+ current.copy(name = name, color = color, isDirty = false),
+ )
+ }
+ Outcome.Done
+ }
+
+ is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
+ is CollectionOutcome.Failed -> Outcome.Unreachable
+ is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
+ }
+ }
+
+ /**
+ * Deletes [listId] on the server, then on the device.
+ *
+ * ⚠️ The one write where "already gone" is success — [CollectionAdmin.delete]
+ * grades 404 and 410 that way — because otherwise a collection someone
+ * removed from another client leaves a row here that nothing can get rid of.
+ */
+ suspend fun delete(listId: Long): Outcome = withContext(io) {
+ val list = database.taskLists().entity(listId) ?: return@withContext Outcome.Done
+ if (list.isReadOnly) return@withContext Outcome.ReadOnly
+ val url = list.href?.toHttpUrlOrNull() ?: return@withContext Outcome.NoAccount
+ val account = list.accountId?.let { database.accounts().account(it) }
+ ?: return@withContext Outcome.NoAccount
+ val admin = adminFor(account) ?: return@withContext Outcome.NoAccount
+
+ when (val outcome = admin.delete(url)) {
+ CollectionOutcome.Updated -> {
+ withContext(NonCancellable) {
+ // `tasks.list_id` is ON DELETE CASCADE, so the tasks go with
+ // it — which is what was just done on the server.
+ database.taskLists().delete(listId)
+ // And the per-list state keyed off it, exactly as removing an
+ // account clears its lists': the ids are AUTOINCREMENT so
+ // nothing would ever read these again. The notices go by
+ // *name*, which is how they are keyed — a discarded-edit
+ // notice would otherwise name a list that no longer exists
+ // until the user tapped "Got it".
+ forgetPerListState(listId)
+ list.accountId?.let { notices.forgetList(it, list.name) }
+ }
+ Outcome.Done
+ }
+
+ is CollectionOutcome.Refused -> Outcome.Refused(outcome.code)
+ is CollectionOutcome.Failed -> Outcome.Unreachable
+ is CollectionOutcome.Created, CollectionOutcome.Unsupported -> Outcome.Unexpected
+ }
+ }
+
+ private suspend fun supportFor(account: AccountEntity): CollectionSupport {
+ val homeSet = account.homeSetUrl?.toHttpUrlOrNull() ?: return CollectionSupport.NONE
+ val admin = adminFor(account) ?: return CollectionSupport.NONE
+ return support.refresh(account.id) { admin.support(homeSet) }
+ }
+
+ /**
+ * Null when the account has no credential we can use — a stopped account, or
+ * a restore.
+ *
+ * ⚠️ The stopped check is the same one `SyncEngine.sync` makes before it
+ * touches the network, and for the same reason: Nextcloud's brute-force
+ * protection throttles and then **429s per source IP**, so spending a
+ * request on a credential we already know the server rejects lands on the
+ * user's *other* clients. Opening the "new list" sheet must not do that any
+ * more than a timer may.
+ */
+ private suspend fun adminFor(account: AccountEntity): CollectionAdmin? {
+ if (accountState.needsSignIn(account.id)) return null
+ val username = account.username ?: return null
+ val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
+ val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
+ ?: return null
+ return DavCollectionAdmin(client(username, password, origin))
+ }
+
+ private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
+ CalDavHttp.authenticated(USER_AGENT, username, password, origin)
+
+ private suspend fun forgetPerListState(listId: Long) {
+ val ids = setOf(listId)
+ cadence.forget(ids)
+ quarantine.forget(ids)
+ // The subscription went with the collection on the server.
+ push.forget(ids)
+ }
+
+ private companion object {
+ /** The same agent the sync and the add flow use, so the server names us once. */
+ const val USER_AGENT = "Agendula (Android)"
+
+ /**
+ * Refusals a different path segment can get past, and only those.
+ *
+ * 403 is Nextcloud's trashbin still holding the name; 405 is a server
+ * answering "already a collection there". Everything else — 401, 409,
+ * 423, 507 — means the same thing under any name.
+ */
+ val NAME_REFUSALS = setOf(403, 405)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt
new file mode 100644
index 0000000..f915658
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAdapterService.kt
@@ -0,0 +1,96 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.accounts.Account
+import android.app.Service
+import android.content.AbstractThreadedSyncAdapter
+import android.content.ContentProviderClient
+import android.content.Context
+import android.content.Intent
+import android.content.SyncResult
+import android.os.Bundle
+import android.os.IBinder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration.Companion.minutes
+
+/**
+ * The sync adapter whose entire job is to start a WorkManager job and wait.
+ *
+ * DAVx5's own comment describes the same design: *"We use the sync adapter
+ * framework only for the trigger, actual syncing is implemented with
+ * WorkManager."*
+ *
+ * ⚠️ Registering this is **not optional decoration**.
+ * `ContentService.hasAuthorityAccess()` gates `requestSync`,
+ * `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable`, `getSyncStatus` and
+ * seven more behind a compat change that is on for targetSdk ≥ 34 — which we
+ * are. With no sync adapter registered for our authority, every one of those
+ * calls **returns silently**: no exception, no log, and it passes on a
+ * Robolectric shadow. The visible result is an account permanently reading "Sync
+ * off for all items" with a greyed-out "Sync now", and it is documented on no
+ * Android behaviour-changes page.
+ *
+ * The greying-out is why the app ships its own sync button regardless:
+ * `enabledSyncNowMenu()` needs at least one checked authority switch, and ours
+ * is `userVisible="false"`.
+ */
+class SyncAdapterService : Service() {
+
+ private val adapter by lazy { CalDavSyncAdapter(applicationContext) }
+
+ override fun onBind(intent: Intent?): IBinder = adapter.syncAdapterBinder
+}
+
+private class CalDavSyncAdapter(context: Context) :
+ AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true) {
+
+ override fun onPerformSync(
+ account: Account,
+ extras: Bundle,
+ authority: String,
+ provider: ContentProviderClient,
+ syncResult: SyncResult,
+ ) {
+ val workManager = WorkManager.getInstance(context)
+ val uniqueName = SyncTrigger(context).enqueue(account.name)
+
+ // Block this thread until the work reaches a terminal state. The framework
+ // treats onPerformSync returning as "the sync is done", so returning early
+ // would make every sync look instantaneous and defeat the back-off it
+ // applies on failure. runBlocking is fine here: onPerformSync is already
+ // called on a background thread the framework owns.
+ //
+ // ⚠️ Watch the **unique work name**, not the request id. enqueueUniqueWork
+ // is asynchronous — the WorkSpec row is not written by the time the next
+ // line runs — so a flow keyed on the id emits null for an unknown id and
+ // the wait returns immediately, having waited for nothing. And under
+ // KEEP, when a run is already in flight, our request is never enqueued at
+ // all and its id stays unknown forever. Keying on the name handles both:
+ // it waits for whichever run is actually happening.
+ val infos = runCatching {
+ runBlocking {
+ withTimeoutOrNull(WORKER_TIMEOUT_MINUTES.minutes) {
+ workManager.getWorkInfosForUniqueWorkFlow(uniqueName)
+ .first { infos -> infos.isNotEmpty() && infos.all { it.state.isFinished } }
+ }
+ }
+ }.getOrNull()
+
+ // Counted as a soft error: the engine's own per-collection and
+ // per-resource isolation decides what is actually fatal, and telling the
+ // framework otherwise would have it back off the whole account. Being
+ // deduplicated by KEEP is *not* a failure — the sync is happening, this
+ // trigger simply joined the one already running.
+ val timedOut = infos == null
+ val failed = infos?.any { it.state == WorkInfo.State.FAILED } == true
+ if (timedOut || failed) syncResult.stats.numIoExceptions++
+ }
+
+ private companion object {
+ /** DAVx5 uses the same ceiling; an ordinary worker is documented for < 10 min. */
+ const val WORKER_TIMEOUT_MINUTES = 10L
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt
new file mode 100644
index 0000000..4538fe7
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAuthenticator.kt
@@ -0,0 +1,109 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.AccountManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.IBinder
+
+/**
+ * The account authenticator.
+ *
+ * Agendula holds no auth tokens — a CalDAV account is a username and an app
+ * password, and the password lives in [CredentialStore], not here.
+ * `AccountManager` stores passwords as plain `TEXT`; there is no encryption or
+ * hashing anywhere in AOSP, so nothing secret is handed to it.
+ *
+ * It is **not** required by any
+ * provider — that argument was circular. The real reasons: a stable account
+ * identity a third-party engine could address, presence in system Settings, and
+ * the sync framework as a change trigger.
+ */
+class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
+
+ /**
+ * ⚠️ Refuses until the account-add UI exists.
+ *
+ * The authenticator service is exported and registered, so Settings →
+ * Accounts → Add account lists Agendula **today**. Handing back an intent to
+ * a screen that does not yet handle [ACTION_ADD_ACCOUNT] would open the
+ * ordinary home screen while Settings waits forever on a response nothing
+ * answers. A refusal the user can read is strictly better than a hang; 2d
+ * replaces this with the real intent and answers [response].
+ */
+ override fun addAccount(
+ response: AccountAuthenticatorResponse?,
+ accountType: String?,
+ authTokenType: String?,
+ requiredFeatures: Array?,
+ options: Bundle?,
+ ): Bundle = unsupported("Add a CalDAV account from inside Agendula, under Settings")
+
+ override fun editProperties(
+ response: AccountAuthenticatorResponse?,
+ accountType: String?,
+ ): Bundle = Bundle()
+
+ /**
+ * ⚠️ Never `null`. `AbstractAccountAuthenticator.Transport` reads a null
+ * return as "I will answer asynchronously via the response", and nothing here
+ * ever does — the caller's `AccountManagerFuture` would never complete.
+ */
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ options: Bundle?,
+ ): Bundle = unsupported("Agendula does not confirm credentials from the system UI")
+
+ /** No token type: this is Basic/Digest against a CalDAV server. */
+ override fun getAuthToken(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ authTokenType: String?,
+ options: Bundle?,
+ ): Bundle = unsupported("Agendula accounts do not use auth tokens")
+
+ override fun getAuthTokenLabel(authTokenType: String?): String? = null
+
+ /** Never `null`, for the reason given on [confirmCredentials]. */
+ override fun updateCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ authTokenType: String?,
+ options: Bundle?,
+ ): Bundle = unsupported("Re-authenticate from inside Agendula, under Settings")
+
+ override fun hasFeatures(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ features: Array?,
+ ): Bundle = Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
+
+ private fun unsupported(message: String) = Bundle().apply {
+ putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
+ putString(AccountManager.KEY_ERROR_MESSAGE, message)
+ }
+
+ companion object {
+ /** Sent to `MainActivity` when the system asks us to add an account (chunk 2d). */
+ const val ACTION_ADD_ACCOUNT = "de.jeanlucmakiola.agendula.ADD_ACCOUNT"
+ }
+}
+
+/**
+ * Binds [SyncAuthenticator] for the system.
+ *
+ * Exported and guarded by `android.permission.ACCOUNT_MANAGER` — note that
+ * `android.permission.ACCOUNT_AUTHENTICATOR`, which the obvious guess would
+ * reach for, **does not exist**.
+ */
+class AuthenticatorService : Service() {
+
+ private val authenticator by lazy { SyncAuthenticator(this) }
+
+ override fun onBind(intent: Intent?): IBinder? = authenticator.iBinder
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt
new file mode 100644
index 0000000..f9dff3f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncAvailability.kt
@@ -0,0 +1,32 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
+import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
+import de.jeanlucmakiola.agendula.data.tasks.StorageMode
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Whether CalDAV accounts can do anything right now.
+ *
+ * Sync writes into Agendula's own store. In External mode the screens read a
+ * third-party provider instead, so an account would sync into rows nobody sees.
+ * Read from the stored preference rather than [ProviderResolver.mode], which is
+ * only current once `StorageModeHolder` has mirrored it — and a worker can start
+ * before that.
+ */
+@Singleton
+class SyncAvailability @Inject constructor(
+ private val prefs: SettingsPrefs,
+ private val resolver: ProviderResolver,
+) {
+
+ suspend fun accountsUsable(): Boolean = observe().first()
+
+ fun observe(): Flow = prefs.storageMode.map { stored ->
+ (stored ?: resolver.autoMode()) == StorageMode.OWN
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt
new file mode 100644
index 0000000..135de79
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncCadenceStore.kt
@@ -0,0 +1,92 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
+
+/**
+ * When each collection was last reconciled against a full listing.
+ *
+ * ⚠️ This is the mitigation for the one RFC 6578 failure that has no signal at
+ * all: a token the server still accepts, over a change log it has already
+ * pruned, answers `207` with zero changes and no error. Nothing in the protocol
+ * distinguishes that from "nothing happened". The only defence is to stop
+ * trusting the token periodically and diff a real listing — so the full path is
+ * a permanent safety net, not a fallback, and this is its clock.
+ *
+ * Kept out of Room deliberately: it is scheduling bookkeeping, not user data,
+ * and it must never be part of a backup that could restore a stale "we checked
+ * recently" into a fresh install.
+ */
+@Singleton
+class SyncCadenceStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+) {
+
+ /**
+ * Last *scheduled* full reconciliation per list id.
+ *
+ * ⚠️ Not "the last time a full listing was read". A collection whose server
+ * has no `sync-collection` support reads one on every run, and recording
+ * each would keep this permanently fresh — so nothing hung off the periodic
+ * mark would ever come due again.
+ */
+ suspend fun lastFullSync(): Map =
+ dataStore.data.first()[KEY].orEmpty().mapNotNull { entry ->
+ val separator = entry.lastIndexOf(SEPARATOR)
+ if (separator <= 0) return@mapNotNull null
+ val id = entry.substring(0, separator).toLongOrNull() ?: return@mapNotNull null
+ val at = entry.substring(separator + 1).toLongOrNull() ?: return@mapNotNull null
+ id to Instant.fromEpochSeconds(at)
+ }.toMap()
+
+ /** Merges, rather than replacing, so concurrent accounts do not erase each other. */
+ suspend fun record(reconciled: Map) {
+ if (reconciled.isEmpty()) return
+ dataStore.edit { prefs ->
+ val current = prefs[KEY].orEmpty()
+ .mapNotNull { entry ->
+ val separator = entry.lastIndexOf(SEPARATOR)
+ if (separator <= 0) null else entry.substring(0, separator) to entry
+ }
+ .toMap()
+ .toMutableMap()
+ reconciled.forEach { (id, at) ->
+ current["$id"] = "$id$SEPARATOR${at.epochSeconds}"
+ }
+ prefs[KEY] = current.values.toSet()
+ }
+ }
+
+ /** Forgets a list, so a re-added account starts from a full reconciliation. */
+ suspend fun forget(listIds: Set) {
+ if (listIds.isEmpty()) return
+ dataStore.edit { prefs ->
+ prefs[KEY] = prefs[KEY].orEmpty().filterNot { entry ->
+ entry.substringBefore(SEPARATOR).toLongOrNull() in listIds
+ }.toSet()
+ }
+ }
+
+ companion object {
+ /**
+ * How long a sync token is trusted before a full listing is diffed anyway.
+ *
+ * Long enough that the incremental path still carries almost every sync,
+ * short enough that a silently pruned change log is a day's divergence
+ * rather than an indefinite one.
+ */
+ val FULL_RECONCILIATION_INTERVAL: Duration = 24.hours
+
+ private const val SEPARATOR = '@'
+ private val KEY = stringSetPreferencesKey("sync_last_full")
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt
new file mode 100644
index 0000000..7b16605
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncEngine.kt
@@ -0,0 +1,257 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar
+import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import de.jeanlucmakiola.caldav.CalDavHttp
+import de.jeanlucmakiola.caldav.CalendarCollection
+import de.jeanlucmakiola.caldav.RemoteCalendar
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Syncs one account: every list it owns, against the collection each points at.
+ *
+ * ⚠️ **A failed collection must not fail the account.** One revoked share, one
+ * calendar the server 500s on, must not stop the other four from syncing — so
+ * every collection's outcome is a [SyncReport] rather than an exception, and the
+ * account's own result is the list of them.
+ */
+@Singleton
+class SyncEngine @Inject constructor(
+ private val database: TasksDatabase,
+ private val store: RoomSyncStore,
+ private val credentials: CredentialStore,
+ private val quarantine: QuarantineStore,
+ private val cadence: SyncCadenceStore,
+ private val accountState: AccountStateStore,
+ private val notices: SyncNoticeStore,
+ private val availability: SyncAvailability,
+ private val push: PushRegistrar,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+
+ /** Why an account could not be synced at all, as opposed to one of its lists. */
+ sealed interface Result {
+ data class Synced(
+ val reports: List,
+ /**
+ * What this run destroyed or gave up on that was not already on
+ * record — the caller's cue to say so out loud.
+ */
+ val notices: List = emptyList(),
+ ) : Result
+
+ /** The credential is gone or undecryptable: only re-authentication helps. */
+ data class NeedsSignIn(val accountId: Long, val reason: String) : Result
+
+ data class Misconfigured(val reason: String) : Result
+
+ /** External storage mode: nothing reads what a sync would write, so none runs. */
+ data object Paused : Result
+ }
+
+ suspend fun sync(accountName: String): Result = withContext(io) {
+ if (!availability.accountsUsable()) return@withContext Result.Paused
+ val account = database.accounts().all().firstOrNull { it.displayName == accountName }
+ ?: return@withContext Result.Misconfigured("no such account: $accountName")
+
+ // ⚠️ Before anything reaches the network. A periodic request that outlives
+ // the stop — or a manual trigger on a stopped account — must not spend a
+ // request on a credential we already know the server rejects: Nextcloud
+ // throttles then 429s per source IP, and that lands on the user's other
+ // clients rather than on us.
+ if (accountState.needsSignIn(account.id)) {
+ return@withContext Result.NeedsSignIn(account.id, "waiting for you to sign in again")
+ }
+
+ val username = account.username
+ ?: return@withContext fatal(account.id, Result.Misconfigured("account has no username"))
+ val origin = account.principalUrl?.toHttpUrlOrNull()
+ ?: return@withContext fatal(
+ account.id,
+ Result.Misconfigured("account has no principal URL"),
+ )
+
+ val password = when (val secret = credentials.get(account.id)) {
+ is CredentialStore.Secret.Present -> secret.value
+ CredentialStore.Secret.Absent -> {
+ stopForSignIn(account.id, "no stored password")
+ return@withContext Result.NeedsSignIn(account.id, "no stored password")
+ }
+ is CredentialStore.Secret.Unrecoverable -> {
+ stopForSignIn(account.id, secret.reason)
+ return@withContext Result.NeedsSignIn(account.id, secret.reason)
+ }
+ }
+
+ val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
+ val subscriptions = push.subscriptionsByHref(account.id)
+ val reports = syncCollections(account) { url ->
+ CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
+ }
+
+ // ⚠️ Before the auth check, not after it. A 401 on one collection does
+ // not un-discard an edit another collection already destroyed, and
+ // returning NeedsSignIn past this point would drop the record of it.
+ // Outside `syncCollections` because that is driven without a network by
+ // the reconciliation tests, which have nothing to say about notices.
+ val fresh = notices.record(
+ accountId = account.id,
+ at = kotlin.time.Clock.System.now(),
+ reports = reports,
+ titles = quarantinedTitles(reports),
+ )
+
+ if (reports.any { it.authFailure }) {
+ // ⚠️ Stop the account rather than let the schedule keep trying.
+ // Nextcloud throttles and then 429s **per source IP**, so a timer on a
+ // dead app password degrades every other Nextcloud client on the
+ // user's network — and there is nothing here to retry: the fix is a
+ // sign-in only the user can perform.
+ stopForSignIn(account.id, "the server rejected the credentials")
+ return@withContext Result.NeedsSignIn(account.id, "the server rejected the credentials")
+ }
+
+ accountState.setNeedsSignIn(account.id, false)
+ // Never fails the sync: push is an optimisation on top of the schedule.
+ runCatching { push.onSynced(account, reports) }
+ Result.Synced(reports, fresh)
+ }
+
+ /**
+ * The local title of each quarantined resource, by href.
+ *
+ * ⚠️ Resolved here rather than left to the store, which has no database.
+ * Without it the user is told "a task has stopped syncing" over a row
+ * reading `a1f9c3e2-….ics` — the opaque blob `SyncNoticeStore` refuses to
+ * show for a discarded edit, and unactionable for exactly the same reason.
+ * A resource we have never stored has no title to find, and its filename is
+ * then genuinely all there is.
+ */
+ private fun quarantinedTitles(reports: List): Map =
+ reports.filter { it.quarantined.isNotEmpty() }
+ .flatMap { report ->
+ val wanted = report.quarantined.mapTo(mutableSetOf()) { it.href }
+ store.rowsIn(report.listId)
+ .filter { it.href in wanted && !it.title.isNullOrBlank() }
+ .map { it.href!! to it.title!! }
+ }
+ .toMap()
+
+ /**
+ * Records why the account could not be synced at all.
+ *
+ * ⚠️ Without this the row keeps its old `lastSyncAt`, and the accounts screen
+ * goes on reporting "synced 5 minutes ago" for an account whose credential
+ * can no longer be decrypted — the silent failure the account layer exists to
+ * avoid.
+ */
+ /**
+ * Marks an account as stopped until the user signs in again.
+ *
+ * ⚠️ It does **not** cancel the work, even though stopping the timer is the
+ * whole point — because this runs *inside* `SyncWorker`, and one of the two
+ * unique names it would cancel is the WorkSpec currently executing us.
+ * WorkManager would interrupt the coroutine, so `Result.NeedsSignIn` would
+ * never be returned and the adapter would see CANCELLED rather than FAILED.
+ *
+ * The flag does the work instead: [sync] refuses before touching the network,
+ * so a firing that survives costs nothing, and [AccountRepository.rescheduleAll]
+ * cancels the schedule from outside any worker.
+ */
+ private suspend fun stopForSignIn(accountId: Long, reason: String) {
+ accountState.setNeedsSignIn(accountId, true)
+ database.accounts().recordSync(accountId, at = null, error = reason)
+ }
+
+ private fun fatal(accountId: Long, result: Result): Result {
+ val reason = when (result) {
+ is Result.NeedsSignIn -> result.reason
+ is Result.Misconfigured -> result.reason
+ is Result.Synced, Result.Paused -> return result
+ }
+ database.accounts().recordSync(accountId, at = null, error = reason)
+ return result
+ }
+
+ /** Split out from [sync] so the reconciliation can be driven without a network. */
+ internal suspend fun syncCollections(
+ account: AccountEntity,
+ remoteFor: (HttpUrl) -> RemoteCalendar,
+ ): List {
+ // Lists owing a DELETE first: a task moved between two of this account's
+ // collections then leaves the old one before it arrives in the new one,
+ // which a server that keeps UIDs unique per account needs.
+ val owing = database.tasks().listsWithTombstones().toSet()
+ val lists = database.taskLists().syncedForAccount(account.id)
+ .sortedBy { it.id !in owing }
+ val listIds = lists.map { it.id }.toSet()
+
+ // ⚠️ Only this account's keys are written back. The counts are global
+ // while the worker's uniqueness is only per account, so replacing the
+ // whole map would discard a concurrently syncing account's increments and
+ // resurrect the counters it had cleared.
+ val before = quarantine.counts()
+ val counts = before.toMutableMap()
+ val syncer = CollectionSyncer(store)
+
+ val now = kotlin.time.Clock.System.now()
+ val lastFull = cadence.lastFullSync()
+
+ // Never reconciled, or the token has been trusted long enough.
+ val due = lists.associate { list ->
+ val since = lastFull[list.id]
+ list.id to (since == null || now - since >= SyncCadenceStore.FULL_RECONCILIATION_INTERVAL)
+ }
+
+ val reports = lists.map { list ->
+ val url = list.href?.toHttpUrlOrNull()
+ ?: return@map SyncReport(list.id, list.name, failure = "list has no collection URL")
+ syncer.sync(
+ list = list,
+ remote = remoteFor(url),
+ quarantine = counts,
+ fullReconciliationDue = due[list.id] == true,
+ )
+ }
+
+ // ⚠️ Only the runs that were *due*. A server without `sync-collection`
+ // reconciles in full every single time, so recording each one kept the
+ // clock permanently fresh and `fullReconciliationDue` permanently false
+ // — which costs nothing on that path, since the cursor is null anyway,
+ // but silently disables everything else hung off the periodic mark. The
+ // download-side quarantine probe is the one that matters: for exactly
+ // those servers it would never have fired.
+ cadence.record(
+ reports.filter { it.reconciledInFull && it.failure == null && due[it.listId] == true }
+ .associate { it.listId to now },
+ )
+
+ fun mine(key: String) = key.substringBefore('|').toLongOrNull() in listIds
+ quarantine.merge(
+ updates = counts.filterKeys(::mine),
+ cleared = before.keys.filter(::mine).filterNot { it in counts }.toSet(),
+ )
+ database.accounts().recordSync(
+ accountId = account.id,
+ at = now,
+ error = reports.mapNotNull { it.failure }.firstOrNull(),
+ )
+ return reports
+ }
+
+ private companion object {
+ /**
+ * Matches what the account-add flow signed in with, so Nextcloud's
+ * Settings → Security → Devices & sessions keeps naming the app password
+ * after the app rather than after OkHttp.
+ */
+ const val USER_AGENT = "Agendula (Android)"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt
new file mode 100644
index 0000000..b114d32
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncFailure.kt
@@ -0,0 +1,81 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+/**
+ * Why a sync failed, in classes a user can act on.
+ *
+ * `accounts.last_sync_error` holds the engine's own words — a collection
+ * failure wrapping an exception's `toString()` — which are for logs and never
+ * for the screen. This reads the class back out of them.
+ */
+data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
+
+ enum class Kind {
+ /** The server refused the credentials. */
+ SIGN_IN,
+
+ /** DNS, a refused connection, a timeout: nothing answered. */
+ UNREACHABLE,
+
+ /** The TLS handshake failed — an untrusted or mismatched certificate. */
+ CERTIFICATE,
+
+ /** The server answered, with an error of its own. */
+ SERVER,
+
+ /** The account or a list is missing something the sync needs. */
+ MISCONFIGURED,
+
+ /** Anything else: the collection did not finish, for a reason we do not name. */
+ OTHER,
+ }
+
+ companion object {
+
+ fun of(error: String): SyncFailure {
+ val code = HTTP_CODE.find(error)?.groupValues?.get(1)?.toIntOrNull()
+ return when {
+ TLS.any { it in error } -> SyncFailure(Kind.CERTIFICATE)
+ AUTH.any { it in error } || code == 401 -> SyncFailure(Kind.SIGN_IN)
+ NETWORK.any { it in error } -> SyncFailure(Kind.UNREACHABLE)
+ CONFIG.any { it in error } -> SyncFailure(Kind.MISCONFIGURED)
+ "ServiceUnavailableException" in error -> SyncFailure(Kind.SERVER, code ?: 503)
+ code != null -> SyncFailure(Kind.SERVER, code)
+ else -> SyncFailure(Kind.OTHER)
+ }
+ }
+
+ private val HTTP_CODE = Regex("""\bHTTP (\d{3})\b""")
+
+ private val TLS = listOf(
+ "SSLHandshakeException",
+ "SSLPeerUnverifiedException",
+ "CertPathValidatorException",
+ "CertificateException",
+ "SSLException",
+ )
+
+ private val AUTH = listOf(
+ "UnauthorizedException",
+ "rejected the credentials",
+ "no stored password",
+ )
+
+ private val NETWORK = listOf(
+ "UnknownHostException",
+ "ConnectException",
+ "NoRouteToHostException",
+ "SocketTimeoutException",
+ "InterruptedIOException",
+ "SocketException",
+ "EOFException",
+ "timeout",
+ )
+
+ private val CONFIG = listOf(
+ "no such account",
+ "has no username",
+ "has no principal URL",
+ "has no collection URL",
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt
new file mode 100644
index 0000000..d10e7b5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeNotifier.kt
@@ -0,0 +1,191 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.agendula.MainActivity
+import de.jeanlucmakiola.agendula.R
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Tells the user what a background sync destroyed or gave up on.
+ *
+ * ⚠️ A notification, and not only a row on the accounts screen. Sync runs on a
+ * four-hour timer while the app is closed, so a surface the user has to go and
+ * look at means the discarded edit is discovered — if ever — days later, next to
+ * a task that quietly says something else than what they typed. The account
+ * screen keeps the detail; this is what makes them go there.
+ *
+ * Its own channel, at `IMPORTANCE_LOW`: it is a report rather than an alarm, and
+ * it must be silenceable without taking due-task reminders with it.
+ */
+@Singleton
+class SyncNoticeNotifier @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ fun canPost(): Boolean {
+ val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+
+ // canPost() checks POST_NOTIFICATIONS before we ever call notify().
+ @SuppressLint("MissingPermission")
+ fun post(accountName: String, notices: List) {
+ if (notices.isEmpty() || !canPost()) return
+ ensureChannel()
+
+ val discarded = notices.count { it.kind == SyncNotice.Kind.DISCARDED_EDIT }
+ val quarantined = notices.size - discarded
+ // ⚠️ A discarded edit outranks a quarantine even when there are more
+ // quarantines, and the collapsed line says so. They are not equivalent:
+ // an edit that lost is work already destroyed and unrecoverable, while a
+ // quarantined task is a condition that persists and clears itself. The
+ // big text below lists both, in full, whichever headline was chosen.
+ val title = if (discarded > 0) {
+ context.resources.getQuantityString(
+ R.plurals.sync_notice_discarded_title, discarded, discarded,
+ )
+ } else {
+ context.resources.getQuantityString(
+ R.plurals.sync_notice_quarantined_title, quarantined, quarantined,
+ )
+ }
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(title)
+ .setContentText(context.getString(R.string.sync_notice_body, accountName))
+ .setStyle(NotificationCompat.BigTextStyle().bigText(summaryOf(notices)))
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setAutoCancel(true)
+ .setContentIntent(
+ PendingIntent.getActivity(
+ context,
+ accountName.hashCode(),
+ MainActivity.openIntent(context),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ .build()
+
+ // Tagged by account, so a second account's news replaces nothing.
+ NotificationManagerCompat.from(context).notify(accountName, NOTIFICATION_ID, notification)
+ }
+
+ /** "Sign in to again", for a background sync the server refused. */
+ // canPost() checks POST_NOTIFICATIONS before we ever call notify().
+ @SuppressLint("MissingPermission")
+ fun postSignIn(accountName: String, accountId: Long) {
+ if (!canPost()) return
+ ensureSignInChannel()
+ val body = context.getString(R.string.sync_sign_in_body)
+ val notification = NotificationCompat.Builder(context, SIGN_IN_CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(context.getString(R.string.sync_sign_in_title, accountName))
+ .setContentText(body)
+ .setStyle(NotificationCompat.BigTextStyle().bigText(body))
+ .setCategory(NotificationCompat.CATEGORY_ERROR)
+ .setAutoCancel(true)
+ .setContentIntent(
+ PendingIntent.getActivity(
+ context,
+ accountId.toInt(),
+ signInIntent(accountId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ .build()
+ NotificationManagerCompat.from(context).notify(accountName, SIGN_IN_NOTIFICATION_ID, notification)
+ }
+
+ /** The account syncs again, so the prompt has done its job. */
+ fun cancelSignIn(accountName: String) {
+ NotificationManagerCompat.from(context).cancel(accountName, SIGN_IN_NOTIFICATION_ID)
+ }
+
+ /**
+ * Where tapping the sign-in prompt lands. Only opens the app for now; the
+ * extra names the account for routing to Settings → Accounts → it.
+ */
+ private fun signInIntent(accountId: Long): Intent =
+ MainActivity.openIntent(context).putExtra(EXTRA_SIGN_IN_ACCOUNT_ID, accountId)
+
+ private fun ensureSignInChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (manager.getNotificationChannel(SIGN_IN_CHANNEL_ID) != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(
+ SIGN_IN_CHANNEL_ID,
+ context.getString(R.string.sync_sign_in_channel_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply { description = context.getString(R.string.sync_sign_in_channel_desc) },
+ )
+ }
+
+ /**
+ * The first few, by name.
+ *
+ * ⚠️ A count on its own is unactionable — "3 edits were replaced" leaves the
+ * user to guess which three, across every list they own. The names are the
+ * only part that makes the account screen worth opening.
+ */
+ private fun summaryOf(notices: List): String {
+ val named = notices.take(SUMMARY_LIMIT).joinToString("\n") { notice ->
+ val subject = notice.subject.ifBlank { context.getString(R.string.task_untitled) }
+ context.getString(R.string.sync_notice_line, subject, notice.listName)
+ }
+ val rest = notices.size - SUMMARY_LIMIT
+ return if (rest > 0) {
+ named + "\n" + context.resources.getQuantityString(R.plurals.sync_notice_more, rest, rest)
+ } else {
+ named
+ }
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ context.getString(R.string.sync_notice_channel_name),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply { description = context.getString(R.string.sync_notice_channel_desc) },
+ )
+ }
+
+ /** Re-create the channel, if it exists, in the current language. */
+ fun refreshChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) ensureChannel()
+ }
+
+ companion object {
+ /** The account a sign-in notification is about, on the intent it opens. */
+ const val EXTRA_SIGN_IN_ACCOUNT_ID = "de.jeanlucmakiola.agendula.extra.SIGN_IN_ACCOUNT_ID"
+
+ private const val CHANNEL_ID = "sync_notices"
+ private const val NOTIFICATION_ID = 2
+ private const val SIGN_IN_CHANNEL_ID = "account_sign_in"
+ private const val SIGN_IN_NOTIFICATION_ID = 3
+
+ /** Enough to recognise the work; the screen has the rest. */
+ private const val SUMMARY_LIMIT = 5
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt
new file mode 100644
index 0000000..b2995ba
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStore.kt
@@ -0,0 +1,279 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import java.util.Base64
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Instant
+
+/**
+ * One thing a sync did that the user would not otherwise find out about.
+ *
+ * ⚠️ Conflict policy is **server wins, local edit discarded**, and
+ * [SyncReport]'s own doc says the report is "the other half of the decision, not
+ * a nice-to-have". Until this existed the other half was a `Log.i` — the edit was
+ * gone, nothing in `ui/` read `discardedEdits`, and from where the user sits that
+ * is indistinguishable from the app losing their work.
+ */
+data class SyncNotice(
+ val accountId: Long,
+ val listName: String,
+ val kind: Kind,
+ /** The task's title for a discarded edit, the resource's name for a quarantine. */
+ val subject: String,
+ /**
+ * What makes this notice distinct from another about a different task.
+ *
+ * ⚠️ Carried but never shown. These are stored as a `Set`, and two
+ * discarded edits from one run share an account, a list, a cause and a
+ * timestamp — so two *untitled* tasks, or two both called "Milk", encoded
+ * identically and one of them silently vanished. The notification counted
+ * two and the screen listed one. The UID is the only thing that tells them
+ * apart, and it is exactly what must not reach the user: opaque text chosen
+ * by whoever created the task.
+ */
+ val key: String,
+ /** Why the edit lost. Null for a quarantine, which has no such choice behind it. */
+ val cause: DiscardedEdit.Cause?,
+ val at: Instant,
+) {
+ enum class Kind { DISCARDED_EDIT, QUARANTINED }
+}
+
+/**
+ * What the last syncs destroyed or gave up on, per account, until it is read.
+ *
+ * The two kinds keep different company, which is why they are written
+ * differently:
+ *
+ * - A **discarded edit** is news. It happened once, it cannot be undone, and a
+ * later clean sync does not make it untrue — so it accumulates and is cleared
+ * only by the user acknowledging it. Replacing the set every run would let a
+ * quiet sync an hour later erase the one thing worth saying.
+ * - A **quarantined resource** is a standing condition: one task has stopped
+ * syncing while the rest of its list is fine. It is re-reported on every run
+ * for as long as it holds, so the account's set of them is *replaced* each
+ * time — which is also how it clears itself the moment the resource starts
+ * working again.
+ *
+ * Lives with the other per-device sync state, and is therefore excluded from
+ * backup — see [SyncStateDataStore]. Correct on its own terms too: a restored
+ * device has not discarded anything.
+ */
+@Singleton
+class SyncNoticeStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+) {
+
+ /** Observed, so a background sync's news reaches a screen that is already open. */
+ fun observeAll(): Flow> = dataStore.data.map { prefs ->
+ prefs[KEY].orEmpty().mapNotNull(::decode).sortedByDescending { it.at }
+ }
+
+ /**
+ * Folds one account's run into the store.
+ *
+ * @return only what is **new**, which is what a notification may be posted
+ * for. A quarantine already on record is a condition the user has already
+ * been told about, and re-announcing it on every four-hour run would train
+ * them to ignore the one that matters.
+ */
+ /**
+ * @param titles the local title of each quarantined resource, by href.
+ * ⚠️ Not optional decoration. Without it the row read
+ * `a1f9c3e2-….ics`, which is the opaque blob this file refuses to show
+ * for a discarded edit — and "a task has stopped syncing" that does not
+ * say which task is the very failure the feature exists to fix. Absent
+ * only for a resource we never stored, where the filename is genuinely
+ * all there is.
+ */
+ suspend fun record(
+ accountId: Long,
+ at: Instant,
+ reports: List,
+ titles: Map = emptyMap(),
+ ): List {
+ val discarded = reports.flatMap { report ->
+ report.discardedEdits.map { edit ->
+ SyncNotice(
+ accountId = accountId,
+ listName = report.listName,
+ kind = SyncNotice.Kind.DISCARDED_EDIT,
+ // The UID is not shown to anyone: it is opaque text chosen by
+ // whoever created the task, routinely a bare hex blob.
+ subject = edit.title.orEmpty(),
+ key = edit.uid,
+ cause = edit.cause,
+ at = at,
+ )
+ }
+ }
+ // ⚠️ Only the ones that have actually stopped. Below the threshold the
+ // resource is still being retried, and "one of your tasks has stopped
+ // syncing" would be untrue of a single 502 from a proxy mid-restart.
+ val quarantined = reports.flatMap { report ->
+ report.quarantined
+ .filter { it.failures >= QuarantineStore.THRESHOLD }
+ .map { resource ->
+ SyncNotice(
+ accountId = accountId,
+ listName = report.listName,
+ kind = SyncNotice.Kind.QUARANTINED,
+ subject = titles[resource.href] ?: resource.href.substringAfterLast('/'),
+ key = resource.href,
+ cause = null,
+ at = at,
+ )
+ }
+ }
+
+ // ⚠️ Nothing to say is the overwhelmingly common case — most syncs
+ // discard nothing and quarantine nothing — and a DataStore edit rewrites
+ // and fsyncs the whole file. Skipped only when there is also nothing on
+ // record to clear, or a recovered resource would keep its notice for ever.
+ if (discarded.isEmpty() && quarantined.isEmpty() && !hasRecord(accountId)) {
+ return emptyList()
+ }
+
+ var added = emptyList()
+ dataStore.edit { prefs ->
+ // ⚠️ Re-read inside `edit`, which DataStore serialises. The set is
+ // global while `SyncWorker`'s uniqueness is only per account, so two
+ // accounts can be folding in at once and a snapshot taken outside
+ // would discard the other's.
+ val current = prefs[KEY].orEmpty().mapNotNull(::decode)
+ val others = current.filter { it.accountId != accountId }
+ val keptDiscards = current.filter {
+ it.accountId == accountId && it.kind == SyncNotice.Kind.DISCARDED_EDIT
+ }
+ val standing = current.filter {
+ it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED
+ }
+ added = discarded + quarantined.filterNot { fresh ->
+ standing.any { it.key == fresh.key }
+ }
+ // Newest first, then capped: an account that has been failing for a
+ // week must not grow this without bound, and the oldest news is the
+ // least actionable.
+ val kept = (discarded + keptDiscards).sortedByDescending { it.at }.take(MAX_PER_ACCOUNT)
+ // ⚠️ Capped as well, and the class doc used to claim it did not need
+ // to be. "Bounded by the collection" is only true of a healthy one:
+ // a server answering 415 to four hundred resources puts four hundred
+ // entries in one preference key, rewritten on every run — and the
+ // account screen renders them into a plain scrolling column.
+ val standingNow = quarantined.take(MAX_PER_ACCOUNT)
+ val updated = (others + kept + standingNow).map(::encode).toSet()
+ // ⚠️ Only when it differs. A DataStore edit rewrites and fsyncs the
+ // whole file, and an account holding one un-dismissed notice would
+ // otherwise pay that on every four-hour sync until the user tapped
+ // "Got it" — which is the cost the fast path above claims to avoid.
+ if (updated != prefs[KEY]) prefs[KEY] = updated
+ }
+ return added
+ }
+
+ private suspend fun hasRecord(accountId: Long): Boolean =
+ dataStore.data.first().let { prefs ->
+ prefs[KEY].orEmpty().mapNotNull(::decode).any { it.accountId == accountId }
+ }
+
+ /**
+ * Forgets one list's notices, for a list that has just been deleted.
+ *
+ * By name, because that is how they are keyed — there is no list id in a
+ * notice, and by the time this is called the row it would have named is
+ * already gone.
+ */
+ suspend fun forgetList(accountId: Long, listName: String) {
+ dataStore.edit { prefs ->
+ val kept = prefs[KEY].orEmpty()
+ .mapNotNull(::decode)
+ .filterNot { it.accountId == accountId && it.listName == listName }
+ .map(::encode)
+ .toSet()
+ if (kept != prefs[KEY]) prefs[KEY] = kept
+ }
+ }
+
+ /** Drops one quarantine notice, for a resource the user has asked to retry. */
+ suspend fun forgetQuarantined(accountId: Long, key: String) {
+ dataStore.edit { prefs ->
+ val kept = prefs[KEY].orEmpty()
+ .mapNotNull(::decode)
+ .filterNot {
+ it.accountId == accountId && it.kind == SyncNotice.Kind.QUARANTINED && it.key == key
+ }
+ .map(::encode)
+ .toSet()
+ if (kept != prefs[KEY]) prefs[KEY] = kept
+ }
+ }
+
+ /** The user has read them. */
+ suspend fun dismiss(accountId: Long) {
+ dataStore.edit { prefs ->
+ prefs[KEY] = prefs[KEY].orEmpty()
+ .mapNotNull(::decode)
+ .filterNot { it.accountId == accountId }
+ .map(::encode)
+ .toSet()
+ }
+ }
+
+ /**
+ * ⚠️ Base64 around the free text, not a delimiter and a hope. A list is
+ * named by its owner and a task is titled by its author, so both can hold
+ * any character at all — including whatever separator looked safe.
+ */
+ private fun encode(notice: SyncNotice): String = listOf(
+ notice.accountId.toString(),
+ notice.kind.name,
+ notice.cause?.name.orEmpty(),
+ notice.at.toEpochMilliseconds().toString(),
+ base64(notice.listName),
+ base64(notice.subject),
+ base64(notice.key),
+ ).joinToString(SEPARATOR)
+
+ private fun decode(entry: String): SyncNotice? {
+ val parts = entry.split(SEPARATOR)
+ if (parts.size != FIELDS) return null
+ val accountId = parts[0].toLongOrNull() ?: return null
+ val kind = SyncNotice.Kind.entries.firstOrNull { it.name == parts[1] } ?: return null
+ val at = parts[3].toLongOrNull() ?: return null
+ return SyncNotice(
+ accountId = accountId,
+ listName = unBase64(parts[4]) ?: return null,
+ kind = kind,
+ subject = unBase64(parts[5]) ?: return null,
+ key = unBase64(parts[6]) ?: return null,
+ cause = DiscardedEdit.Cause.entries.firstOrNull { it.name == parts[2] },
+ at = Instant.fromEpochMilliseconds(at),
+ )
+ }
+
+ private fun base64(value: String): String =
+ Base64.getUrlEncoder().encodeToString(value.toByteArray(Charsets.UTF_8))
+
+ private fun unBase64(value: String): String? = runCatching {
+ String(Base64.getUrlDecoder().decode(value), Charsets.UTF_8)
+ }.getOrNull()
+
+ private companion object {
+ val KEY = stringSetPreferencesKey("sync_notices")
+
+ /** Not present in URL-safe Base64, nor in a decimal or an enum name. */
+ const val SEPARATOR = "|"
+ const val FIELDS = 7
+
+ /** Discarded edits, per account. Quarantines are bounded by the collection. */
+ const val MAX_PER_ACCOUNT = 50
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt
new file mode 100644
index 0000000..d3bf6be
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncOnEdit.kt
@@ -0,0 +1,37 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.util.Log
+import de.jeanlucmakiola.agendula.data.tasks.room.LocalWriteListener
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Pushes a local edit to its account soon after it is made.
+ *
+ * Hooked into the store's own write paths rather than Room's invalidation
+ * tracker: sync writes the same tables, and an observer cannot tell its own
+ * downloads from the user's edits.
+ */
+@Singleton
+class SyncOnEdit @Inject constructor(
+ private val database: TasksDatabase,
+ private val trigger: SyncTrigger,
+) : LocalWriteListener {
+
+ override fun onWritten(listIds: Set) {
+ try {
+ listIds.mapNotNull { database.taskLists().entity(it)?.accountId }
+ .toSet()
+ .mapNotNull { database.accounts().account(it)?.displayName }
+ .forEach(trigger::pushSoon)
+ } catch (e: Exception) {
+ // The edit is saved either way; the schedule picks it up later.
+ Log.w(TAG, "could not schedule a push", e)
+ }
+ }
+
+ private companion object {
+ const val TAG = "SyncOnEdit"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt
new file mode 100644
index 0000000..a45537c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncPushWorker.kt
@@ -0,0 +1,27 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+
+/**
+ * The end of [SyncTrigger.pushSoon]'s debounce: hands the account to a real
+ * sync. Instant, so the REPLACE that restarts the debounce only ever cancels a
+ * timer.
+ */
+@HiltWorker
+class SyncPushWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted parameters: WorkerParameters,
+ private val trigger: SyncTrigger,
+) : CoroutineWorker(context, parameters) {
+
+ override suspend fun doWork(): Result {
+ val accountName = inputData.getString(SyncWorker.KEY_ACCOUNT_NAME) ?: return Result.failure()
+ trigger.enqueueAfterRunning(accountName)
+ return Result.success()
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt
new file mode 100644
index 0000000..8fa17eb
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncReport.kt
@@ -0,0 +1,105 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.caldav.PushSupport
+
+/**
+ * What a sync did, and — the part that matters — what it destroyed.
+ *
+ * ⚠️ Conflict policy is **server wins, local edit discarded**.
+ * That policy terminates, which is why it was chosen over forking under a new
+ * UID, but on its own it is indistinguishable from data loss: the user's edit is
+ * gone and nothing said so. The report is the other half of the decision, not a
+ * nice-to-have — [discardedEdits] is why this type exists.
+ */
+data class SyncReport(
+ val listId: Long,
+ val listName: String,
+ val downloaded: Int = 0,
+ val uploaded: Int = 0,
+ val deletedRemotely: Int = 0,
+ val deletedLocally: Int = 0,
+ /** Local edits thrown away because the server's copy was newer. */
+ val discardedEdits: List = emptyList(),
+ /** Resources the collection gave up on, so the rest of it could finish. */
+ val quarantined: List = emptyList(),
+ /**
+ * Writes sent without `If-Match` because the server offers no usable
+ * validator. Not an error, but the one case where a concurrent edit can be
+ * overwritten without us noticing, so it is said out loud.
+ */
+ val unconditionalWrites: Int = 0,
+ /**
+ * Whether this run reconciled against a full listing rather than a change log.
+ *
+ * ⚠️ Tracked because a token the server accepts over a change log it has
+ * already pruned returns 207, zero changes and no error — RFC 6578 gives no
+ * signal for it at all. The only mitigation is to reconcile in full on a slow
+ * cadence regardless of the token, which means knowing when we last did.
+ */
+ val reconciledInFull: Boolean = false,
+ /**
+ * Why the change-log path was abandoned, on a run the full path then
+ * completed.
+ *
+ * Not a [failure]: the collection is reconciled and the user has nothing to
+ * act on. Kept because a server that rejects `sync-collection` every time
+ * will do it again, and that is worth seeing in a log without it becoming an
+ * error in the UI.
+ */
+ val incrementalNote: String? = null,
+ /**
+ * The server refused our credentials.
+ *
+ * ⚠️ Escalates to the whole account and stops it, unlike every other failure
+ * here. Nextcloud's brute-force protection throttles and then **429s per
+ * source IP**, so a client that keeps retrying a dead app password on a timer
+ * takes the user's *other* Nextcloud clients down with it, on that network,
+ * and looks from the outside like we broke their server. There is nothing to
+ * retry anyway: only the user can fix it.
+ */
+ val authFailure: Boolean = false,
+ /** Set when the collection failed as a whole. The account keeps going. */
+ val failure: String? = null,
+ /**
+ * The collection's own properties were read this run, so [pushSupport] is
+ * the server's answer rather than the absence of one.
+ */
+ val collectionRead: Boolean = false,
+ /** WebDAV-Push, as the collection offered it this run. */
+ val pushSupport: PushSupport? = null,
+) {
+ val hadWork: Boolean
+ get() = downloaded > 0 || uploaded > 0 || deletedRemotely > 0 || deletedLocally > 0
+}
+
+/** One local edit that lost to the server. */
+data class DiscardedEdit(
+ val uid: String,
+ val title: String?,
+ val cause: Cause,
+) {
+ enum class Cause {
+ /** The server's copy changed after we last read it. */
+ SERVER_NEWER,
+
+ /** The task was deleted on the server while it was edited here. */
+ DELETED_ON_SERVER,
+
+ /** Deleted here, but changed on the server after that. The delete lost. */
+ DELETE_LOST,
+ }
+}
+
+/**
+ * A resource the collection stopped trying.
+ *
+ * ⚠️ Quarantine is a **counter, not a backoff**. A single HTTP 400 on one
+ * resource has halted all of a user's calendar sync in DAVx5 for weeks; the
+ * failure has to be contained to the resource that caused it, and the rest of
+ * the collection has to complete.
+ */
+data class QuarantinedResource(
+ val href: String,
+ val reason: String,
+ val failures: Int,
+)
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt
new file mode 100644
index 0000000..b3940c5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStore.kt
@@ -0,0 +1,88 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import javax.inject.Inject
+
+/**
+ * The database, as [CollectionSyncer] needs it.
+ *
+ * A seam, and the reason is the same one that put [CalDavGateway] in front of
+ * discovery: the reconciliation above this interface is where local edits are
+ * discarded, tombstones swept and conflicts resolved, and every one of those is
+ * a decision that should be provable without a device. Room's test double is
+ * Robolectric plus an in-memory database; this is eight methods.
+ */
+interface SyncStore {
+
+ /** Every row in a list, **tombstones included**. */
+ fun rowsIn(listId: Long): List
+
+ fun insert(row: TaskEntity): Long
+
+ fun update(row: TaskEntity)
+
+ fun deleteAll(taskIds: List)
+
+ /**
+ * Records href and ETag on a resource's rows, clearing `is_dirty`.
+ *
+ * The caller excludes any row it left out of the body — that row is deleted,
+ * not marked synced.
+ */
+ fun markSynced(taskIds: List, href: String?, eTag: String?)
+
+ fun setParent(taskId: Long, parentId: Long?)
+
+ /** The master row for a UID — the one with no `RECURRENCE-ID`. */
+ fun masterByUid(listId: Long, uid: String): TaskEntity?
+
+ fun row(taskId: Long): TaskEntity?
+
+ /**
+ * One column, deliberately. A whole-entity update would carry the row as it
+ * looked when the sync started and revert anything the user changed while it
+ * ran.
+ */
+ fun setListReadOnly(listId: Long, readOnly: Boolean)
+
+ /** The RFC 6578 cursor. Null resets the collection to a full reconciliation. */
+ fun setSyncToken(listId: Long, token: String?)
+}
+
+class RoomSyncStore @Inject constructor(
+ private val database: TasksDatabase,
+) : SyncStore {
+
+ override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
+
+ override fun insert(row: TaskEntity) = database.tasks().insert(row)
+
+ override fun update(row: TaskEntity) {
+ database.tasks().update(row)
+ }
+
+ override fun deleteAll(taskIds: List) {
+ if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
+ }
+
+ override fun markSynced(taskIds: List, href: String?, eTag: String?) {
+ if (taskIds.isNotEmpty()) database.tasks().markSynced(taskIds, href, eTag)
+ }
+
+ override fun setParent(taskId: Long, parentId: Long?) {
+ database.tasks().setParent(taskId, parentId)
+ }
+
+ override fun masterByUid(listId: Long, uid: String) = database.tasks().byUid(listId, uid)
+
+ override fun row(taskId: Long) = database.tasks().entity(taskId)
+
+ override fun setListReadOnly(listId: Long, readOnly: Boolean) {
+ database.taskLists().setReadOnly(listId, readOnly)
+ }
+
+ override fun setSyncToken(listId: Long, token: String?) {
+ database.taskLists().setSyncToken(listId, token)
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt
new file mode 100644
index 0000000..9471d2c
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncStubProvider.kt
@@ -0,0 +1,52 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+
+/**
+ * A `ContentProvider` that stores nothing.
+ *
+ * It exists because **a sync adapter is registered against a content
+ * authority**, and Agendula publishes no provider — `:provider` was deleted when
+ * we took our own Room store. Without an authority there
+ * is nothing for `` to name, nothing for
+ * `ContentResolver.requestSync` to address, and nothing for system Settings to
+ * render a sync switch against.
+ *
+ * The sync-adapter registration is not optional:
+ * `ContentService.hasAuthorityAccess()` gates `requestSync`,
+ * `setSyncAutomatically`, `addPeriodicSync`, `setIsSyncable` and seven more
+ * behind a compat change that is **on for targetSdk ≥ 34**, and with nothing
+ * registered every one of those calls returns silently — no exception, no log,
+ * and it passes on a Robolectric shadow. This provider is the cheapest way to
+ * hold up the other end of that requirement.
+ *
+ * Not exported, and every method is a no-op. Real data lives in Room.
+ */
+class SyncStubProvider : ContentProvider() {
+
+ override fun onCreate() = true
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? = null
+
+ override fun getType(uri: Uri): String? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ) = 0
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt
new file mode 100644
index 0000000..fc796f2
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt
@@ -0,0 +1,201 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.Data
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Starts a sync for one account.
+ *
+ * Shared by the sync adapter and by the app's own "sync now", because the app
+ * cannot rely on the system trigger: ⚠️ `ContentService.hasAuthorityAccess()`
+ * gates `requestSync` behind a compat change that is on at targetSdk ≥ 34, and
+ * our authority is `userVisible="false"`, so Settings greys "Sync now" out. The
+ * in-app button enqueues the work directly and is unaffected.
+ */
+@Singleton
+class SyncTrigger @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ /**
+ * Starts a sync now.
+ *
+ * @param expedited for a trigger the user is looking at. ⚠️ Paired with
+ * `RUN_AS_NON_EXPEDITED_WORK_REQUEST`, which is not optional: the expedited
+ * quota is per-app and exhaustible, and the alternative policy
+ * (`DROP_WORK_REQUEST`) silently discards the sync the user just asked for.
+ * Never set from a background trigger — a boot receiver spending the quota
+ * leaves none for the button.
+ * @return the unique work name, which the caller may wait on.
+ */
+ fun enqueue(accountName: String, expedited: Boolean = false): String {
+ val uniqueName = SyncWorker.uniqueNameFor(accountName)
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(inputFor(accountName))
+ .setConstraints(NETWORK)
+ .apply {
+ if (expedited) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ }
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ uniqueName,
+ // KEEP, not REPLACE: a periodic trigger arriving while a manual sync
+ // is mid-flight must not cancel it and lose the cursor.
+ ExistingWorkPolicy.KEEP,
+ request,
+ )
+ return uniqueName
+ }
+
+ /**
+ * Pushes a local edit soon, rather than on the next periodic window.
+ *
+ * Debounced: each call restarts [PUSH_DELAY], so a burst of edits costs one
+ * sync. What waits out the delay is a [SyncPushWorker], which hands over to
+ * [enqueueAfterRunning] — so the replace can only ever cancel a timer, never
+ * a sync that is mid-flight.
+ */
+ fun pushSoon(accountName: String) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(inputFor(accountName))
+ .setInitialDelay(PUSH_DELAY.inWholeSeconds, TimeUnit.SECONDS)
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ pushNameFor(accountName),
+ ExistingWorkPolicy.REPLACE,
+ request,
+ )
+ }
+
+ /**
+ * A sync that runs after any one already running for [accountName].
+ *
+ * Not [enqueue]'s KEEP: a sync already past its upload phase would swallow
+ * this request and leave the edit that prompted it for the next window.
+ */
+ fun enqueueAfterRunning(accountName: String) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(inputFor(accountName))
+ .setConstraints(NETWORK)
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ SyncWorker.uniqueNameFor(accountName),
+ ExistingWorkPolicy.APPEND_OR_REPLACE,
+ request,
+ )
+ }
+
+ /**
+ * A sync for a push message: the server says something changed.
+ *
+ * Appended like [enqueueAfterRunning], since a sync already past its
+ * download would miss the change — but only once. A burst of pushes during
+ * one sync must cost one more sync, not one each.
+ */
+ suspend fun enqueueFromPush(accountName: String) = pushEnqueue.withLock {
+ val waiting = WorkManager.getInstance(context)
+ .getWorkInfosForUniqueWorkFlow(SyncWorker.uniqueNameFor(accountName))
+ .first()
+ .any { it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.BLOCKED }
+ if (!waiting) enqueueAfterRunning(accountName)
+ }
+
+ /** Serialises [enqueueFromPush]'s check and its enqueue across concurrent messages. */
+ private val pushEnqueue = Mutex()
+
+ /**
+ * Puts the account on the periodic schedule.
+ *
+ * A plain `PeriodicWorkRequest` and no foreground service, deliberately —
+ * see [SyncWorker]. WorkManager restores its own schedule after a reboot, so
+ * nothing has to re-arm this from `BOOT_COMPLETED`; that matters because
+ * Android 15 forbids starting a `dataSync` foreground service from boot, and
+ * a design that needed one would have no way to run at all.
+ *
+ * ⚠️ Be honest about the cadence in the UI. The interval setting is a
+ * floor, not a promise: in the `rare` and `restricted` App Standby buckets
+ * network access is off entirely, and the genuine worst case is once overnight.
+ *
+ * @param intervalMinutes the sync-interval setting; 0 (manual only) takes
+ * the account off the schedule.
+ * @param intervalChanged the user just picked a new interval, so a schedule
+ * already in place is replaced rather than kept.
+ */
+ fun schedule(accountName: String, intervalMinutes: Int, intervalChanged: Boolean = false) {
+ val minutes = intervalMinutes
+ if (minutes <= 0) {
+ WorkManager.getInstance(context).cancelUniqueWork(periodicNameFor(accountName))
+ return
+ }
+ val request = PeriodicWorkRequestBuilder(
+ minutes.toLong(), TimeUnit.MINUTES,
+ flexFor(minutes).inWholeMinutes, TimeUnit.MINUTES,
+ )
+ .setInputData(inputFor(accountName))
+ .setConstraints(NETWORK)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ periodicNameFor(accountName),
+ // UPDATE on every call would restart the interval on every app
+ // launch, so a device that is opened often would never reach the
+ // end of one. Only a changed interval replaces it.
+ if (intervalChanged) ExistingPeriodicWorkPolicy.UPDATE else ExistingPeriodicWorkPolicy.KEEP,
+ request,
+ )
+ }
+
+ /** Takes a removed account off the schedule. */
+ fun cancel(accountName: String) {
+ WorkManager.getInstance(context).apply {
+ cancelUniqueWork(periodicNameFor(accountName))
+ cancelUniqueWork(pushNameFor(accountName))
+ cancelUniqueWork(SyncWorker.uniqueNameFor(accountName))
+ }
+ }
+
+ private fun inputFor(accountName: String) =
+ Data.Builder().putString(SyncWorker.KEY_ACCOUNT_NAME, accountName).build()
+
+ private companion object {
+ /** How long a local edit waits for more before it is pushed. */
+ val PUSH_DELAY: Duration = 30.seconds
+
+ /** The tail of each interval the system may run us in: a quarter of it, at most an hour. */
+ fun flexFor(intervalMinutes: Int): Duration =
+ (intervalMinutes / 4).minutes.coerceIn(5.minutes, 1.hours)
+
+ /**
+ * Sync needs a network, and saying so lets WorkManager run us the moment
+ * connectivity returns rather than on the next interval.
+ */
+ val NETWORK: Constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ fun periodicNameFor(accountName: String) = "caldav-sync-periodic:$accountName"
+
+ fun pushNameFor(accountName: String) = "caldav-sync-push:$accountName"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt
new file mode 100644
index 0000000..9a1cedc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncWorker.kt
@@ -0,0 +1,133 @@
+package de.jeanlucmakiola.agendula.data.sync
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import de.jeanlucmakiola.agendula.R
+import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
+
+/**
+ * Where sync actually happens.
+ *
+ * Two things about this worker are decided already. It is a
+ * **`CoroutineWorker` with no foreground service**: an ordinary
+ * worker is documented for under 10 minutes, and escalating to `setForeground`
+ * pulls in `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24
+ * `dataSync` budget whose failure mode is a fatal `RemoteServiceException`, and
+ * a Play requirement for a video demo per declared FGS type. And it must be
+ * **chunked and resumable** — the sync cursor is persisted per collection so a
+ * killed worker resumes rather than restarts, because under WorkManager process
+ * death mid-sync is routine rather than exotic.
+ */
+@HiltWorker
+class SyncWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted parameters: WorkerParameters,
+ private val engine: SyncEngine,
+ private val noticeNotifier: SyncNoticeNotifier,
+ private val reminderScheduler: ReminderScheduler,
+ private val accountState: AccountStateStore,
+) : CoroutineWorker(context, parameters) {
+
+ override suspend fun doWork(): Result {
+ val accountName = inputData.getString(KEY_ACCOUNT_NAME) ?: return Result.failure()
+
+ return when (val outcome = engine.sync(accountName)) {
+ is SyncEngine.Result.Synced -> {
+ // ⚠️ Success even when collections failed. A retry re-runs the
+ // whole account, and WorkManager's backoff would then punish the
+ // four healthy collections for the one that 500s — while the
+ // failing one is already contained by its own quarantine counter.
+ outcome.reports.forEach { report ->
+ if (report.failure != null || report.hadWork) Log.i(TAG, report.toString())
+ }
+ // ⚠️ Said out loud, not only logged. `SyncReport`'s own doc
+ // calls the report "the other half" of server-wins, and this
+ // worker runs on a four-hour timer with the app closed — so a
+ // log line is the same as saying nothing. Only what is new: the
+ // store has already dropped whatever the user has been told.
+ noticeNotifier.post(accountName, outcome.notices)
+ // Nothing else re-arms reminders for what a sync pulled in: in our
+ // own store no provider broadcast fires.
+ if (outcome.reports.any { it.hadWork || it.discardedEdits.isNotEmpty() }) {
+ runCatching { reminderScheduler.sync() }
+ }
+ noticeNotifier.cancelSignIn(accountName)
+ Result.success()
+ }
+
+ // Only the user can fix this, and retrying costs them Nextcloud's
+ // per-IP brute-force throttle — which takes their *other* clients
+ // down with it. Said once per stop, since this runs with the app closed.
+ is SyncEngine.Result.NeedsSignIn -> {
+ if (accountState.markSignInNotified(outcome.accountId)) {
+ noticeNotifier.postSignIn(accountName, outcome.accountId)
+ }
+ Result.failure()
+ }
+
+ is SyncEngine.Result.Misconfigured -> Result.failure()
+
+ // Not a failure: the account is kept, and syncs again once the user
+ // switches back to Agendula's own storage.
+ SyncEngine.Result.Paused -> Result.success()
+ }
+ }
+
+ /**
+ * ⚠️ Implemented **unconditionally**, even though this worker never asks to
+ * run in the foreground.
+ *
+ * `setExpedited` falls back to a foreground service below API 31, and
+ * WorkManager calls this to build it. The default implementation throws
+ * `IllegalStateException`, so a worker that only ever runs expedited on
+ * modern devices crashes on every device running API 29 or 30 — which we
+ * support. It is never actually shown above API 30.
+ */
+ override suspend fun getForegroundInfo(): ForegroundInfo {
+ val manager = applicationContext.getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ applicationContext.getString(R.string.sync_notification_channel),
+ NotificationManager.IMPORTANCE_LOW,
+ ),
+ )
+
+ val notification: Notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setContentTitle(applicationContext.getString(R.string.sync_notification_title))
+ .setSmallIcon(R.drawable.ic_notification)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+
+ // ⚠️ **No `foregroundServiceType`.** Declaring `dataSync` is what drags in
+ // `FOREGROUND_SERVICE_DATA_SYNC`, the Android 15 six-hours-per-24 budget
+ // whose failure mode is a fatal `RemoteServiceException`, and a Play
+ // requirement for a video demo per declared type — the whole tail this
+ // worker exists to avoid. It is not needed either: above API 30
+ // `setExpedited` uses an expedited job and never calls this at all, and
+ // types only became mandatory at API 34.
+ return ForegroundInfo(NOTIFICATION_ID, notification)
+ }
+
+ companion object {
+ /** One in-flight sync per account, so a manual trigger cannot pile up. */
+ fun uniqueNameFor(accountName: String) = "caldav-sync:$accountName"
+
+ const val KEY_ACCOUNT_NAME = "accountName"
+
+ private const val TAG = "SyncWorker"
+ private const val CHANNEL_ID = "sync"
+ private const val NOTIFICATION_ID = 4001
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt
new file mode 100644
index 0000000..a166063
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/AgendulaPushService.kt
@@ -0,0 +1,60 @@
+package de.jeanlucmakiola.agendula.data.sync.push
+
+import android.util.Log
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.agendula.data.di.ApplicationScope
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.PushService
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+import javax.inject.Inject
+
+/**
+ * Where the UnifiedPush distributor reaches us; the instance is the account id.
+ * Work runs in the application scope, since the connector unbinds after a second.
+ */
+@AndroidEntryPoint
+class AgendulaPushService : PushService() {
+
+ @Inject @ApplicationScope lateinit var scope: CoroutineScope
+
+ @Inject lateinit var registrar: PushRegistrar
+
+ @Inject lateinit var messages: PushMessageHandler
+
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
+ val accountId = instance.toLongOrNull() ?: return
+ scope.launch { runCatching { registrar.onNewEndpoint(accountId, endpoint) }.onFailure(::log) }
+ }
+
+ override fun onMessage(message: PushMessage, instance: String) {
+ // Encryption is mandatory in the draft.
+ if (!message.decrypted) {
+ Log.w(TAG, "dropped a push message that did not decrypt")
+ return
+ }
+ val content = message.content.toString(Charsets.UTF_8)
+ scope.launch { runCatching { messages.handle(content, instance) }.onFailure(::log) }
+ }
+
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) {
+ Log.w(TAG, "distributor refused registration for $instance: $reason")
+ // A transient failure leaves the last endpoint valid; the next renewal retries.
+ if (reason == FailedReason.NETWORK || reason == FailedReason.INTERNAL_ERROR) return
+ val accountId = instance.toLongOrNull() ?: return
+ scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
+ }
+
+ override fun onUnregistered(instance: String) {
+ val accountId = instance.toLongOrNull() ?: return
+ scope.launch { runCatching { registrar.onUnregistered(accountId) }.onFailure(::log) }
+ }
+
+ private fun log(error: Throwable) = Log.w(TAG, "push handling failed", error)
+
+ private companion object {
+ const val TAG = "AgendulaPushService"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt
new file mode 100644
index 0000000..bc28d7e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushDistributors.kt
@@ -0,0 +1,79 @@
+package de.jeanlucmakiola.agendula.data.sync.push
+
+import android.content.Context
+import android.content.pm.PackageManager
+import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import org.unifiedpush.android.connector.UnifiedPush
+import org.unifiedpush.android.connector.data.ResolvedDistributor
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Which UnifiedPush distributor delivers our pushes, and whether push is on. The
+ * connector keeps the choice; the on/off switch lives in [SettingsPrefs].
+ */
+@Singleton
+class PushDistributors @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: SettingsPrefs,
+) {
+
+ data class Distributor(val packageName: String, val label: String)
+
+ /** The distributor apps installed right now. */
+ fun installed(): List =
+ UnifiedPush.getDistributors(context)
+ .filter { it != context.packageName }
+ .map { Distributor(it, labelOf(it)) }
+ .sortedBy { it.label.lowercase() }
+
+ /**
+ * The distributor to register with, or null when push is off or none is
+ * usable. With no choice saved, a default or sole distributor is adopted.
+ */
+ suspend fun toUse(): String? {
+ if (!settings.settings.first().pushEnabled) return null
+ UnifiedPush.getSavedDistributor(context)?.let { return it }
+ return when (val resolved = UnifiedPush.resolveDefaultDistributor(context)) {
+ is ResolvedDistributor.Found -> resolved.packageName
+ .takeIf { it != context.packageName }
+ ?.also { UnifiedPush.saveDistributor(context, it) }
+ ResolvedDistributor.ToSelect, ResolvedDistributor.NoneAvailable -> null
+ }
+ }
+
+ /** The saved choice, without resolving a default. For display. */
+ fun saved(): String? = UnifiedPush.getSavedDistributor(context)
+
+ suspend fun select(packageName: String) {
+ UnifiedPush.saveDistributor(context, packageName)
+ settings.setPushEnabled(true)
+ }
+
+ /** Turns push off. Every registration with the distributor goes with it. */
+ suspend fun disable() {
+ settings.setPushEnabled(false)
+ UnifiedPush.removeDistributor(context)
+ }
+
+ /**
+ * Unregisters one instance without losing the user's choice, which the
+ * connector drops along with its last instance.
+ */
+ fun unregister(instance: String) {
+ val chosen = UnifiedPush.getSavedDistributor(context)
+ UnifiedPush.unregister(context, instance)
+ if (chosen != null && UnifiedPush.getSavedDistributor(context) == null) {
+ UnifiedPush.saveDistributor(context, chosen)
+ }
+ }
+
+ fun labelOf(packageName: String): String = try {
+ val info = context.packageManager.getApplicationInfo(packageName, 0)
+ context.packageManager.getApplicationLabel(info).toString()
+ } catch (_: PackageManager.NameNotFoundException) {
+ packageName
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt
new file mode 100644
index 0000000..860c9ac
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushMessageHandler.kt
@@ -0,0 +1,53 @@
+package de.jeanlucmakiola.agendula.data.sync.push
+
+import android.util.Log
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import de.jeanlucmakiola.caldav.WebDavPush
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Turns a WebDAV-Push message into a sync of the account it is about. */
+@Singleton
+class PushMessageHandler @Inject constructor(
+ private val database: TasksDatabase,
+ private val store: PushStore,
+ private val trigger: SyncTrigger,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+
+ /**
+ * @param content the decrypted message body.
+ * @param instance the UnifiedPush instance, which is the account id.
+ */
+ suspend fun handle(content: String, instance: String) = withContext(io) {
+ val accountId = instance.toLongOrNull() ?: return@withContext
+ val account = database.accounts().account(accountId) ?: return@withContext
+ val message = WebDavPush.parse(content)
+
+ val topic = message?.topic
+ if (topic != null) {
+ // Within this account: a shared calendar has one topic across accounts.
+ val list = store.all().values
+ .filter { it.support?.topic == topic }
+ .firstNotNullOfOrNull { push ->
+ database.taskLists().entity(push.listId)?.takeIf { it.accountId == accountId }
+ }
+ if (list == null) {
+ Log.i(TAG, "push for a topic no synced list has")
+ return@withContext
+ }
+ // Already at that state, e.g. our own write echoed back.
+ if (message.syncToken != null && message.syncToken == list.syncToken) return@withContext
+ }
+ // Without a topic (key rotation, unreadable): a sync re-reads the VAPID key.
+ trigger.enqueueFromPush(account.displayName)
+ }
+
+ private companion object {
+ const val TAG = "PushMessageHandler"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt
new file mode 100644
index 0000000..b3faa64
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRegistrar.kt
@@ -0,0 +1,271 @@
+package de.jeanlucmakiola.agendula.data.sync.push
+
+import android.content.Context
+import android.util.Log
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import dagger.hilt.android.qualifiers.ApplicationContext
+import de.jeanlucmakiola.agendula.data.di.IoDispatcher
+import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
+import de.jeanlucmakiola.agendula.data.sync.CredentialStore
+import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
+import de.jeanlucmakiola.agendula.data.sync.SyncReport
+import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
+import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
+import de.jeanlucmakiola.caldav.CalDavHttp
+import de.jeanlucmakiola.caldav.WebDavPush
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import org.unifiedpush.android.connector.UnifiedPush
+import org.unifiedpush.android.connector.data.PushEndpoint
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.days
+
+/**
+ * Keeps WebDAV-Push subscriptions in step with the synced lists, modelled on
+ * DAVx5's `PushRegistrationManager`. [update] registers each account with the
+ * distributor; [onNewEndpoint] subscribes the endpoint it answers with. The
+ * daily [PushRenewalWorker] re-registers, which is what renews subscriptions.
+ */
+@Singleton
+class PushRegistrar @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val database: TasksDatabase,
+ private val store: PushStore,
+ private val distributors: PushDistributors,
+ private val credentials: CredentialStore,
+ private val accountState: AccountStateStore,
+ private val availability: SyncAvailability,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) {
+
+ /** One subscribe/unsubscribe pass at a time, across every entry point. */
+ private val mutex = Mutex()
+
+ /** Records what a sync read about push support, and registers if that changed. */
+ suspend fun onSynced(account: AccountEntity, reports: List) {
+ val read = reports.filter { it.collectionRead }.associate { it.listId to it.pushSupport }
+ if (store.recordSupport(read)) update(account.id)
+ }
+
+ suspend fun updateAll() = mutex.withLock {
+ withContext(io) {
+ database.accounts().all().forEach { updateAccount(it) }
+ scheduleRenewal()
+ }
+ }
+
+ suspend fun update(accountId: Long) = mutex.withLock {
+ withContext(io) {
+ database.accounts().account(accountId)?.let { updateAccount(it) }
+ scheduleRenewal()
+ }
+ }
+
+ /** Our subscription per collection URL, for `Push-Dont-Notify` on the account's writes. */
+ suspend fun subscriptionsByHref(accountId: Long): Map = withContext(io) {
+ val pushes = store.all()
+ database.taskLists().syncedForAccount(accountId).mapNotNull { list ->
+ // Normalised the way SyncEngine spells the URL it looks up.
+ val href = list.href?.toHttpUrlOrNull()?.toString() ?: return@mapNotNull null
+ val subscription = pushes[list.id]?.subscription?.toHttpUrlOrNull() ?: return@mapNotNull null
+ href to subscription
+ }.toMap()
+ }
+
+ /** The distributor's endpoint for [accountId] is ready: subscribe the account's lists to it. */
+ suspend fun onNewEndpoint(accountId: Long, endpoint: PushEndpoint) = mutex.withLock {
+ withContext(io) {
+ val account = database.accounts().account(accountId) ?: return@withContext
+ if (!pushAllowed(account)) return@withContext
+ val client = clientFor(account) ?: return@withContext
+
+ val pushes = store.all()
+ val lists = database.taskLists().syncedForAccount(account.id)
+ val wanted = lists.filter { it.href != null && pushes[it.id]?.support != null }
+ val renewBefore = Clock.System.now() + RENEW_MARGIN
+
+ for (list in wanted) {
+ val push = pushes.getValue(list.id)
+ val current = push.subscription != null &&
+ push.endpoint == endpoint.url &&
+ push.expires?.let { it > renewBefore } == true
+ if (current) continue
+
+ // A changed endpoint means a new subscription, not an update.
+ if (push.endpoint != null && push.endpoint != endpoint.url) {
+ push.subscription?.toHttpUrlOrNull()?.let { WebDavPush.unregister(client, it) }
+ }
+ val collection = list.href!!.toHttpUrlOrNull() ?: continue
+ when (
+ val outcome = WebDavPush.register(
+ client = client,
+ collection = collection,
+ endpoint = endpoint.url,
+ publicKey = endpoint.pubKeySet?.pubKey,
+ authSecret = endpoint.pubKeySet?.auth,
+ expires = Clock.System.now() + REQUESTED_LIFETIME,
+ )
+ ) {
+ is WebDavPush.Registration.Registered -> store.recordSubscription(
+ listId = list.id,
+ subscription = outcome.url?.toString(),
+ endpoint = endpoint.url,
+ expires = outcome.expires,
+ )
+ is WebDavPush.Registration.Refused -> {
+ Log.w(TAG, "push refused for ${list.id}: HTTP ${outcome.code}")
+ store.clearSubscription(list.id)
+ // Stop the account as a sync would; Nextcloud throttles per IP.
+ if (outcome.code == UNAUTHORIZED) {
+ accountState.setNeedsSignIn(account.id, true)
+ return@withContext
+ }
+ }
+ // Retried by the next renewal.
+ is WebDavPush.Registration.Failed -> Log.w(TAG, "push registration failed for ${list.id}: ${outcome.reason}")
+ }
+ }
+
+ // A list that lost push support still holds a subscription nobody wants.
+ val wantedIds = wanted.map { it.id }.toSet()
+ val stale = lists.filter { it.id !in wantedIds && pushes[it.id]?.subscription != null }
+ unsubscribe(client, stale.map { it.id }, pushes)
+ }
+ }
+
+ /** The distributor dropped [accountId]'s registration: its subscriptions lead nowhere. */
+ suspend fun onUnregistered(accountId: Long) = mutex.withLock {
+ withContext(io) {
+ database.accounts().account(accountId)?.let { unsubscribeAll(it) }
+ }
+ }
+
+ /** Before an account is removed, while its credential still works. */
+ suspend fun forgetAccount(accountId: Long) = mutex.withLock {
+ withContext(io) {
+ val account = database.accounts().account(accountId) ?: return@withContext
+ unsubscribeAll(account)
+ distributors.unregister(accountId.toString())
+ store.forget(database.taskLists().syncedForAccount(accountId).map { it.id }.toSet())
+ }
+ }
+
+ /** Before lists stop syncing with [accountId]. */
+ suspend fun forgetLists(accountId: Long, listIds: Set) = mutex.withLock {
+ withContext(io) {
+ if (listIds.isEmpty()) return@withContext
+ val account = database.accounts().account(accountId)
+ val client = account?.let { clientFor(it) }
+ val pushes = store.all()
+ if (client != null) unsubscribe(client, listIds.toList(), pushes)
+ store.forget(listIds)
+ }
+ }
+
+ private suspend fun updateAccount(account: AccountEntity) {
+ val instance = account.id.toString()
+ val distributor = if (pushAllowed(account)) distributors.toUse() else null
+ val pushes = store.all()
+ val capable = database.taskLists().syncedForAccount(account.id)
+ .mapNotNull { pushes[it.id]?.support }
+
+ if (distributor == null || capable.isEmpty()) {
+ // Not unregistered: the connector would forget the user's distributor.
+ unsubscribeAll(account)
+ return
+ }
+
+ val vapid = capable.firstNotNullOfOrNull { it.vapidPublicKey }
+ try {
+ UnifiedPush.register(context, instance, account.displayName, vapid)
+ } catch (_: UnifiedPush.VapidNotValidException) {
+ Log.w(TAG, "server VAPID key for ${account.id} is not usable")
+ UnifiedPush.register(context, instance, account.displayName, null)
+ }
+ }
+
+ private suspend fun unsubscribeAll(account: AccountEntity) {
+ val pushes = store.all()
+ val held = database.taskLists().syncedForAccount(account.id)
+ .filter { pushes[it.id]?.subscription != null }
+ .map { it.id }
+ if (held.isEmpty()) return
+ val client = clientFor(account)
+ if (client != null) {
+ unsubscribe(client, held, pushes)
+ } else {
+ // Without a credential they are left to expire.
+ held.forEach { store.clearSubscription(it) }
+ }
+ }
+
+ /** Tells the server, then forgets locally; after the first failure only forgets. */
+ private suspend fun unsubscribe(client: OkHttpClient, listIds: List, pushes: Map) {
+ var reachable = true
+ listIds.forEach { listId ->
+ val subscription = pushes[listId]?.subscription?.toHttpUrlOrNull()
+ if (reachable && subscription != null) reachable = WebDavPush.unregister(client, subscription)
+ store.clearSubscription(listId)
+ }
+ }
+
+ private suspend fun pushAllowed(account: AccountEntity): Boolean =
+ availability.accountsUsable() && !accountState.needsSignIn(account.id)
+
+ /** Null for an account waiting on sign-in, as in `SyncEngine.sync`. */
+ private suspend fun clientFor(account: AccountEntity): OkHttpClient? {
+ if (accountState.needsSignIn(account.id)) return null
+ val username = account.username ?: return null
+ val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
+ val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
+ return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
+ .newBuilder()
+ .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .build()
+ }
+
+ /** Only while some list could be pushed; nothing to renew otherwise. */
+ private suspend fun scheduleRenewal() {
+ val work = WorkManager.getInstance(context)
+ val needed = distributors.toUse() != null && store.all().values.any { it.support != null }
+ if (!needed) {
+ work.cancelUniqueWork(PushRenewalWorker.NAME)
+ return
+ }
+ val request = PeriodicWorkRequestBuilder(RENEWAL_INTERVAL_DAYS, TimeUnit.DAYS)
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
+ .build()
+ work.enqueueUniquePeriodicWork(PushRenewalWorker.NAME, ExistingPeriodicWorkPolicy.KEEP, request)
+ }
+
+ private companion object {
+ const val TAG = "PushRegistrar"
+ const val USER_AGENT = "Agendula (Android)"
+ const val UNAUTHORIZED = 401
+
+ /** A registration is one small request; removal waits on it. */
+ const val CALL_TIMEOUT_SECONDS = 15L
+
+ /** What we ask for; the draft recommends at least three days. */
+ val REQUESTED_LIFETIME = 3.days
+
+ const val RENEWAL_INTERVAL_DAYS = 1L
+
+ /** Two renewal intervals, since periodic work is not punctual. */
+ val RENEW_MARGIN = (2 * RENEWAL_INTERVAL_DAYS).days
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt
new file mode 100644
index 0000000..d3e0a4b
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushRenewalWorker.kt
@@ -0,0 +1,26 @@
+package de.jeanlucmakiola.agendula.data.sync.push
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+
+/** Re-registers every account daily, which renews the subscriptions; see [PushRegistrar]. */
+@HiltWorker
+class PushRenewalWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted parameters: WorkerParameters,
+ private val registrar: PushRegistrar,
+) : CoroutineWorker(context, parameters) {
+
+ override suspend fun doWork(): Result {
+ registrar.updateAll()
+ return Result.success()
+ }
+
+ companion object {
+ const val NAME = "push-renewal"
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt
new file mode 100644
index 0000000..cee3e30
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/push/PushStore.kt
@@ -0,0 +1,120 @@
+package de.jeanlucmakiola.agendula.data.sync.push
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
+import de.jeanlucmakiola.caldav.PushSupport
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import java.net.URLDecoder
+import java.net.URLEncoder
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Instant
+
+/**
+ * Per synced list: what its server offers for push, and our subscription there.
+ * Kept out of Room and backups, since a subscription names this device's endpoint.
+ */
+@Singleton
+class PushStore @Inject constructor(
+ @SyncStateDataStore private val dataStore: DataStore,
+) {
+
+ data class ListPush(
+ val listId: Long,
+ /** Null when the server offers no push for the list. */
+ val support: PushSupport? = null,
+ /** Where our subscription lives on the server; null when there is none. */
+ val subscription: String? = null,
+ /** The push endpoint [subscription] was registered for. */
+ val endpoint: String? = null,
+ val expires: Instant? = null,
+ )
+
+ suspend fun all(): Map = decode(dataStore.data.first()[KEY].orEmpty())
+
+ fun observe(): Flow