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.

+

CI Android 10+ Kotlin + Compose Material 3 Expressive MIT License +

+ +

+Get it on Obtainium +  +Support me on Ko-fi +

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> = dataStore.data.map { decode(it[KEY].orEmpty()) } + + /** + * Records what the last sync read about each list's push support. + * + * @return whether anything changed, which is what makes a registration due. + */ + suspend fun recordSupport(support: Map): Boolean { + if (support.isEmpty()) return false + var changed = false + update { current -> + support.forEach { (listId, found) -> + val before = current[listId] ?: ListPush(listId) + if (before.support != found) { + changed = true + current[listId] = before.copy(support = found) + } + } + } + return changed + } + + suspend fun recordSubscription(listId: Long, subscription: String?, endpoint: String?, expires: Instant?) = + update { current -> + val before = current[listId] ?: ListPush(listId) + current[listId] = before.copy(subscription = subscription, endpoint = endpoint, expires = expires) + } + + suspend fun clearSubscription(listId: Long) = recordSubscription(listId, null, null, null) + + suspend fun forget(listIds: Set) { + if (listIds.isEmpty()) return + update { current -> listIds.forEach(current::remove) } + } + + private suspend fun update(change: (MutableMap) -> Unit) { + dataStore.edit { prefs -> + // Re-read inside `edit`, which DataStore serialises. + val current = decode(prefs[KEY].orEmpty()).toMutableMap() + change(current) + prefs[KEY] = current.values + .filter { it.support != null || it.subscription != null } + .map(::encode) + .toSet() + } + } + + private fun encode(push: ListPush): String = listOf( + push.listId.toString(), + push.support?.topic, + push.support?.vapidPublicKey, + push.subscription, + push.endpoint, + push.expires?.epochSeconds?.toString(), + ).joinToString(SEPARATOR) { it?.let(::escape).orEmpty() } + + private fun decode(entries: Set): Map = + entries.mapNotNull { entry -> + val parts = entry.split(SEPARATOR).map { part -> part.takeIf { it.isNotEmpty() }?.let(::unescape) } + if (parts.size != FIELDS) return@mapNotNull null + val listId = parts[0]?.toLongOrNull() ?: return@mapNotNull null + listId to ListPush( + listId = listId, + support = parts[1]?.let { PushSupport(topic = it, vapidPublicKey = parts[2]) }, + subscription = parts[3], + endpoint = parts[4], + expires = parts[5]?.toLongOrNull()?.let(Instant::fromEpochSeconds), + ) + }.toMap() + + // Server-chosen strings, escaped so none contains the separator. + private fun escape(value: String): String = URLEncoder.encode(value, "UTF-8") + + private fun unescape(value: String): String = URLDecoder.decode(value, "UTF-8") + + private companion object { + val KEY = stringSetPreferencesKey("push_lists") + const val SEPARATOR = "|" + const val FIELDS = 6 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt index 1ddbfa6..c317238 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/AndroidTasksDataSource.kt @@ -11,13 +11,21 @@ import android.os.Looper import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks +import de.jeanlucmakiola.agendula.data.tasks.room.SPLIT_COUNT_CEILING +import de.jeanlucmakiola.agendula.data.tasks.room.TaskFormWriter import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Instant /** * The only class that knows about the ContentResolver, [TasksContract] and the @@ -66,6 +74,12 @@ class AndroidTasksDataSource @Inject constructor( return rows.firstOrNull { it.distanceFromCurrent == 0 } ?: rows.firstOrNull() } + override fun task(taskId: Long, occurrenceStart: Instant?): Task? { + if (occurrenceStart == null) return task(taskId) + val rows = queryInstances("${Instances.TASK_ID} = ?", arrayOf(taskId.toString())) + return rows.firstOrNull { it.occurrenceStart == occurrenceStart } ?: task(taskId) + } + override fun subtasks(parentTaskId: Long): List = queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString())) @@ -83,27 +97,296 @@ class AndroidTasksDataSource @Inject constructor( } ?: emptyList() } + override fun exportTasks(listId: Long): List { + // projection = null for the same reason queryInstances uses it: the tasks + // table's shape varies across provider versions, and the by-name mapper + // reads what's there. + val uri = TasksContract.tasksUri(authority()) + return resolver.query( + uri, + null, + // _deleted marks a row awaiting a sync round-trip. It's gone as far as + // the user is concerned, so exporting it would resurrect deleted tasks + // in the backup. + "${Tasks.LIST_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)", + arrayOf(listId.toString()), + null, + )?.use { c -> + val reader = CursorColumnReader(c) + buildList { while (c.moveToNext()) add(TaskMapper.exportTask(reader)) } + } ?: emptyList() + } + // --- writes --------------------------------------------------------------- override fun insertTask(form: TaskForm): Long { - val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id) + val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues()) ?: throw TaskWriteFailedException("insert task") return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id") } override fun updateTask(taskId: Long, form: TaskForm) { - val values = TaskWriteMapper.taskValues(form, ZoneId.systemDefault().id) + val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("update task $taskId") } + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) { + val occurrence = task(seriesId, occurrenceStart) + val anchors = resolver.query( + taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null, + )?.use { c -> + if (!c.moveToFirst()) return@use null + val r = CursorColumnReader(c) + r.getLong(Tasks.DTSTART) to r.getLong(Tasks.DUE) + } + fun shifted(anchor: Long?, was: Instant?, now: Instant?): Long? = when { + now == null -> null + anchor == null || was == null -> now.toEpochMilliseconds() + else -> anchor + (now - was).inWholeMilliseconds + } + val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id) + + (Tasks.DTSTART to shifted(anchors?.first, occurrence?.start, form.start)) + + (Tasks.DUE to shifted(anchors?.second, occurrence?.due, form.due)) + val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update series $seriesId") + } + + /** + * The provider has no series split of its own, so it is done by hand: the + * master's rule is ended with an `UNTIL` and the rest continues as a new + * task. Per-occurrence edits past the split point are dropped rather than + * carried over — the provider offers no way to re-parent an exception. + */ + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long { + val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId") + if (spec.rrule == null || occurrenceStart <= spec.anchor) { + updateSeries(seriesId, occurrenceStart, form) + return seriesId + } + // COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3). + val spent = RecurrenceExpander.expand( + spec.copy(exdate = null), + ExpansionWindow(from = spec.anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING), + ).size + endSeriesBefore(seriesId, spec, occurrenceStart) + val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it } + return insertTask(form.copy(rrule = rule, percentComplete = null)) + } + + override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) { + val spec = seriesSpec(seriesId) ?: return + if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId) + endSeriesBefore(seriesId, spec, occurrenceStart) + } + + /** The series' rule set as stored on its master row, or null for a missing row. */ + private fun seriesSpec(seriesId: Long): RecurrenceSpec? = resolver.query( + taskUri(authority(), seriesId), + arrayOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.DTSTART, Tasks.DUE, Tasks.IS_ALLDAY, Tasks.TZ), + null, null, null, + )?.use { c -> + if (!c.moveToFirst()) return@use null + val r = CursorColumnReader(c) + val anchor = r.getLong(Tasks.DTSTART) ?: r.getLong(Tasks.DUE) ?: return@use null + RecurrenceSpec( + rrule = r.getString(Tasks.RRULE)?.ifBlank { null }, + rdate = r.getString(Tasks.RDATE), + exdate = r.getString(Tasks.EXDATE), + anchor = Instant.fromEpochMilliseconds(anchor), + isAllDay = r.getBoolean(Tasks.IS_ALLDAY), + timeZone = r.getString(Tasks.TZ), + ) + } + + /** End the master's rule before [occurrenceStart], and drop the exceptions past it. */ + private fun endSeriesBefore(seriesId: Long, spec: RecurrenceSpec, occurrenceStart: Instant) { + val rule = TaskFormWriter.ruleEndedBefore( + spec.rrule ?: return, + occurrenceStart, + spec.isAllDay, + spec.timeZone, + ZoneId.systemDefault().id, + ) + val values = mapOf(Tasks.RRULE to rule) + val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("end series $seriesId") + exceptionsFrom(seriesId, occurrenceStart).forEach(::deleteTask) + } + + /** Override rows of [seriesId] standing for [from] or a later occurrence. */ + private fun exceptionsFrom(seriesId: Long, from: Instant): List = resolver.query( + TasksContract.tasksUri(authority()), + arrayOf(Tasks.ID, Tasks.ORIGINAL_INSTANCE_TIME), + "${Tasks.ORIGINAL_INSTANCE_ID} = ?", + arrayOf(seriesId.toString()), + null, + )?.use { c -> + val r = CursorColumnReader(c) + buildList { + while (c.moveToNext()) { + val original = r.getLong(Tasks.ORIGINAL_INSTANCE_TIME) ?: continue + if (original >= from.toEpochMilliseconds()) r.getLong(Tasks.ID)?.let(::add) + } + } + } ?: emptyList() + + /** Through the instances URI, which the provider turns into an exception on the series. */ + override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) { + val instanceId = instanceIdFor(seriesId, occurrenceStart) ?: return + resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null) + } + + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence") + val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update instance $instanceId") + } + + /** + * The provider's instance row id for one occurrence. + * + * The seam addresses occurrences by `(taskId, occurrenceStart)`; writing + * through the instances URI still needs the row id, so it is looked up here + * rather than carried around above the data layer. Selection is on `task_id` + * only — the anchor is matched in Kotlin because the column that holds it + * (`instance_original_time`) is missing on older provider schemas, where a + * WHERE clause naming it would throw instead of falling back. + */ + private fun instanceIdFor(taskId: Long, occurrenceStart: Instant): Long? { + val uri = TasksContract.instancesUri(authority()) + val selection = "${Instances.TASK_ID} = ?" + return resolver.query(uri, null, selection, arrayOf(taskId.toString()), null)?.use { c -> + val reader = CursorColumnReader(c) + while (c.moveToNext()) { + if (TaskMapper.occurrenceAnchor(reader) == occurrenceStart) { + return@use reader.getLong(Tasks.ID) + } + } + null + } + } + + override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { + val uri = TasksContract.propertiesUri(authority()) + // Replace rather than update: the provider's AlarmHandler re-validates the + // whole row on every update, so a partial edit throws. Only the editable + // (last before-due) row goes — a start-relative or additional alarm + // another client wrote is not ours to drop. + alarmRows(taskId).lastOrNull { !it.second.fromStart }?.let { (propertyId, _) -> + resolver.delete(uri, "${Properties.PROPERTY_ID} = ?", arrayOf(propertyId.toString())) + } + if (minutesBeforeDue != null) { + resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, minutesBeforeDue).toContentValues()) + ?: throw TaskWriteFailedException("set alarm for task $taskId") + } + } + + override fun setReminders(taskId: Long, reminders: List) { + val uri = TasksContract.propertiesUri(authority()) + resolver.delete( + uri, + "${Properties.TASK_ID} = ? AND ${Properties.MIMETYPE} = ?", + arrayOf(taskId.toString(), TasksContract.Alarm.MIMETYPE), + ) + reminders.forEach { + resolver.insert(uri, TaskWriteMapper.alarmValues(taskId, it.minutesBefore, it.fromStart).toContentValues()) + ?: throw TaskWriteFailedException("set alarms for task $taskId") + } + } + + override fun reminders(): Map> = + readAlarms(null).groupBy({ it.first }) { it.third } + + override fun alarms(): Map = + reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap() + + /** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */ + private fun alarmRows(taskId: Long): List> = + readAlarms(taskId).map { it.second to it.third } + + /** `(task_id, property_id, reminder)` rows, for one task or (with `null`) all. */ + private fun readAlarms(taskId: Long?): List> { + val uri = TasksContract.propertiesUri(authority()) + val projection = arrayOf( + Properties.PROPERTY_ID, + Properties.TASK_ID, + TasksContract.Alarm.MINUTES_BEFORE, + TasksContract.Alarm.REFERENCE, + ) + val selection = buildString { + append("${Properties.MIMETYPE} = ?") + if (taskId != null) append(" AND ${Properties.TASK_ID} = ?") + } + val args = listOfNotNull(TasksContract.Alarm.MIMETYPE, taskId?.toString()).toTypedArray() + return resolver.query(uri, projection, selection, args, Properties.PROPERTY_ID)?.use { c -> + val reader = CursorColumnReader(c) + buildList { + while (c.moveToNext()) { + val propertyId = reader.getLong(Properties.PROPERTY_ID) + val id = reader.getLong(Properties.TASK_ID) + val minutes = reader.getInt(TasksContract.Alarm.MINUTES_BEFORE) + val reference = reader.getInt(TasksContract.Alarm.REFERENCE) + if (propertyId != null && id != null && minutes != null) { + add( + Triple( + id, + propertyId, + TaskReminder( + minutesBefore = minutes, + fromStart = reference == TasksContract.Alarm.REFERENCE_START, + ), + ), + ) + } + } + } + } ?: emptyList() + } + + override fun setCancelled(taskId: Long, cancelled: Boolean) { + val rows = resolver.update(taskUri(authority(), taskId), cancelValues(cancelled).toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("cancel task $taskId") + } + + /** Through the instances URI, like [setCompletedInstance]. */ + override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: return setCancelled(taskId, cancelled) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, cancelValues(cancelled).toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("cancel instance $instanceId") + } + + private fun cancelValues(cancelled: Boolean) = mapOf( + Tasks.STATUS to if (cancelled) TasksContract.STATUS_CANCELLED else TasksContract.STATUS_NEEDS_ACTION, + Tasks.COMPLETED to null, + ) + override fun setCompleted(taskId: Long, completed: Boolean) { val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null) if (rows == 0) throw TaskWriteFailedException("complete task $taskId") } + /** + * Through the instances URI, which is what makes the provider fork an override + * rather than close the series. No instance row for the anchor means the task + * is not a series after all — the plain write is then the right one. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) { + val instanceId = instanceIdFor(taskId, occurrenceStart) + ?: return setCompleted(taskId, completed) + val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis()) + val uri = TasksContract.instanceUri(authority(), instanceId) + val rows = resolver.update(uri, values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("complete instance $instanceId") + } + override fun deleteTask(taskId: Long) { resolver.delete(taskUri(authority(), taskId), null, null) } @@ -120,6 +403,31 @@ class AndroidTasksDataSource @Inject constructor( return result.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("create local list: no id") } + override fun updateList(listId: Long, name: String, color: Int) { + val values = TaskWriteMapper.listValues(name, color) + val rows = resolver.update(listSyncUri(listId), values.toContentValues(), null, null) + if (rows == 0) throw TaskWriteFailedException("update list $listId") + } + + override fun deleteList(listId: Long) { + val rows = resolver.delete(listSyncUri(listId), null, null) + if (rows == 0) throw TaskWriteFailedException("delete list $listId") + } + + /** + * A list row addressed as its own account's sync adapter — the provider only + * lets that caller write the `tasklists` table, and the account has to be the + * row's own (the params are matched against it, not merely accepted). + */ + private fun listSyncUri(listId: Long): Uri { + val authority = authority() + val uri = TasksContract.listUri(authority, listId) + val account = resolver.query(uri, arrayOf(Lists.ACCOUNT_NAME, Lists.ACCOUNT_TYPE), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0).orEmpty() to c.getString(1).orEmpty() else null } + ?: throw TaskWriteFailedException("list $listId not found") + return TasksContract.asSyncAdapter(uri, account.first, account.second) + } + // --- observation ---------------------------------------------------------- override fun registerObserver(onChange: () -> Unit): AutoCloseable { @@ -127,9 +435,16 @@ class AndroidTasksDataSource @Inject constructor( val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) = onChange() } - resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) - resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) - return AutoCloseable { resolver.unregisterContentObserver(observer) } + // Register both or neither: if the second call throws, the first + // registration would otherwise leak (no AutoCloseable was handed back yet). + try { + resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer) + resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer) + } catch (e: RuntimeException) { + runCatching { resolver.unregisterContentObserver(observer) } + throw e + } + return AutoCloseable { runCatching { resolver.unregisterContentObserver(observer) } } } private fun Map.toContentValues(): ContentValues { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt new file mode 100644 index 0000000..1f25caf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ModeRoutingTasksDataSource.kt @@ -0,0 +1,105 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import javax.inject.Provider +import kotlin.time.Instant + +/** + * Routes every call to the store [StorageMode] selects. + * + * Per-call rather than bound once: the mode is a setting the user can change + * while the process lives, and [StorageModeHolder] pushes the new value into + * [ProviderResolver] without rebuilding the object graph. Both delegates are + * singletons, so this chooses between two existing objects. + * + * Room versus a third-party ContentProvider — nothing else. + */ +class ModeRoutingTasksDataSource( + private val resolver: ProviderResolver, + private val room: Provider, + private val external: Provider, +) : TasksDataSource { + + private fun active(): TasksDataSource = + when (resolver.mode()) { + StorageMode.OWN -> room.get() + StorageMode.EXTERNAL -> external.get() + } + + override fun taskLists(): List = active().taskLists() + override fun tasks(query: TaskQuery): List = active().tasks(query) + override fun task(taskId: Long): Task? = active().task(taskId) + override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart) + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = + active().updateSeries(seriesId, occurrenceStart, form) + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = + active().splitSeries(seriesId, occurrenceStart, form) + override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = + active().deleteOccurrence(seriesId, occurrenceStart) + override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = + active().deleteFollowing(seriesId, occurrenceStart) + override fun subtasks(parentTaskId: Long): List = active().subtasks(parentTaskId) + override fun insertTask(form: TaskForm): Long = active().insertTask(form) + override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form) + + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = + active().updateInstance(taskId, occurrenceStart, form) + + override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue) + override fun alarms(): Map = active().alarms() + override fun reminders(): Map> = active().reminders() + override fun setReminders(taskId: Long, reminders: List) = + active().setReminders(taskId, reminders) + override fun exportTasks(listId: Long): List = active().exportTasks(listId) + override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed) + override fun setCancelled(taskId: Long, cancelled: Boolean) = active().setCancelled(taskId, cancelled) + + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = + active().setCompletedInstance(taskId, occurrenceStart, completed) + + override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = + active().setCancelledInstance(taskId, occurrenceStart, cancelled) + + override fun deleteTask(taskId: Long) = active().deleteTask(taskId) + override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color) + override fun updateList(listId: Long, name: String, color: Int) = active().updateList(listId, name, color) + override fun deleteList(listId: Long) = active().deleteList(listId) + override fun reorderLists(listIds: List) = active().reorderLists(listIds) + /** + * Unlike every other method here, an observer is registered once and then + * *held* — so it cannot be routed per call, and would otherwise stay bound to + * whichever store was active when the flow started. Switching stores in + * Settings would then leave every open screen listening to the store it is no + * longer reading from. + * + * So the registration moves with the mode, and the switch itself counts as a + * change: the data underneath every live flow has just been replaced. + */ + override fun registerObserver(onChange: () -> Unit): AutoCloseable { + val lock = Any() + var closed = false + var handle: AutoCloseable? = runCatching { active().registerObserver(onChange) }.getOrNull() + + val modeHandle = resolver.onModeChanged { + synchronized(lock) { + if (!closed) { + handle?.let { runCatching { it.close() } } + handle = runCatching { active().registerObserver(onChange) }.getOrNull() + } + } + onChange() + } + return AutoCloseable { + synchronized(lock) { + closed = true + modeHandle.close() + handle?.let { runCatching { it.close() } } + handle = null + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt new file mode 100644 index 0000000..e60193f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderEnvironment.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The two platform facts [ProviderResolver] needs, behind an interface. + * + * Same seam the data source uses, for the same reason: which store a returning + * user lands on is decided by [ProviderResolver.autoMode], getting it wrong shows + * them an empty app, and that decision is worth testing on the JVM rather than + * only on a device. Everything Android-shaped lives here so the logic above stays + * plain Kotlin. + */ +interface ProviderEnvironment { + + /** The package declaring [authority], or `null` when nothing on the device does. */ + fun packageDeclaring(authority: String): String? + + /** Whether this app currently holds [permission]. */ + fun isGranted(permission: String): Boolean + + /** [packageName]'s own app name, or null when it cannot be read. */ + fun appLabel(packageName: String): String? +} + +@Singleton +class AndroidProviderEnvironment @Inject constructor( + @ApplicationContext private val context: Context, +) : ProviderEnvironment { + + override fun packageDeclaring(authority: String): String? = + context.packageManager.resolveContentProvider(authority, 0)?.packageName + + override fun isGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + override fun appLabel(packageName: String): String? = runCatching { + val pm = context.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + }.getOrNull() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt new file mode 100644 index 0000000..472df64 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderFlow.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.retryWhen + +private const val BASE_RETRY_MS = 1_000L +private const val MAX_RETRY_MS = 30_000L + +/** 1s, 2s, 4s … capped at 30s, so a permanently-absent provider costs little. */ +private fun retryDelayMs(attempt: Long): Long = + (BASE_RETRY_MS shl attempt.coerceAtMost(5).toInt()).coerceAtMost(MAX_RETRY_MS) + +/** + * Recover a provider-backed flow without killing it. + * + * Provider reads fail for reasons that resolve on their own: the read permission + * isn't granted yet (first launch collects before the permission gate), or the + * provider app is mid-update. A terminal `catch` swallows the failure *and* + * cancels the upstream, so the flow never produces again — the screen stays empty + * until the process restarts, even after the user grants the permission. + * + * This emits [fallback] instead and keeps retrying with a capped backoff, so the + * collector recovers on its own once the provider becomes readable. + */ +fun Flow.recoveringFromProviderFailure(fallback: () -> T): Flow = + retryWhen { _, attempt -> + emit(fallback()) + delay(retryDelayMs(attempt)) + true + } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt index ba24eb0..e43c910 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolver.kt @@ -1,15 +1,12 @@ package de.jeanlucmakiola.agendula.data.tasks -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton /** - * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every - * candidate, so the [TasksContract] columns apply regardless of which is present. + * An external tasks provider Agendula can talk to. Every candidate runs the same + * dmfs `TaskProvider`, so the [TasksContract] columns apply to either. */ data class TaskProvider( val authority: String, @@ -19,41 +16,122 @@ data class TaskProvider( ) /** - * The A/B seam. Detects which tasks provider is installed at runtime and which - * permission set it needs, so nothing above the data layer hardcodes an - * authority. Under Posture B (bundled provider) this simply finds our own - * `org.dmfs.tasks` first. See docs/PLAN.md. + * Discovers the *external* tasks providers (OpenTasks, tasks.org) that + * [StorageMode.EXTERNAL] can be pointed at. + * + * This used to be the A/B seam between an external provider and one Agendula + * bundled itself. That second half is gone: [StorageMode.OWN] is a Room database + * with no authority, no ContentResolver and nothing to permit, so there is + * nothing here for it to resolve. + * + * Which store is active comes from [storageMode]; how that gets decided when the + * user has not chosen is [autoMode]. */ @Singleton class ProviderResolver @Inject constructor( - @ApplicationContext private val context: Context, + private val environment: ProviderEnvironment, ) { - /** The active provider, or `null` when no tasks provider is installed. */ - fun resolve(): TaskProvider? { - for (candidate in CANDIDATES) { - val info = context.packageManager.resolveContentProvider(candidate.authority, 0) - ?: continue - return candidate.copy(packageName = info.packageName) + private val modeListeners = CopyOnWriteArrayList<() -> Unit>() + + /** + * The user's explicit choice, or `null` while they have not made one (which is + * the normal state — most people never open Settings). Kept as a plain field + * rather than read from DataStore on demand because [resolve] is called from + * synchronous data-source code on every query, including from the main thread + * via `providerStatus()`. [StorageModeHolder] owns keeping it current. + * + * Assigning a *different* mode notifies [onModeChanged]: every live store + * observer is bound to one store and has to be moved across. + */ + @Volatile + var storageMode: StorageMode? = null + set(value) { + val changed = field != value + field = value + if (changed) modeListeners.forEach { it() } + } + + /** + * Observe switches between stores. Fires on the thread that set [storageMode] + * — [StorageModeHolder]'s collector — so listeners must be cheap and must not + * block. + */ + fun onModeChanged(listener: () -> Unit): AutoCloseable { + modeListeners += listener + return AutoCloseable { modeListeners -= listener } + } + + /** The active store, resolving the undecided case through [autoMode]. */ + fun mode(): StorageMode = storageMode ?: autoMode() + + /** + * The provider to query, or `null` — either because [StorageMode.OWN] is + * active and there is no provider involved at all, or because + * [StorageMode.EXTERNAL] is and none is installed. Callers that need to tell + * those apart ask [mode]. + */ + fun resolve(): TaskProvider? = when (mode()) { + StorageMode.OWN -> null + StorageMode.EXTERNAL -> resolveExternal() + } + + /** + * What to use when the user has not chosen — and the one piece of real + * judgement in this class, because getting it wrong loses people their data. + * + * Ranking our own store first unconditionally would be wrong: someone who + * has been using Agendula over OpenTasks since 0.3.x would update, land on an + * empty database, and reasonably conclude their tasks were deleted. + * + * So the tell is **whether we already hold an external provider's runtime + * permission**. That is a dangerous permission — it can only be there because + * a previous version asked and the user agreed, which is precisely the + * definition of "this person is an existing Posture A user". A fresh install + * never holds it, and gets our own store. + * + * Deliberately cheap and synchronous: a PackageManager lookup and a permission + * check, no database probe. Settings overrides it either way. + */ + fun autoMode(): StorageMode { + val external = resolveExternal() + return if (external != null && hasPermission(external)) StorageMode.EXTERNAL else StorageMode.OWN + } + + /** The first installed external candidate, or `null` when none is present. */ + fun resolveExternal(): TaskProvider? { + for (candidate in EXTERNAL_CANDIDATES) { + val packageName = environment.packageDeclaring(candidate.authority) ?: continue + return candidate.copy(packageName = packageName) } return null } fun hasPermission(provider: TaskProvider): Boolean = - granted(provider.readPermission) && granted(provider.writePermission) + environment.isGranted(provider.readPermission) && environment.isGranted(provider.writePermission) - private fun granted(permission: String): Boolean = - ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + /** + * Whether the active store can be read at all. + * + * [StorageMode.OWN] always can — it is our own database, with nothing to + * install and nothing to grant. Only [StorageMode.EXTERNAL] can be + * unreadable. Callers that gate on `resolve() != null` instead get this wrong + * the moment OWN is active, because OWN resolves to no provider by design. + */ + fun canReadStore(): Boolean = when (mode()) { + StorageMode.OWN -> true + StorageMode.EXTERNAL -> resolveExternal()?.let(::hasPermission) == true + } companion object { /** * Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by - * `org.dmfs.provider.tasks.TaskProvider`, guarded by - * `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks` - * + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical - * authority; on a device with only one installed, order is moot. + * `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*` + * (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`. + * OpenTasks is listed first as the canonical authority; on a device with + * only one installed, order is moot. */ - val CANDIDATES: List = listOf( + val EXTERNAL_CANDIDATES: List = listOf( TaskProvider( authority = "org.dmfs.tasks", readPermission = "org.dmfs.permission.READ_TASKS", diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt new file mode 100644 index 0000000..fe160f6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StartupGate.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The work that has to finish before anything reads a task store: the stored + * [StorageMode] has to reach [ProviderResolver], and a v0.3.x install's tasks + * have to be imported out of the dmfs provider's file into Room. + * + * Both are startup races with the same shape. Reading before the mode lands + * answers from `autoMode()` instead of the user's choice; reading before the + * import lands shows an upgrading user an empty app, which is the single worst + * thing this migration could do. + */ +@Singleton +class StartupGate @Inject constructor( + private val storageModeHolder: StorageModeHolder, + private val oneShotImport: OneShotImport, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val ready = CompletableDeferred() + + /** Call once, from `Application.onCreate`. */ + fun start() { + storageModeHolder.start() + scope.launch { + // Opens the gate even on failure: a store that cannot be imported is + // still better shown empty than not shown at all, and the source file + // is left where it was either way. + runCatching { + storageModeHolder.awaitReady() + oneShotImport.runIfNeeded() + } + ready.complete(Unit) + } + } + + suspend fun awaitReady() = ready.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt new file mode 100644 index 0000000..6a89f47 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageMode.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.data.tasks + +/** + * Which task store backs the app — the user's choice. + * + * Only two values. + * **Synced is not a third store**: it is [OWN] with an account attached to a + * list, which is derived state rather than something the user picks. Attaching + * one is a plain `UPDATE task_lists SET account_id = ?` — not the full data + * migration it was under the dmfs provider, whose `ACCOUNT_TYPE` was write-once. + */ +enum class StorageMode { + /** + * Agendula's own Room database. The default, and always available: there is + * no authority, no ContentResolver and no permission to grant. + */ + OWN, + + /** + * A tasks provider app already on the device (OpenTasks, tasks.org), synced by + * whatever that provider's engine is — DAVx5 and friends. Still fully + * supported, now a choice rather than the only way. Requires that provider's + * runtime read/write permissions. + */ + EXTERNAL, +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt new file mode 100644 index 0000000..53b94fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/StorageModeHolder.kt @@ -0,0 +1,64 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Mirrors the stored [StorageMode] into [ProviderResolver]. + * + * The resolver is consulted synchronously from every data-source call and from + * `providerStatus()` on the main thread, so it cannot read DataStore itself. + * This is the one component that bridges the two: it collects the preference for + * the life of the process and pushes each value across. + * + * [awaitReady] exists for the startup race. Until the first DataStore emission + * arrives the resolver's mode is `null` and `ProviderResolver.autoMode` answers + * instead — fine as a steady state, wrong for a user who explicitly chose the + * other mode. Anything that touches the provider before the UI is up (the launch + * reminder re-sync, notably) should wait rather than risk reading the wrong + * store and rescheduling every alarm off it. + */ +@Singleton +class StorageModeHolder @Inject constructor( + private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + @ApplicationScope private val scope: CoroutineScope, +) { + + private val firstValue = CompletableDeferred() + + /** Starts mirroring. Idempotent in effect; call once, from `Application.onCreate`. */ + fun start() { + scope.launch { + try { + prefs.storageMode.collect { mode -> + resolver.storageMode = mode + firstValue.complete(Unit) + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // ⚠️ Two failures in one, and the second is the worse. This is a + // root coroutine in a scope with no exception handler, so a read + // that throws — a corrupt file the handler could not replace, a + // filesystem that will not answer — takes the app down at every + // launch. And releasing the gate is not optional either: + // `awaitReady` is awaited before anything touches the provider, + // so failing quietly without it parks every observing flow for + // ever, which is a blank app rather than a crashing one. + // `autoMode` answers from here on, which is the steady state for + // a user who never chose. + firstValue.complete(Unit) + } + } + } + + /** Suspends until the stored mode has been applied at least once. */ + suspend fun awaitReady() = firstValue.await() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt index c8b36ab..e5d810f 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapper.kt @@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask import de.jeanlucmakiola.agendula.domain.priorityFromICal import de.jeanlucmakiola.agendula.domain.statusFromInt import kotlin.time.Instant @@ -16,10 +17,17 @@ object TaskMapper { fun instant(name: String): Instant? = r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } - val instanceId = r.getLong(Tasks.ID) ?: 0L + val rowId = r.getLong(Tasks.ID) ?: 0L + // Derived from the rule columns rather than the `is_recurring` column + // alone: that column only exists from OpenTasks 1.4.0 (DB 23) and is + // absent on tasks.org's bundled provider (DB 22), where reading it + // would silently report every recurring task as one-off — and route + // its edits onto the series anchor. + val recurring = r.getString(Tasks.RRULE) != null || + r.getString(Tasks.RDATE) != null || + r.getBoolean(Instances.IS_RECURRING) return Task( - id = instanceId, - taskId = r.getLong(Instances.TASK_ID) ?: instanceId, + taskId = r.getLong(Instances.TASK_ID) ?: rowId, listId = r.getLong(Tasks.LIST_ID) ?: 0L, title = r.getString(Tasks.TITLE).orEmpty(), description = r.getString(Tasks.DESCRIPTION), @@ -38,10 +46,65 @@ object TaskMapper { listName = r.getString(Tasks.LIST_NAME), accountName = r.getString(Tasks.ACCOUNT_NAME), parentId = r.getLong(Tasks.PARENT_ID), - isRecurring = r.getBoolean(Instances.IS_RECURRING), + isRecurring = recurring, + occurrenceStart = if (recurring) occurrenceAnchor(r) else null, distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT), created = instant(Tasks.CREATED), lastModified = instant(Tasks.LAST_MODIFIED), + seriesId = if (recurring) r.getLong(Instances.TASK_ID) ?: rowId else null, + recurrenceRule = r.getString(Tasks.RRULE), + ) + } + + /** + * The occurrence's `RECURRENCE-ID` anchor. + * + * `instance_original_time` is the provider's own name for it and is set on + * every occurrence of a recurring task, so it is read first. It is absent on + * older provider schemas, where the fallbacks reconstruct the same value: a + * DTSTART-anchored series instantiates each occurrence at its start, and a + * series carrying only DUE anchors on the due date instead. + */ + fun occurrenceAnchor(r: ColumnReader): Instant? = + ( + r.getLong(Instances.INSTANCE_ORIGINAL_TIME) + ?: r.getLong(Instances.INSTANCE_START) + ?: r.getLong(Instances.INSTANCE_DUE) + )?.let { Instant.fromEpochMilliseconds(it) } + + /** + * Maps a row of the **`tasks` table** — a master task, not an occurrence. + * + * Export reads there rather than from `instances` on purpose: in the instances + * view a recurring task appears once per occurrence with its times already + * resolved and no rule attached, so exporting from it would write the same + * task many times over and drop the RRULE that produced them. Here each task + * appears exactly once, carrying the rule itself. + */ + fun exportTask(r: ColumnReader): ExportTask { + fun instant(name: String): Instant? = + r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) } + + return ExportTask( + taskId = r.getLong(Tasks.ID) ?: 0L, + uid = r.getString(Tasks.UID), + title = r.getString(Tasks.TITLE).orEmpty(), + description = r.getString(Tasks.DESCRIPTION), + location = r.getString(Tasks.LOCATION), + url = r.getString(Tasks.URL), + priority = priorityFromICal(r.getInt(Tasks.PRIORITY)), + status = statusFromInt(r.getInt(Tasks.STATUS)), + percentComplete = r.getInt(Tasks.PERCENT_COMPLETE), + // The task's own columns, not the instance view's resolved ones. + start = instant(Tasks.DTSTART), + due = instant(Tasks.DUE), + isAllDay = r.getBoolean(Tasks.IS_ALLDAY), + completedAt = instant(Tasks.COMPLETED), + created = instant(Tasks.CREATED), + lastModified = instant(Tasks.LAST_MODIFIED), + rrule = r.getString(Tasks.RRULE), + rdate = r.getString(Tasks.RDATE), + parentId = r.getLong(Tasks.PARENT_ID)?.takeIf { it > 0 }, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt index 6b6fa45..b7464bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskProjections.kt @@ -1,8 +1,6 @@ package de.jeanlucmakiola.agendula.data.tasks -import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists -import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks /** Column lists requested from the provider. Order is irrelevant; we read by name. */ object TaskProjections { @@ -18,31 +16,9 @@ object TaskProjections { Lists.ACCOUNT_TYPE, ) - /** Read from the `instances` view (inherits all task columns). */ - val INSTANCES: Array = arrayOf( - Tasks.ID, - Instances.TASK_ID, - Tasks.LIST_ID, - Tasks.TITLE, - Tasks.DESCRIPTION, - Tasks.LOCATION, - Tasks.URL, - Tasks.PRIORITY, - Tasks.STATUS, - Tasks.PERCENT_COMPLETE, - Tasks.COMPLETED, - Tasks.IS_ALLDAY, - Tasks.TZ, - Instances.INSTANCE_START, - Instances.INSTANCE_DUE, - Tasks.TASK_COLOR, - Tasks.LIST_COLOR, - Tasks.LIST_NAME, - Tasks.ACCOUNT_NAME, - Tasks.PARENT_ID, - Instances.IS_RECURRING, - Instances.DISTANCE_FROM_CURRENT, - Tasks.CREATED, - Tasks.LAST_MODIFIED, - ) + // No `instances` projection on purpose: that read passes `projection = null` + // (all columns), because the view's shape differs across provider versions — + // tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list + // here would drift out of sync with the by-name mapper and quietly drop + // columns it depends on. See AndroidTasksDataSource.queryInstances. } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt index 601ac24..e608c59 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapper.kt @@ -1,9 +1,21 @@ package de.jeanlucmakiola.agendula.data.tasks +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists +import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.toICal +import kotlin.time.Instant + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** Floor to UTC midnight when [allDay], else pass through unchanged. */ +private fun Instant.forAllDay(allDay: Boolean): Instant = + if (!allDay) this + else Instant.fromEpochMilliseconds( + Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY, + ) /** * Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues), @@ -16,6 +28,8 @@ object TaskWriteMapper { put(Tasks.TITLE, form.title.trim()) put(Tasks.LIST_ID, form.listId) put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null }) + put(Tasks.LOCATION, form.location?.trim()?.ifBlank { null }) + put(Tasks.URL, form.url?.trim()?.ifBlank { null }) put(Tasks.PRIORITY, form.priority.toICal()) // Keep completion in sync with progress whenever the form carries a // percent (the Progress field was used). The provider already auto- @@ -39,8 +53,17 @@ object TaskWriteMapper { } } put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) - put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) - put(Tasks.DUE, form.due?.toEpochMilliseconds()) + // All-day tasks are date-only in iCalendar. The provider reads them back + // through DateTime.toAllDay(), which drops the time-of-day and resolves the + // remaining date against UTC — so a local-midnight instant lands on the + // previous day for anyone west of UTC. Pin all-day values to UTC midnight. + put(Tasks.DTSTART, form.start?.forAllDay(form.isAllDay)?.toEpochMilliseconds()) + put(Tasks.DUE, form.due?.forAllDay(form.isAllDay)?.toEpochMilliseconds()) + // DUE and DURATION are mutually exclusive. The provider's Validating + // processor evaluates the *merged* row (supplied values over the stored + // ones), so writing DUE onto a task that already carries a DURATION throws + // "Only one of DUE or DURATION must be supplied." Clear it alongside. + put(Tasks.DURATION, null) put(Tasks.PARENT_ID, form.parentId) // The provider treats a null tz as local time; set it explicitly for // timed tasks so the stored instant is unambiguous across zones. @@ -48,6 +71,20 @@ object TaskWriteMapper { put(Tasks.TZ, if (timed) tzId else null) } + /** [taskValues] plus the rule — for a plain task or a series master, never an occurrence. */ + fun seriesValues(form: TaskForm, tzId: String): Map = + taskValues(form, tzId) + (Tasks.RRULE to form.rrule?.removePrefix("RRULE:")?.ifBlank { null }) + + /** + * Values for an update through the *instances* URI (a recurring occurrence). + * The provider clones the row into an override and strips list/recurrence + * fields as it goes, so LIST_ID and PARENT_ID are dropped here rather than + * written and silently ignored — moving one occurrence between lists or + * parents isn't a thing the override model expresses. + */ + fun instanceValues(form: TaskForm, tzId: String): Map = + taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID + fun completionValues(completed: Boolean, nowMillis: Long): Map = if (completed) { mapOf( @@ -63,9 +100,26 @@ object TaskWriteMapper { ) } - fun localListValues(name: String, color: Int): Map = mapOf( + /** + * A reminder for [taskId], as an Alarm property row. The provider's validator + * requires MINUTES_BEFORE, REFERENCE (non-negative) and ALARM_TYPE on every + * write, so all three are always present. + */ + fun alarmValues(taskId: Long, minutesBeforeDue: Int, fromStart: Boolean = false): Map = mapOf( + Properties.TASK_ID to taskId, + Properties.MIMETYPE to Alarm.MIMETYPE, + Alarm.MINUTES_BEFORE to minutesBeforeDue, + Alarm.REFERENCE to if (fromStart) Alarm.REFERENCE_START else Alarm.REFERENCE_DUE, + Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE, + ) + + /** The user-owned columns of a list — what an edit is allowed to change. */ + fun listValues(name: String, color: Int): Map = mapOf( Lists.NAME to name.trim(), Lists.COLOR to color, + ) + + fun localListValues(name: String, color: Int): Map = listValues(name, color) + mapOf( Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME, Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE, Lists.VISIBLE to 1, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt index a400d59..d3712b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksContract.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.agendula.data.tasks import android.net.Uri +import androidx.core.net.toUri /** * The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as @@ -66,6 +67,12 @@ object TasksContract { const val IS_ALLDAY = "is_allday" const val TZ = "tz" const val RRULE = "rrule" + const val RDATE = "rdate" + const val EXDATE = "exdate" + /** Set on an override row — the master occurrence this one replaces. */ + const val ORIGINAL_INSTANCE_ID = "original_instance_id" + /** On an override row: the start of the occurrence it replaces. */ + const val ORIGINAL_INSTANCE_TIME = "original_instance_time" const val PARENT_ID = "parent_id" const val SORTING = "sorting" const val CREATED = "created" @@ -96,8 +103,59 @@ object TasksContract { const val INSTANCE_DUE_SORTING = "instance_due_sorting" const val DISTANCE_FROM_CURRENT = "distance_from_current" const val IS_RECURRING = "is_recurring" + + /** + * The occurrence's `RECURRENCE-ID` — the time this occurrence was + * instantiated at, before any override moved it. Set on every occurrence + * of a recurring task, which is what makes it the occurrence's identity. + */ + const val INSTANCE_ORIGINAL_TIME = "instance_original_time" } + /** The `properties` table — per-task side rows, discriminated by [Properties.MIMETYPE]. */ + object Properties { + const val PATH = "properties" + const val PROPERTY_ID = "property_id" + const val TASK_ID = "task_id" + const val MIMETYPE = "mimetype" + } + + /** + * An alarm property row — a per-task reminder lead. + * + * Storage and sync format *only*: the provider fires nothing (its alarm + * scheduling is commented out and the internal `alarms` table is never + * populated), so [de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler] + * still arms the real AlarmManager alarm. Writing it here is what makes the + * lead survive a sync and show up in other OpenTasks clients. + * + * The columns are the generic `dataN` slots; the meanings below are the + * Alarm property's contract for them. + */ + object Alarm { + const val MIMETYPE = "vnd.android.cursor.item/alarm" + + /** `data0` — minutes from the reference date; positive means *before* it. */ + const val MINUTES_BEFORE = "data0" + + /** `data1` — which date to count from. */ + const val REFERENCE = "data1" + + /** `data2` — optional message shown with the alarm. */ + const val MESSAGE = "data2" + + /** `data3` — alarm kind. Must be present, and non-zero to count as an alarm. */ + const val ALARM_TYPE = "data3" + + const val REFERENCE_DUE = 1 + const val REFERENCE_START = 2 + + /** 0 (NOTHING) is excluded from the provider's `has_alarms` count — use MESSAGE. */ + const val TYPE_MESSAGE = 1 + } + + fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri() + // --- status values (TaskColumns.STATUS_*) -------------------------------- const val STATUS_NEEDS_ACTION = 0 const val STATUS_IN_PROCESS = 1 @@ -107,10 +165,21 @@ object TasksContract { /** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */ const val PRIORITY_NONE = 0 - fun authorityUri(authority: String): Uri = Uri.parse("content://$authority") - fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}") - fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}") - fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}") + fun authorityUri(authority: String): Uri = "content://$authority".toUri() + fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri() + fun listUri(authority: String, listId: Long): Uri = + "content://$authority/${Lists.PATH}/$listId".toUri() + fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri() + fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri() + + /** + * A single occurrence. Updating through this URI is how a *recurring* task is + * edited: the provider clones the row into an override task + * (`original_instance_id` set, recurrence fields stripped) instead of moving + * the series anchor, which is what writing to `tasks/` would do. + */ + fun instanceUri(authority: String, instanceId: Long): Uri = + "content://$authority/${Instances.PATH}/$instanceId".toUri() /** Append the sync-adapter params required to write local-account rows. */ fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri = diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt index e73d7ee..92f5eef 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksDataSource.kt @@ -3,6 +3,25 @@ package de.jeanlucmakiola.agendula.data.tasks import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import kotlin.time.Instant + +/** + * A stored reminder: how long before, and what it counts back from. + * + * [fromStart] matters because both stores can hold a `START`-referenced alarm — + * the dmfs import preserves one, and an external provider's other clients write + * them — while Agendula's own UI only ever sets a before-due lead. Collapsing it + * to a number here is what silently fired those reminders off the wrong anchor. + */ +data class TaskReminder(val minutesBefore: Int, val fromStart: Boolean = false) + +/** + * The one reminder Agendula's single-reminder editor shows and writes: the last + * before-due one, which is where a write lands, so it stays the same one across + * saves. Anything else a task carries — a start-relative alarm, or the extra ones + * another client or the dmfs import brought — is left to ride along. + */ +fun List.editable(): TaskReminder? = lastOrNull { !it.fromStart } /** What to fetch from the provider. Smart-list date logic is applied above this. */ data class TaskQuery( @@ -19,14 +38,112 @@ interface TasksDataSource { fun taskLists(): List fun tasks(query: TaskQuery): List fun task(taskId: Long): Task? + + /** + * The occurrence of [taskId] that starts at [occurrenceStart], or [task] when + * that is null or names no occurrence — so a stale anchor still lands on the task. + */ + fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId) fun subtasks(parentTaskId: Long): List fun insertTask(form: TaskForm): Long fun updateTask(taskId: Long, form: TaskForm) + + /** + * Update a single occurrence of a recurring task, addressed by the task row and + * the occurrence's `RECURRENCE-ID` anchor ([Task.occurrenceStart]). The store + * forks an override rather than moving the series anchor — which is what + * [updateTask] would do, since a recurring task's start/due are the + * occurrence's resolved times. + * + * Addressing by `(taskId, occurrenceStart)` rather than by a materialised + * instance row id keeps this seam independent of any one store's row + * numbering; External mode maps it back to an instance row itself. + */ + fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) + /** + * Set (or clear, with `null`) the task's [editable] reminder lead, stored as an + * Alarm property row. Every other reminder on the task is left untouched. The + * provider never fires it — [de.jeanlucmakiola.agendula.data.reminders + * .ReminderScheduler] does — but persisting it here is what syncs the lead and + * shares it with other OpenTasks clients. + */ + fun setAlarm(taskId: Long, minutesBeforeDue: Int?) + + /** + * Edit the whole series [seriesId] through its occurrence at [occurrenceStart]: + * content from [form], and the series moved by however far that occurrence moved. + */ + fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) + + /** + * "This and following": end [seriesId] before [occurrenceStart] and continue + * from there as a new series built from [form]. Returns the new series' id. + */ + fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long + + /** Delete one generated occurrence of [seriesId] (an `EXDATE`). */ + fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) + + /** Delete [occurrenceStart] and every later occurrence of [seriesId]. */ + fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) + + + /** Replace every reminder on [taskId] with [reminders], in order. */ + fun setReminders(taskId: Long, reminders: List) + + /** Every task's reminders, by task id, in stored order. One query, for the scheduler. */ + fun reminders(): Map> = alarms().mapValues { listOf(it.value) } + + /** Every task's [editable] reminder, by task id. */ + fun alarms(): Map + + /** + * Every task in [listId] read from the **`tasks` table**, for export. Masters, + * not occurrences — see [TaskMapper.exportTask] for why that distinction + * matters. Excludes rows the provider has flagged deleted-but-unsynced. + */ + fun exportTasks(listId: Long): List + fun setCompleted(taskId: Long, completed: Boolean) + + /** Cancel [taskId] (`STATUS:CANCELLED`) or put it back to needs-action. */ + fun setCancelled(taskId: Long, cancelled: Boolean) + + /** + * Complete (or reopen) **one occurrence** of a recurring task, addressed the + * same way [updateInstance] is. Ticking a series through [setCompleted] would + * close the master row, which takes every past and future occurrence out of + * every list at once. + * + * Implementations fall back to [setCompleted] when the row turns out not to + * be a series master — an override, or a plain task the caller happened to + * hand an anchor for — so the routing above cannot get this wrong. + */ + fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) + + /** Cancel (or restore) one occurrence of a series; the counterpart of [setCompletedInstance]. */ + fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) fun deleteTask(taskId: Long) fun createLocalList(name: String, color: Int): Long + /** Rename and recolour [listId]. */ + fun updateList(listId: Long, name: String, color: Int) + + /** + * Delete [listId] **and the tasks in it** — `tasks.list_id` cascades on the + * Room path, and the provider does the same on the External one. + * + * Only ever called for a local, device-only list: a collection that belongs + * to an account is the server's to remove, and neither store expresses a + * collection tombstone yet. The UI gates on [TaskList.isLocal]; this seam + * does not re-check it. + */ + fun deleteList(listId: Long) + + /** Store [listIds] as the lists' display order. A store without one ignores it. */ + fun reorderLists(listIds: List) = Unit + /** Observe any change to tasks/lists; [onChange] fires on a background thread. */ fun registerObserver(onChange: () -> Unit): AutoCloseable } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt index 10efd6f..5519e76 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepository.kt @@ -5,7 +5,9 @@ import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlin.time.Instant /** Whether Agendula can use the tasks provider right now. Drives onboarding. */ @@ -18,6 +20,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER } */ interface TasksRepository { fun taskLists(): Flow> + + /** + * The lists a task may be saved into — [taskLists] without read-only shares. + * What a list picker for creating or moving a task should offer. + */ + fun writableTaskLists(): Flow> = + taskLists().map { lists -> lists.filter { it.acceptsWrites } } fun tasks(filter: TaskFilter): Flow> /** @@ -26,20 +35,66 @@ interface TasksRepository { * children are usually filtered out of the list's own (date-based) query. */ fun subtasks(parentId: Long): Flow> - fun taskDetail(taskId: Long): Flow + /** [occurrenceStart] picks one occurrence of a series; `null` means the current one. */ + fun taskDetail(taskId: Long, occurrenceStart: Instant? = null): Flow suspend fun createTask(form: TaskForm): Long + /** + * Copy a task into its own list as a fresh, open, non-repeating task — this + * occurrence's dates, its reminders and its open subtasks. Returns the copy's id. + */ + suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant? = null): Long + /** * Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the * task's current `last_modified` is re-checked first and a * [TaskConflictException] is thrown if it differs — i.e. something changed it * since the form loaded. Pass `null` to force the write (overwrite-anyway). */ - suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null) - suspend fun setCompleted(taskId: Long, completed: Boolean) - suspend fun deleteTask(taskId: Long) + suspend fun updateTask( + taskId: Long, + form: TaskForm, + expectedLastModified: Instant? = null, + occurrenceStart: Instant? = null, + scope: RecurringScope = RecurringScope.ThisOccurrence, + ) + /** + * Complete or reopen a task. Pass the occurrence's [Task.occurrenceStart] so a + * recurring series forks a `RECURRENCE-ID` override for that one occurrence + * instead of closing the whole series; `null` completes the row itself. + */ + suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) + + /** Cancel or restore a task; with [occurrenceStart], only that occurrence of a series. */ + suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) + /** Delete a task, or — for an occurrence of a series — as much of the series as [scope] says. */ + suspend fun deleteTask( + taskId: Long, + occurrenceStart: Instant? = null, + scope: RecurringScope = RecurringScope.AllOccurrences, + ) + + /** + * The per-task reminder lead in minutes before due, or `null` if the task has + * none (in which case the list's / global setting applies). Read when the edit + * form loads so saving can't silently drop it. + */ + suspend fun reminderFor(taskId: Long): Int? + + /** Every reminder stored on the task, in order — including start-relative ones. */ + suspend fun remindersFor(taskId: Long): List + + /** Replace every reminder on the task; an empty list leaves the list's / global setting in charge. */ + suspend fun setReminders(taskId: Long, reminders: List) + suspend fun createLocalList(name: String, color: Int): Long + suspend fun updateList(listId: Long, name: String, color: Int) + + /** Deletes the list **and its tasks**. Local lists only — see [TasksDataSource.deleteList]. */ + suspend fun deleteList(listId: Long) + + suspend fun reorderLists(listIds: List) /** Synchronous snapshot for the permission/onboarding gate. */ fun providerStatus(): ProviderStatus diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt index d137d1e..584a112 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/TasksRepositoryImpl.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.floret.time.DayWindow +import de.jeanlucmakiola.agendula.domain.SeriesCollapse import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskFilter @@ -9,6 +10,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskSorting +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.awaitClose @@ -28,6 +31,7 @@ import kotlin.time.Instant class TasksRepositoryImpl @Inject constructor( private val dataSource: TasksDataSource, private val providerResolver: ProviderResolver, + private val startupGate: StartupGate, @IoDispatcher private val io: CoroutineDispatcher, ) : TasksRepository { @@ -36,16 +40,16 @@ class TasksRepositoryImpl @Inject constructor( override fun tasks(filter: TaskFilter): Flow> = observing { loadTasks(filter) } override fun subtasks(parentId: Long): Flow> = observing { - dataSource.subtasks(parentId) + SeriesCollapse.visible(dataSource.subtasks(parentId)) .filter { it.taskId != parentId } .sortedWith(TaskSorting.DEFAULT) } - override fun taskDetail(taskId: Long): Flow = observing { - dataSource.task(taskId)?.let { task -> + override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow = observing { + dataSource.task(taskId, occurrenceStart)?.let { task -> TaskDetail( task = task, - subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId }, + subtasks = SeriesCollapse.visible(dataSource.subtasks(taskId)).filter { it.taskId != taskId }, parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) }, ) } @@ -61,7 +65,7 @@ class TasksRepositoryImpl @Inject constructor( is TaskFilter.Smart -> TaskQuery(includeCompleted = true) } val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) - val all = dataSource.tasks(query) + val all = SeriesCollapse.visible(dataSource.tasks(query)) // (done, total) of direct children per parent, over the unfiltered set. val progress: Map> = all .filter { (it.parentId ?: 0L) > 0L } @@ -80,41 +84,197 @@ class TasksRepositoryImpl @Inject constructor( } override suspend fun createTask(form: TaskForm): Long = - withContext(io) { dataSource.insertTask(form) } - - override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) = withContext(io) { - // Conflict-safe overwrite: re-read just before writing and bail if the - // provider's last_modified moved since the form captured it (external - // sync / another app). A null baseline means "force / overwrite anyway". - if (expectedLastModified != null) { - val current = dataSource.task(taskId)?.lastModified - if (current != null && current != expectedLastModified) throw TaskConflictException(taskId) - } - dataSource.updateTask(taskId, form) + val id = dataSource.insertTask(form) + writeReminders(id, form) + id } - override suspend fun setCompleted(taskId: Long, completed: Boolean) = - withContext(io) { dataSource.setCompleted(taskId, completed) } + override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long = + withContext(io) { + val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" } + copyOf(task, task.parentId, depth = 0) + } - override suspend fun deleteTask(taskId: Long) = - withContext(io) { dataSource.deleteTask(taskId) } + private fun copyOf(task: Task, parentId: Long?, depth: Int): Long { + val id = dataSource.insertTask( + TaskForm( + title = task.title, + listId = task.listId, + description = task.description, + start = task.start, + due = task.due, + isAllDay = task.isAllDay, + priority = task.priority, + parentId = parentId, + location = task.location, + url = task.url, + ), + ) + val reminders = dataSource.reminders()[task.taskId].orEmpty() + if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders) + if (depth < MAX_COPY_DEPTH) { + SeriesCollapse.visible(dataSource.subtasks(task.taskId)) + .filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED } + .forEach { copyOf(it, id, depth + 1) } + } + return id + } + + override suspend fun reminderFor(taskId: Long): Int? = + withContext(io) { runCatching { dataSource.alarms()[taskId]?.minutesBefore }.getOrNull() } + + override suspend fun remindersFor(taskId: Long): List = + withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) } + + override suspend fun setReminders(taskId: Long, reminders: List) = + withContext(io) { dataSource.setReminders(taskId, reminders) } + + override suspend fun updateTask( + taskId: Long, + form: TaskForm, + expectedLastModified: Instant?, + occurrenceStart: Instant?, + scope: RecurringScope, + ) = withContext(io) { + // Re-read just before writing: it settles the conflict check *and* tells + // us which URI to write through. + val current = dataSource.task(taskId, occurrenceStart) + // Conflict-safe overwrite: bail if the provider's last_modified moved + // since the form captured it (external sync / another app). A null + // baseline means "force / overwrite anyway". + if (expectedLastModified != null) { + val seen = current?.lastModified + if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId) + } + val seriesId = current?.seriesId + val occurrence = current?.occurrenceStart + if (seriesId != null && occurrence != null) { + when (scope) { + RecurringScope.AllOccurrences -> { + writeReminders(seriesId, form) + dataSource.updateSeries(seriesId, occurrence, form) + return@withContext + } + RecurringScope.ThisAndFollowing -> { + val created = dataSource.splitSeries(seriesId, occurrence, form) + writeReminders(created, form) + return@withContext + } + RecurringScope.ThisOccurrence -> Unit + } + } + // A recurring task's start/due are one occurrence's resolved times, so + // writing them back to the task row would re-anchor the whole series. + // Going through the occurrence forks an override instead. + val instance = current?.takeIf { it.isRecurring }?.occurrenceStart + if (instance == null) { + writeReminders(taskId, form) + dataSource.updateTask(taskId, form) + return@withContext + } + // ⚠️ Written to the series, and then put back. Forking an occurrence + // copies the task's *current* properties onto the new override row, + // so setting the alarm beforehand is the only way to carry it + // across — and leaving it there would silently give every other + // occurrence in the series the reminder the user set on one of them. + val seriesReminders = dataSource.reminders()[taskId].orEmpty() + writeReminders(taskId, form) + try { + dataSource.updateInstance(taskId, instance, form) + } finally { + dataSource.setReminders(taskId, seriesReminders) + } + } + + /** The form's before-due reminders; a start-relative one from elsewhere is kept as it was. */ + private fun writeReminders(taskId: Long, form: TaskForm) { + val kept = dataSource.reminders()[taskId].orEmpty().filter { it.fromStart } + dataSource.setReminders(taskId, kept + form.reminders.distinct().sorted().map { TaskReminder(it) }) + } + + override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) = + withContext(io) { + if (occurrenceStart != null) dataSource.setCancelledInstance(taskId, occurrenceStart, cancelled) + else dataSource.setCancelled(taskId, cancelled) + } + + override suspend fun setCompleted(taskId: Long, occurrenceStart: Instant?, completed: Boolean) = + withContext(io) { + if (occurrenceStart != null) { + dataSource.setCompletedInstance(taskId, occurrenceStart, completed) + } else { + dataSource.setCompleted(taskId, completed) + // Done means done with the parts too; reopening leaves them as they are. + if (completed) { + SeriesCollapse.visible(dataSource.subtasks(taskId)) + .filter { it.taskId != taskId && !it.isClosed } + .forEach { sub -> + val occurrence = sub.occurrenceStart + if (occurrence != null) dataSource.setCompletedInstance(sub.taskId, occurrence, true) + else dataSource.setCompleted(sub.taskId, true) + } + } + } + } + + override suspend fun deleteTask(taskId: Long, occurrenceStart: Instant?, scope: RecurringScope) = + withContext(io) { + val current = dataSource.task(taskId, occurrenceStart) + val seriesId = current?.seriesId + val occurrence = current?.occurrenceStart + if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId) + when (scope) { + RecurringScope.ThisOccurrence -> + // An override row deletes itself into an EXDATE; a generated + // occurrence has no row to delete. + if (current.taskId != seriesId) dataSource.deleteTask(current.taskId) + else dataSource.deleteOccurrence(seriesId, occurrence) + RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence) + RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId) + } + } + + /** A parent goes with its subtasks; left behind they would hang off a tombstone. */ + private fun deleteWithSubtasks(taskId: Long) { + dataSource.subtasks(taskId).map { it.taskId }.distinct() + .filter { it != taskId } + .forEach { dataSource.deleteTask(it) } + dataSource.deleteTask(taskId) + } override suspend fun createLocalList(name: String, color: Int): Long = withContext(io) { dataSource.createLocalList(name, color) } + override suspend fun updateList(listId: Long, name: String, color: Int) = + withContext(io) { dataSource.updateList(listId, name, color) } + + override suspend fun deleteList(listId: Long) = + withContext(io) { dataSource.deleteList(listId) } + + override suspend fun reorderLists(listIds: List) = + withContext(io) { dataSource.reorderLists(listIds) } + override fun providerStatus(): ProviderStatus { + // Our own store is always ready: it ships with the app, needs no provider + // and no grant. The permission gate only ever applied to External mode — + // now that is visibly true rather than a special case inside it. + if (providerResolver.mode() == StorageMode.OWN) return ProviderStatus.READY val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER return if (providerResolver.hasPermission(provider)) ProviderStatus.READY else ProviderStatus.NEEDS_PERMISSION } /** - * Emits an initial load, then re-loads on every provider change. The observer + * Emits an initial load, then re-loads on every store change. The observer * callback (main thread) only pokes a conflated channel; the actual blocking * query runs on [io]. */ private fun observing(load: () -> T): Flow = callbackFlow { + // Nothing reads a store before the stored mode has landed and a v0.3.x + // install has been imported — otherwise the first emission comes from the + // wrong store, or from an empty one. + startupGate.awaitReady() val ticks = Channel(Channel.CONFLATED) val handle = dataSource.registerObserver { ticks.trySend(Unit) } ticks.trySend(Unit) // prime the initial emission @@ -128,3 +288,6 @@ class TasksRepositoryImpl @Inject constructor( } }.flowOn(io) } + +/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */ +private const val MAX_COPY_DEPTH = 8 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/CalendarResource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/CalendarResource.kt new file mode 100644 index 0000000..380ddcc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/CalendarResource.kt @@ -0,0 +1,56 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.VTimeZones + +/** + * One CalDAV resource: the `VCALENDAR` wrapper around the `VTODO`s that share a + * `UID`. + * + * ⚠️ A resource is **not** a task. RFC 4791 §4.1 requires every component in one + * resource to share a UID, which makes a recurring task and all of its + * `RECURRENCE-ID` overrides exactly one resource — and makes two unrelated tasks + * in one resource unuploadable. The engine works in resources; the mapper works + * in components. + */ +object CalendarResource { + + const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN" + const val VERSION = "2.0" + + /** Wraps [vtodos] with the `VTIMEZONE`s their `TZID` parameters reference. */ + fun build(vtodos: List): ICalComponent { + val zones = vtodos + .flatMap { VTimeZones.forComponent(it) } + .distinctBy { it.property("TZID")?.value } + return ICalComponent( + name = "VCALENDAR", + properties = listOf( + ICalProperty("VERSION", emptyList(), VERSION), + ICalProperty("PRODID", emptyList(), PRODUCT_ID), + ), + // Zones first: a server that streams the object as it parses has the + // definition before the reference. + components = zones + vtodos, + ) + } + + fun serialize(vtodos: List): String = + ICalSerializer.serialize(build(vtodos)) + + /** + * The `VCALENDAR`s in a downloaded body. + * + * More than one is malformed but does happen; the caller decides what to do + * about it rather than having the decision made here by a parser. + */ + fun parse(text: String): List = + ICalParser.parseAll(text).filter { it.name.equals("VCALENDAR", ignoreCase = true) } + + /** Every `VTODO` across [calendars], in document order. */ + fun todosIn(calendars: List): List = + calendars.flatMap { it.components("VTODO") } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/ResourceValidator.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/ResourceValidator.kt new file mode 100644 index 0000000..7164711 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/ResourceValidator.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent + +/** + * Refuses a resource before it is uploaded. + * + * ⚠️ This exists because **sabre answers 415 for things ordinary UI actions + * produce**, and a 415 is not recoverable by retrying: the row stays dirty, the + * next sync sends the same bytes, and the user sees a task that never leaves the + * device with no explanation. Catching it here turns a permanent silent failure + * into one message naming the field. + * + * Every rule below is a real sabre rejection, not defensive tidiness. + */ +object ResourceValidator { + + /** Why a resource cannot be uploaded, in words that name the offending field. */ + @JvmInline + value class Rejection(val reason: String) + + /** Null when [calendar] may be uploaded. */ + fun validate(calendar: ICalComponent): Rejection? { + // An object carrying METHOD is an iTIP *message*, not calendar data. + // RFC 4791 §4.1 forbids it outright in a calendar object resource. + calendar.property("METHOD")?.let { + return Rejection("carries METHOD:${it.value}, which makes it a scheduling message") + } + + val todos = calendar.components("VTODO") + if (todos.isEmpty()) return Rejection("contains no VTODO") + + // ⚠️ Mixed component types in one resource. A VEVENT that arrived in the + // same body and was never modelled must not be re-emitted next to a + // VTODO — RFC 4791 §4.1 allows only one component type per resource. + val foreign = calendar.components + .map { it.name.uppercase() } + .filterNot { it == "VTODO" || it == "VTIMEZONE" } + .distinct() + if (foreign.isNotEmpty()) { + return Rejection("mixes VTODO with ${foreign.joinToString(", ")}") + } + + // ⚠️ Overrides with no master. A `RECURRENCE-ID` names an instance *of* + // a series, so a body holding only overrides describes instances of + // something that is not in the resource — RFC 4791 §4.1 asks for the + // recurring component and its overridden instances, not the instances + // alone. This is the shape a partly tombstoned series used to serialise + // to, and no server rejects it in a way that names the cause. + if (todos.all { it.property("RECURRENCE-ID") != null }) { + return Rejection("holds overridden instances but not the task they override") + } + + // One resource, one UID — the constraint that makes "fork the conflicting + // edit into the same resource" impossible, and the one servers enforce. + val uids = todos.map { it.property("UID")?.value.orEmpty() }.distinct() + if (uids.size > 1) return Rejection("holds ${uids.size} different UIDs") + if (uids.singleOrNull().isNullOrBlank()) return Rejection("has no UID") + + // ⚠️ A TZID without a leading solidus must reference a VTIMEZONE in the + // same object (RFC 5545 §3.2.19). We regenerate definitions from + // `java.time`, which cannot resolve a non-IANA id — a Windows zone name + // that arrived from another client and survives in the residue produces a + // reference with nothing behind it. `Prefer: handling=strict` turns that + // from a server-side repair into a rejection, so it is caught by name + // here instead of as an unexplained 415. + val defined = calendar.components("VTIMEZONE") + .mapNotNull { it.property("TZID")?.value } + .toSet() + val undefined = todos.flatMap(::tzidsIn) + // ⚠️ The solidus form is exempt, and the rule above says so: §3.2.19 + // requires a local VTIMEZONE only for a TZID *without* a leading + // solidus, because the prefix marks a globally defined identifier. + // Thunderbird writes `/mozilla.org/20050126_1/Europe/Berlin` on every + // zoned task, `java.time` cannot resolve it, and rejecting it would + // quarantine every Lightning-authored task permanently. + .filterNot { it.startsWith('/') } + .filterNot { it in defined } + .distinct() + if (undefined.isNotEmpty()) { + return Rejection("references the unknown time zone ${undefined.first()}") + } + + return todos.firstNotNullOfOrNull(::validateTodo) + } + + /** + * ⚠️ The per-component rules are [VTodoMapper.validate]'s, not a second copy. + * + * Two value-type tests that disagree is worse than one: `ResourceValidator` + * originally tested only `VALUE=DATE`, while the mapper also treats a bare + * eight-digit value as a DATE, so a residue `DTSTART:20260101` beside an + * authored `DUE;VALUE=DATE:20260102` was rejected as a mismatch and never + * left the device. + */ + private fun validateTodo(todo: ICalComponent): Rejection? { + if (todo.property("DUE") != null && todo.property("DURATION") != null) { + // RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive. + return Rejection("has both DUE and DURATION") + } + return VTodoMapper.validate(todo).firstOrNull()?.let(::Rejection) + } + + private fun tzidsIn(component: ICalComponent): List = + component.properties.mapNotNull { it.param("TZID")?.takeIf(String::isNotBlank) } + + component.components.flatMap(::tzidsIn) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt new file mode 100644 index 0000000..7461de0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapper.kt @@ -0,0 +1,546 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParam +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import java.time.ZoneId +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * VTODO ↔ [TaskEntity]. + * + * ## The residue + * + * Everything the mapper does not claim — unknown properties, unknown parameters, + * `VALARM`s, whole unknown sub-components — is serialised verbatim into + * [TaskEntity.unknownProperties] and re-emitted on write. RFC 5545 §3.1 requires + * it ("Applications MUST preserve the value data for x-name and iana-token + * values that they don't recognize"), and failing it destroys other people's + * data invisibly — invisible in our own UI precisely because we are the client + * that does not understand the property. + * + * ## What "claimed" means, and why it is narrow + * + * The mapper claims a property only when it can **reproduce it exactly** from + * its columns. Everything else stays in the residue and round-trips untouched: + * + * - a value it cannot parse or that is out of range (`PRIORITY:11`, + * `PERCENT-COMPLETE:abc`, `STATUS:X-DEFERRED`, `SEQUENCE:x`) — clamping or + * defaulting these would be a silent rewrite of somebody's data; + * - a time it can read but not reproduce — a floating stamp, or a `TZID` this + * device's tzdb has never heard of. The column still gets a best-effort + * instant so the UI has something to show; + * - a `DTSTART`/`DUE` pair that disagrees on value type or timezone, where + * authoring both from one `is_all_day` flag and one `timezone` column would + * destroy the odd one out. + * + * ## Residue eviction + * + * A suppressed property is only suppressed while it still *agrees* with its + * column. [contradictsResidue] compares the two on write: if the user has since + * edited that field, the stale residue copy is evicted and the column is + * authored. Without this, editing the due date of a task imported with a + * floating `DUE` would silently do nothing on the server. + * + * ## Alarms + * + * `VALARM`s round-trip in the residue and are never authored here. Local + * reminders live in `task_alarms` and are never serialised. The two stores are + * disjoint, so neither can destroy the other. Merging them is a later + * decision, not a silent one. + */ +object VTodoMapper { + + /** + * DAVx5's limit, and ours for the same two reasons: Android's `CursorWindow` + * row cap, and `CALDAV:max-resource-size`, whose violation is a failed PUT. + */ + const val MAX_RESIDUE_BYTES = 25 * 1024 + + /** + * Cardinality-one properties the mapper authors from a column, and therefore + * must not author while the residue still holds the original. + */ + private val SUPPRESSED_BY_RESIDUE = setOf( + "DTSTART", "DUE", "COMPLETED", "RECURRENCE-ID", "CREATED", "LAST-MODIFIED", + "STATUS", "RELATED-TO", + // ⚠️ The scalars belong here too, and their absence was invisible: the + // round-trip corpus filters SEQUENCE out of comparison entirely, so a + // task read from `SEQUENCE:x` was re-emitted carrying *both* `SEQUENCE:0` + // and `SEQUENCE:x` with nothing to catch it. `write` always authors + // SEQUENCE, so the duplicate is unconditional — and under + // `Prefer: handling=strict` sabre will not quietly repair it. + "SEQUENCE", "PRIORITY", "PERCENT-COMPLETE", "CLASS", + // Parameterised copies stay in the residue verbatim; authoring the + // column beside them would emit the same exclusions twice. + "RDATE", "EXDATE", + ) + + /** What a VTODO yields. Row identity ([TaskEntity.id], `listId`) is the caller's. */ + data class Mapped( + val entity: TaskEntity, + /** `RELATED-TO;RELTYPE=PARENT`, for the caller to resolve to a row id. */ + val parentUid: String?, + /** Set when the source carried no `UID` and the caller must mint one. */ + val uidWasMissing: Boolean, + /** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */ + val droppedResidue: Boolean, + ) + + // ---------------------------------------------------------------- read + + fun read(vtodo: ICalComponent, listId: Long = 0L): Mapped { + // Claims are tracked by index, not by value: two identical content lines + // are two pieces of data, and claiming one must not swallow the other on + // the way into the residue. + val claimed = mutableSetOf() + fun claim(property: ICalProperty?) { + property?.let { p -> + vtodo.properties.indexOfFirst { it === p }.takeIf { it >= 0 }?.let(claimed::add) + } + } + + /** Claims [property] only if [value] could be read from it. */ + fun take(property: ICalProperty?, value: T?): T? = + value?.also { claim(property) } + + /** + * Every `RDATE` / `EXDATE` copy's values, merged. + * + * ⚠️ Both are cardinality-**many**, and Apple Calendar writes one line + * per excluded occurrence. Reading only the first left the rest out of + * the column the expander works from, so a deleted occurrence + * reappeared in the list and in its reminders — while round-tripping + * back to the server perfectly out of the residue, which is what made it + * invisible. + * + * ⚠️ Claimed only when no copy carries a parameter. The column is the + * bare value, so claiming a `TZID=`- or `VALUE=DATE`-qualified property + * would drop that qualifier for good: the property never reaches the + * residue, and `write` re-emits it naked. A floating EXDATE stops + * matching the instance it excluded, and a bare eight-digit RDATE is + * read as a malformed DATE-TIME (§3.3.5) — a permanent 415 under the + * `Prefer: handling=strict` this client sends. + */ + fun dateList(vtodo: ICalComponent, name: String, claim: (ICalProperty?) -> Unit): String? { + val copies = vtodo.properties(name) + if (copies.isEmpty()) return null + if (copies.all { it.params.isEmpty() }) copies.forEach(claim) + return copies.joinToString(",") { it.value.trim() }.takeIf { it.isNotEmpty() } + } + + val uidProperty = vtodo.property("UID") + // Claimed even when empty: an empty UID must not reach the residue, or + // write would emit the caller's minted UID alongside the empty one. + val uid = take(uidProperty, uidProperty?.value?.trim()).orEmpty() + + val dtstart = readTime(vtodo, "DTSTART") + val due = readTime(vtodo, "DUE") + + // A task's primary stamp is DUE; DTSTART only decides when there is no + // DUE. Deriving one all-day flag from "either is a DATE" turns a + // date/date-time pair into two DATEs and destroys the time half. + val allDay = if (due.present) due.isDate else dtstart.isDate + val timezone = dtstart.tzid ?: due.tzid + + // Claim a stamp only when the single is_all_day flag and the single + // timezone column can reproduce it. Otherwise it stays in the residue and + // is re-emitted exactly as it arrived. + if (dtstart.reproducible(allDay, timezone)) claim(dtstart.property) + if (due.reproducible(allDay, timezone)) claim(due.property) + + val recurrenceId = readTime(vtodo, "RECURRENCE-ID") + if (recurrenceId.reproducible(allDay, timezone)) claim(recurrenceId.property) + + // These three are UTC-only on the way out, so a zoned or date-valued one + // is not reproducible. + val completed = readTime(vtodo, "COMPLETED") + if (completed.isUtcDateTime) claim(completed.property) + val created = readTime(vtodo, "CREATED") + if (created.isUtcDateTime) claim(created.property) + val lastModified = readTime(vtodo, "LAST-MODIFIED") + if (lastModified.isUtcDateTime) claim(lastModified.property) + + val statusProperty = vtodo.property("STATUS") + val status = take( + statusProperty, + when (statusProperty?.value?.trim()?.uppercase()) { + "NEEDS-ACTION" -> TaskStatus.NEEDS_ACTION + "IN-PROCESS" -> TaskStatus.IN_PROCESS + "COMPLETED" -> TaskStatus.COMPLETED + "CANCELLED" -> TaskStatus.CANCELLED + else -> null + }, + ) ?: TaskStatus.NEEDS_ACTION + + val percentProperty = vtodo.property("PERCENT-COMPLETE") + val percent = take( + percentProperty, + percentProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..100 }, + ) + + // 0 = undefined, 1 = highest, 9 = lowest. Stored raw: bucketing on the way + // in would rewrite a server's PRIORITY:3 as 1 and lose it on write-back. + val priorityProperty = vtodo.property("PRIORITY") + val priority = take( + priorityProperty, + priorityProperty?.value?.trim()?.toIntOrNull()?.takeIf { it in 0..9 }, + ) ?: PRIORITY_NONE + + val classProperty = vtodo.property("CLASS") + val classification = take( + classProperty, + CLASS_NAMES.indexOf(classProperty?.value?.trim()?.uppercase()).takeIf { it >= 0 }, + ) + + val sequenceProperty = vtodo.property("SEQUENCE") + val sequence = take( + sequenceProperty, + sequenceProperty?.value?.trim()?.toIntOrNull()?.takeIf { it >= 0 }, + ) ?: 0 + + // RELTYPE defaults to PARENT (§3.2.15), and §3.8.4.5 reads backwards to + // most implementers: the *referencing* component is the subordinate one, + // so this points at our parent. + // + // Deliberately **not** claimed. TaskEntity holds only a local `parent_id`, + // so a parent that is not in this store — not fetched yet, in another + // collection, deleted locally — would leave nothing to write back and the + // relationship would be destroyed. Keeping it in the residue means the + // link survives even when the parent row does not. + val parentUid = vtodo.properties("RELATED-TO") + .firstOrNull { (it.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) } + ?.value?.trim()?.takeIf { it.isNotEmpty() } + + val entity = TaskEntity( + listId = listId, + uid = uid, + title = take(vtodo.property("SUMMARY"), vtodo.property("SUMMARY")?.text()), + description = take(vtodo.property("DESCRIPTION"), vtodo.property("DESCRIPTION")?.text()), + location = take(vtodo.property("LOCATION"), vtodo.property("LOCATION")?.text()), + // URI, not TEXT — escaping it would corrupt a query string. + url = take(vtodo.property("URL"), vtodo.property("URL")?.value?.trim()), + status = status, + percentComplete = percent, + completedAt = completed.instant, + priority = priority, + classification = classification, + dtstart = dtstart.instant, + due = due.instant, + duration = take(vtodo.property("DURATION"), vtodo.property("DURATION")?.value?.trim()), + isAllDay = allDay, + timezone = timezone, + rrule = take(vtodo.property("RRULE"), vtodo.property("RRULE")?.value?.trim()), + rdate = dateList(vtodo, "RDATE", ::claim), + exdate = dateList(vtodo, "EXDATE", ::claim), + recurrenceId = recurrenceId.instant, + createdAt = created.instant, + lastModified = lastModified.instant, + // The Organizer's revision counter (§3.8.7.4) — preserved verbatim, + // never ours to bump. + sequence = sequence, + ) + + // DTSTAMP is regenerated on every serialisation and carries no state, so + // it is dropped rather than stored. LAST-MODIFIED, which does carry + // state, is a column above — conflating the two makes every sync look + // like an edit. + val residueProperties = vtodo.properties + .filterIndexed { index, _ -> index !in claimed } + .filterNot { it.name.equals("DTSTAMP", ignoreCase = true) } + + val residueText = ICalSerializer.serializeProperties(residueProperties) + + ICalSerializer.serializeAll(vtodo.components) + val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES + + return Mapped( + entity = entity.copy( + unknownProperties = residueText.takeIf { it.isNotEmpty() && !tooLarge }, + ), + parentUid = parentUid, + uidWasMissing = uid.isEmpty(), + droppedResidue = tooLarge, + ) + } + + // --------------------------------------------------------------- write + + /** + * Serialises [entity] back to a VTODO. + * + * [parentUid] is the parent's `UID`, which the entity holds only as a row id. + * [now] is the `DTSTAMP`. + */ + fun write( + entity: TaskEntity, + parentUid: String? = null, + now: Instant = Clock.System.now(), + ): ICalComponent { + val residue = parseResidue(entity.unknownProperties) + val keptResidue = + residue.properties.filterNot { contradictsResidue(it, residue, entity, parentUid) } + val suppressed = keptResidue + .map { it.name.uppercase() } + .filterTo(mutableSetOf()) { it in SUPPRESSED_BY_RESIDUE } + + val properties = mutableListOf() + fun add(name: String, value: String?, vararg params: ICalParam) { + if (value == null || name.uppercase() in suppressed) return + properties += ICalProperty(name, params.toList(), value) + } + fun addTime(name: String, instant: Instant?) { + if (instant == null || name in suppressed) return + properties += timeProperty(name, instant, entity) + } + + add("UID", entity.uid) + add("DTSTAMP", ICalValues.formatDateTime(now, null)) + add("SEQUENCE", entity.sequence.toString()) + add("SUMMARY", entity.title?.let(ICalValues::escapeText)) + add("DESCRIPTION", entity.description?.let(ICalValues::escapeText)) + add("LOCATION", entity.location?.let(ICalValues::escapeText)) + add("URL", entity.url) + + add("STATUS", entity.status.toICalName()) + add("PERCENT-COMPLETE", entity.percentComplete?.toString()) + // §3.8.2.1: COMPLETED MUST be UTC — no TZID, no floating, no DATE. + add("COMPLETED", entity.completedAt?.let { ICalValues.formatDateTime(it, null) }) + add("PRIORITY", entity.priority.takeIf { it != PRIORITY_NONE }?.toString()) + add("CLASS", entity.classification?.let { CLASS_NAMES.getOrNull(it) }) + + addTime("DTSTART", entity.dtstart) + addTime("DUE", entity.due) + add("DURATION", entity.duration) + + add("RRULE", entity.rrule) + // ⚠️ Authored only when the residue's copies contradicted the column — + // i.e. we changed the exclusions ourselves. The parameters come from the + // copy the residue is giving up, so an authored value keeps the TZID the + // list was written in; with no copy to take them from, a date-shaped + // value still needs its VALUE=DATE, since §3.3.5's default is DATE-TIME + // and a bare eight-digit value is malformed. + dateList("RDATE", entity.rdate, residue, suppressed, properties) + dateList("EXDATE", entity.exdate, residue, suppressed, properties) + addTime("RECURRENCE-ID", entity.recurrenceId) + + add("CREATED", entity.createdAt?.let { ICalValues.formatDateTime(it, null) }) + add("LAST-MODIFIED", entity.lastModified?.let { ICalValues.formatDateTime(it, null) }) + + if (parentUid != null && "RELATED-TO" !in suppressed) { + properties += ICalProperty("RELATED-TO", listOf(ICalParam("RELTYPE", "PARENT")), parentUid) + } + + return ICalComponent( + name = "VTODO", + properties = properties + keptResidue, + components = residue.components, + ) + } + + private fun dateList( + name: String, + value: String?, + residue: ICalComponent, + suppressed: Set, + into: MutableList, + ) { + if (value == null || name in suppressed) return + val inherited = residue.properties(name).firstOrNull { it.params.isNotEmpty() }?.params + into += ICalProperty(name, paramsFor(value, inherited.orEmpty()), value) + } + + /** + * The parameters an authored list may carry, given what is in it. + * + * ⚠️ Checked against the value rather than copied across. §3.3.5 forbids + * `TZID` on a `…Z` value and sabre answers 415 for the pair, and a + * date-shaped value needs `VALUE=DATE` or it is read as a malformed + * DATE-TIME. A `TZID` inherited from a residue copy therefore only survives + * where the value is still a local wall time. + * + * What this cannot repair is an `EXDATE` whose zone differs from `DTSTART`'s: + * one `timezone` column describes the task, so a value appended here is + * written in *that* zone. Legal, and vanishingly rare — every client that + * writes both writes them alike. + */ + private fun paramsFor(value: String, inherited: List): List { + val values = value.split(',').map(String::trim).filter(String::isNotEmpty) + if (values.isEmpty()) return emptyList() + if (values.none { it.contains('T') }) return listOf(ICalParam("VALUE", "DATE")) + if (values.any { it.endsWith("Z") }) return emptyList() + return inherited.filter { it.name.equals("TZID", ignoreCase = true) } + } + + /** + * True when a suppressing residue property no longer describes what its + * column holds — i.e. the user has edited that field since it was imported, + * so the stale copy must go and the column must be authored instead. + */ + private fun contradictsResidue( + property: ICalProperty, + residue: ICalComponent, + entity: TaskEntity, + parentUid: String?, + ): Boolean { + // Cardinality-many and list-valued: the column carries every copy's + // values merged, so it contradicts only when that whole set has changed + // — which is what deleting an occurrence does, and nothing else here + // does. All the copies are then dropped together and one authored line + // carries the lot, parameters included. + fun listChanged(column: String?): Boolean { + fun split(text: String?) = + text.orEmpty().split(',').map(String::trim).filter(String::isNotEmpty) + return split(column) != residue.properties(property.name).flatMap { split(it.value) } + } + fun sameInstant(column: Instant?) = + ICalValues.readInstant(ICalValues.parseTime(property)) == column + + return when (property.name.uppercase()) { + "DTSTART" -> !sameInstant(entity.dtstart) + "DUE" -> !sameInstant(entity.due) + "COMPLETED" -> !sameInstant(entity.completedAt) + "RECURRENCE-ID" -> !sameInstant(entity.recurrenceId) + "RDATE" -> listChanged(entity.rdate) + "EXDATE" -> listChanged(entity.exdate) + "CREATED" -> !sameInstant(entity.createdAt) + "LAST-MODIFIED" -> !sameInstant(entity.lastModified) + // The residue only ever holds a STATUS we could not read, which left + // the column at its NEEDS-ACTION fallback. Anything else means the + // user has since set a real status. + "STATUS" -> entity.status != TaskStatus.NEEDS_ACTION + // A null parentUid is "the parent is not in this store", not "there is + // no parent" — dropping the link there would destroy a relationship + // over a row we simply have not fetched. + // + // ⚠️ Scoped to RELTYPE=PARENT, exactly as the read side is. Nothing + // here models CHILD or SIBLING, and no RELATED-TO is ever claimed, + // so every one of them lives in the residue — where an unscoped + // comparison read a CHILD link as "a PARENT link that disagrees with + // the column" and dropped it on the next PUT. + "RELATED-TO" -> + (property.param("RELTYPE") ?: "PARENT").equals("PARENT", ignoreCase = true) && + parentUid != null && property.value.trim() != parentUid + // Each of these reaches the residue only when it could not be parsed, + // which left the column at its fallback. A column that has since moved + // off that fallback is the user's edit, and it wins. + "PRIORITY" -> entity.priority != PRIORITY_NONE + "PERCENT-COMPLETE" -> entity.percentComplete != null + "CLASS" -> entity.classification != null + // Not SEQUENCE: it is the organiser's counter and never ours to bump, + // so the column stays at its fallback and the residue always wins. + else -> false + } + } + + /** + * A `VALARM` with `TRIGGER;RELATED=END` needs `DUE`, or `DTSTART` plus + * `DURATION` (§3.8.6.3). Clearing the due date on a task that has an + * end-relative reminder produces a resource the server rejects permanently — + * and it is reachable from ordinary UI actions, so it is checked before PUT + * rather than discovered as a 415. + */ + fun validate(vtodo: ICalComponent): List { + val problems = mutableListOf() + val due = vtodo.property("DUE") + val dtstart = vtodo.property("DTSTART") + val duration = vtodo.property("DURATION") + + if (due != null && dtstart != null) { + val start = ICalValues.readInstant(ICalValues.parseTime(dtstart)) + val end = ICalValues.readInstant(ICalValues.parseTime(due)) + // sabre answers 415 for both of these, not a 4xx that names them. + if (start != null && end != null && end < start) problems += "DUE precedes DTSTART" + if (isDateValue(dtstart) != isDateValue(due)) { + problems += "DTSTART and DUE disagree on value type" + } + } + + if (due == null && (dtstart == null || duration == null)) { + val endRelative = vtodo.components("VALARM").any { alarm -> + alarm.property("TRIGGER")?.param("RELATED").equals("END", ignoreCase = true) + } + if (endRelative) problems += "TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION" + } + + if (vtodo.property("METHOD") != null) problems += "METHOD is not allowed on a stored resource" + return problems + } + + // -------------------------------------------------------------- helpers + + private val CLASS_NAMES = listOf("PUBLIC", "PRIVATE", "CONFIDENTIAL") + + private class TimeRead( + val property: ICalProperty?, + val instant: Instant?, + val tzid: String?, + val isDate: Boolean, + val representable: Boolean, + ) { + val present get() = property != null + + /** True when one `is_all_day` flag and one `timezone` column reproduce it. */ + fun reproducible(allDay: Boolean, timezone: String?) = + present && representable && isDate == allDay && tzid == timezone + + /** True when it is a UTC date-time — the only form we author for these. */ + val isUtcDateTime get() = present && representable && !isDate && tzid == null + } + + private fun readTime(vtodo: ICalComponent, name: String): TimeRead { + val property = vtodo.property(name) ?: return TimeRead(null, null, null, false, false) + return when (val value = ICalValues.parseTime(property)) { + is ICalValues.TimeValue.Date -> TimeRead(property, value.instant, null, true, true) + is ICalValues.TimeValue.Timed -> TimeRead(property, value.instant, value.tzid, false, true) + // Readable but not reproducible: the column gets the best-effort + // instant, the property round-trips from the residue verbatim. + is ICalValues.TimeValue.Unrepresentable -> + TimeRead(property, value.instant, null, false, false) + } + } + + private fun timeProperty(name: String, instant: Instant, entity: TaskEntity): ICalProperty { + if (entity.isAllDay) { + return ICalProperty( + name, + listOf(ICalParam("VALUE", "DATE")), + ICalValues.formatDate(instant), + ) + } + // Resolve the zone once. Letting the parameter and the value each decide + // separately produces TZID on a `…Z` value, which §3.3.5 forbids and + // sabre answers 415 for. + val tzid = entity.timezone?.takeIf { runCatching { ZoneId.of(it) }.isSuccess } + val params = if (tzid == null) emptyList() else listOf(ICalParam("TZID", tzid)) + return ICalProperty(name, params, ICalValues.formatDateTime(instant, tzid)) + } + + /** The same DATE test [ICalValues.parseTime] applies, so the two cannot disagree. */ + private fun isDateValue(property: ICalProperty): Boolean = + ICalValues.parseTime(property) is ICalValues.TimeValue.Date + + private fun ICalProperty.text(): String = ICalValues.unescapeText(value) + + private fun parseResidue(text: String?): ICalComponent { + if (text.isNullOrEmpty()) return ICalComponent("VTODO") + // The residue is stored as bare properties followed by whole sub-component + // blocks, so it parses as the body of a VTODO with the wrapper restored. + return runCatching { + ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") + }.getOrElse { ICalComponent("VTODO") } + } + + private fun TaskStatus.toICalName(): String = when (this) { + TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION" + TaskStatus.IN_PROCESS -> "IN-PROCESS" + TaskStatus.COMPLETED -> "COMPLETED" + TaskStatus.CANCELLED -> "CANCELLED" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt new file mode 100644 index 0000000..9cb0429 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/legacy/OneShotImport.kt @@ -0,0 +1,412 @@ +package de.jeanlucmakiola.agendula.data.tasks.legacy + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.tasks.CursorColumnReader +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.statusFromInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** How much one import moved. */ +data class ImportCounts(val lists: Int, val tasks: Int, val alarms: Int) + +/** The outcome of [OneShotImport.runIfNeeded] or [OneShotImport.reimportFromArchive]. */ +sealed interface ImportResult { + /** The DataStore flag was already set; nothing was read. */ + data object AlreadyDone : ImportResult + + /** No legacy database on disk — a fresh install, or one already archived. */ + data object NothingToImport : ImportResult + + data class Imported(val counts: ImportCounts) : ImportResult + + /** Nothing landed: the transaction rolled back and the source is untouched. */ + data class Failed(val cause: Throwable) : ImportResult +} + +/** + * Moves a v0.3.x install's tasks out of the bundled dmfs provider's SQLite file + * and into Room, once. + * + * The file is opened read-only and directly — no provider, no ContentResolver — + * so this keeps working after `:provider` is deleted. Everything lands in one + * Room transaction with verified counts, so a failure leaves Room exactly as it + * was and the source file exactly where it was. + * + * dmfs accounts are not carried over: every list is imported as a device-only + * list (`account_id IS NULL`), including one that sat under a real account — + * only reachable if the user had pointed DAVx5 at our authority. Their task + * `uid`s are preserved, which is what lets those rows be re-attached to an + * account once sync lands. + */ +@Singleton +class OneShotImport @Inject constructor( + @ApplicationContext private val context: Context, + private val database: TasksDatabase, + private val dataStore: DataStore, +) { + + /** Whether the import has run. Set before the rename, so both guards hold. */ + val isDone: Flow = dataStore.data.map { it[IMPORT_DONE] ?: false } + + /** + * Whether the last attempt failed and the archived source is still sitting + * there unread. + * + * Recorded because the alternative is what this class used to do: return an + * [ImportResult.Failed] that every caller dropped on the floor, leaving an + * upgrading user with an empty app, no message, and their tasks in a file + * only a developer could find. Settings → Storage offers the retry. + */ + val lastAttemptFailed: Flow = dataStore.data.map { it[IMPORT_FAILED] ?: false } + + /** + * Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record + * completion. Safe to call on every launch. + * + * The archive happens *before* the import, and the import always replaces, so + * that every point this can be killed at re-enters correctly: + * + * - killed after the rename, before the import — the next run finds the + * archive, imports it, and nothing is lost; + * - killed after the import commits, before the flag is written — the next + * run truncates and re-imports the same archive, so the result is the same + * rather than doubled. + * + * Renaming last would leave that second window open: the flag would be unset + * and `tasks.db` still in place, and the next launch would import it a second + * time on top of the first. That is the window the plan's "guarded by a + * DataStore flag *and* by the rename" is meant to close, and only this order + * actually closes it. + */ + suspend fun runIfNeeded(): ImportResult = withContext(Dispatchers.IO) { + if (isDone.first()) return@withContext ImportResult.AlreadyDone + val source = archivedSource() ?: run { + markDone() + return@withContext ImportResult.NothingToImport + } + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext recordFailure(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The rollback path: re-run against the archived `tasks.db.imported`, + * truncating the Room tables first so a second attempt replaces rather than + * merges. Reached by a targeted fix release, not by the app on its own. + */ + suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) { + val source = archivedSource() ?: return@withContext ImportResult.NothingToImport + val counts = runCatching { importFrom(source, replaceExisting = true) } + .getOrElse { return@withContext recordFailure(it) } + markDone() + ImportResult.Imported(counts) + } + + /** + * The legacy database as `tasks.db.imported`, archiving it first if it is + * still under its live name. `null` when there is nothing to import. + */ + private fun archivedSource(): File? { + val archive = context.getDatabasePath(ARCHIVE_NAME) + if (archive.exists()) return archive + val live = context.getDatabasePath(LEGACY_NAME) + if (!live.exists()) return null + return if (archive(live)) archive else live + } + + /** Clears the completion flag so [runIfNeeded] will import again. */ + suspend fun clearCompletion() { + dataStore.edit { it.remove(IMPORT_DONE) } + } + + /** + * Steps 2–6 against an arbitrary dmfs database: read it read-only, then write + * everything in one Room transaction whose counts are verified before it + * commits. Blocking — call it off the main thread. + */ + fun importFrom(source: File, replaceExisting: Boolean = false): ImportCounts { + val snapshot = SQLiteDatabase.openDatabase(source.path, null, SQLiteDatabase.OPEN_READONLY) + .use(::read) + return database.runInTransaction { + if (replaceExisting) truncate() + val baseline = tableCounts() + val written = write(snapshot) + verify(written, baseline) + written + } + } + + // --- reading the dmfs file ------------------------------------------------ + + private fun read(db: SQLiteDatabase): LegacySnapshot { + val lists = mutableListOf() + db.rawQuery("SELECT * FROM Lists ORDER BY _id", null).use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + lists += LegacyList( + id = id, + entity = TaskListEntity( + name = r.getString("list_name").orEmpty(), + color = r.getInt("list_color") ?: 0, + accountId = null, + isVisible = r.getBoolean("visible"), + isSynced = r.getBoolean("sync_enabled"), + owner = r.getString("list_owner"), + ), + ) + } + } + + val rows = mutableListOf() + db.rawQuery("SELECT * FROM Tasks WHERE _deleted IS NULL OR _deleted = 0 ORDER BY _id", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + val id = r.getLong("_id") ?: continue + rows += LegacyTaskRow( + id = id, + listId = r.getLong("list_id") ?: continue, + parentId = r.getLong("parent_id"), + masterId = r.getLong("original_instance_id"), + recurrenceId = r.instant("original_instance_time"), + entity = TaskEntity( + listId = 0, + uid = r.getString("_uid") ?: UUID.randomUUID().toString(), + title = r.getString("title"), + description = r.getString("description"), + location = r.getString("location"), + url = r.getString("url"), + color = r.getInt("task_color"), + status = statusFromInt(r.getInt("status")), + percentComplete = r.getInt("percent_complete"), + completedAt = r.instant("completed"), + priority = r.getInt("priority") ?: PRIORITY_NONE, + classification = r.getInt("class"), + dtstart = r.instant("dtstart"), + due = r.instant("due"), + duration = r.getString("duration"), + isAllDay = r.getBoolean("is_allday"), + timezone = r.getString("tz"), + rrule = r.getString("rrule"), + rdate = r.getString("rdate"), + exdate = r.getString("exdate"), + createdAt = r.instant("created"), + lastModified = r.instant("last_modified"), + ), + ) + } + } + + val alarms = mutableListOf() + db.rawQuery("SELECT task_id, mimetype, data0, data1, data2 FROM Properties", null) + .use { cursor -> + val r = CursorColumnReader(cursor) + while (cursor.moveToNext()) { + if (r.getString("mimetype") != ALARM_MIMETYPE) continue + val taskId = r.getLong("task_id") ?: continue + val minutes = r.getString("data0")?.trim()?.toIntOrNull() ?: continue + alarms += LegacyAlarm( + taskId = taskId, + minutesBefore = minutes, + reference = if (r.getString("data1")?.trim() == REFERENCE_START) { + AlarmReference.START + } else { + AlarmReference.DUE + }, + message = r.getString("data2"), + ) + } + } + + return LegacySnapshot(lists, rows, alarms) + } + + // --- writing into Room ---------------------------------------------------- + + /** + * dmfs `list_id`, `parent_id` and `original_instance_id` are old row ids, and + * Room mints its own on insert, so every one of them is remapped through the + * ids the inserts hand back. Tasks are inserted with their links cleared and + * a second pass sets them, because a parent may be a higher `_id` than its + * child. + */ + private fun write(snapshot: LegacySnapshot): ImportCounts { + val listDao = database.taskLists() + val taskDao = database.tasks() + val alarmDao = database.alarms() + + val listIds = snapshot.lists.associate { it.id to listDao.insert(it.entity) } + + // A task whose list is missing is already invisible in dmfs — its tasks + // view inner-joins Lists — so dropping it loses nothing the user could see. + val importable = snapshot.tasks.filter { it.listId in listIds } + val importableIds = importable.mapTo(mutableSetOf()) { it.id } + val taskIds = mutableMapOf() + val inserted = mutableListOf>() + val seen = mutableSetOf>() + + for (row in importable) { + val listId = listIds.getValue(row.listId) + val overrides = row.masterId != null && row.masterId in importableIds + val recurrenceId = row.recurrenceId.takeIf { overrides } + // A duplicate (list, uid, recurrence) would abort the whole import on + // the unique index; a fresh uid costs the row nothing it still has. + val uid = row.entity.uid.takeIf { seen.add(Triple(listId, it, recurrenceId)) } + ?: UUID.randomUUID().toString() + val entity = row.entity.copy(listId = listId, uid = uid, recurrenceId = recurrenceId) + val newId = taskDao.insert(entity) + taskIds[row.id] = newId + inserted += row to entity.copy(id = newId) + } + + for ((row, entity) in inserted) { + val parentId = row.parentId?.let(taskIds::get) + val masterId = row.masterId?.let(taskIds::get) + if (parentId == null && masterId == null) continue + taskDao.update(entity.copy(parentId = parentId, masterId = masterId)) + } + + var alarmCount = 0 + for (alarm in snapshot.alarms) { + val taskId = taskIds[alarm.taskId] ?: continue + alarmDao.insert( + TaskAlarmEntity( + taskId = taskId, + minutesBefore = alarm.minutesBefore, + reference = alarm.reference, + message = alarm.message, + ), + ) + alarmCount++ + } + + return ImportCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount) + } + + private fun verify(written: ImportCounts, before: ImportCounts) { + val after = tableCounts() + check(after.lists - before.lists == written.lists) { + "list count mismatch: ${after.lists - before.lists} != ${written.lists}" + } + check(after.tasks - before.tasks == written.tasks) { + "task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}" + } + check(after.alarms - before.alarms == written.alarms) { + "alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}" + } + } + + /** Dropping the lists takes their tasks and alarms with them, by cascade. */ + private fun truncate() { + val listDao = database.taskLists() + listDao.lists().forEach { listDao.delete(it.list.id) } + } + + private fun tableCounts() = ImportCounts( + lists = count("task_lists"), + tasks = count("tasks"), + alarms = count("task_alarms"), + ) + + private fun count(table: String): Int = + database.query("SELECT COUNT(*) FROM $table", null).use { + if (it.moveToFirst()) it.getInt(0) else 0 + } + + // --- the source file ------------------------------------------------------ + + /** + * Renames the dmfs file, sidecars included, to `tasks.db.imported`. Never + * deletes it: for one release it is the only way back if the import turns out + * to be wrong on someone's device. + */ + private fun archive(source: File): Boolean { + val target = File(source.parentFile, ARCHIVE_NAME) + if (!source.renameTo(target)) return false + for (suffix in SIDECARS) { + val sidecar = File(source.path + suffix) + if (sidecar.exists()) sidecar.renameTo(File(target.path + suffix)) + } + return true + } + + /** + * Leaves a breadcrumb the UI can act on, and one in logcat for a bug report. + * The completion flag is deliberately *not* set: the next launch retries on + * its own, and the archived source is still where it was. + */ + private suspend fun recordFailure(cause: Throwable): ImportResult.Failed { + Log.e(TAG, "Importing the legacy task database failed; source left in place", cause) + dataStore.edit { it[IMPORT_FAILED] = true } + return ImportResult.Failed(cause) + } + + private suspend fun markDone() { + dataStore.edit { + it[IMPORT_DONE] = true + it.remove(IMPORT_FAILED) + } + } + + private fun CursorColumnReader.instant(name: String): Instant? = + getLong(name)?.let(Instant::fromEpochMilliseconds) + + companion object { + const val LEGACY_NAME = "tasks.db" + const val ARCHIVE_NAME = "tasks.db.imported" + + private const val ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + private const val REFERENCE_START = "2" + private val SIDECARS = listOf("-journal", "-wal", "-shm") + private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done") + private val IMPORT_FAILED = booleanPreferencesKey("legacy_import_failed") + private const val TAG = "OneShotImport" + } +} + +private class LegacySnapshot( + val lists: List, + val tasks: List, + val alarms: List, +) + +private class LegacyList(val id: Long, val entity: TaskListEntity) + +private class LegacyTaskRow( + val id: Long, + val listId: Long, + val parentId: Long?, + val masterId: Long?, + val recurrenceId: Instant?, + val entity: TaskEntity, +) + +private class LegacyAlarm( + val taskId: Long, + val minutesBefore: Int, + val reference: AlarmReference, + val message: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt new file mode 100644 index 0000000..a734b9b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant + +/** Reads and writes over `accounts`. */ +@Dao +interface AccountDao { + + @Query("SELECT * FROM accounts ORDER BY display_name") + fun all(): List + + /** + * The same rows, observed. + * + * ⚠️ A one-shot read cannot show a sync: the worker writes `last_sync_at` + * from a background thread minutes after the button was pressed, so a screen + * holding a snapshot goes on saying "never synced" until the user leaves and + * comes back. Room's invalidation tracker is what closes that gap. + */ + @Query("SELECT * FROM accounts ORDER BY display_name") + fun observeAll(): Flow> + + @Query("SELECT * FROM accounts WHERE id = :accountId") + fun account(accountId: Long): AccountEntity? + + @Insert + fun insert(account: AccountEntity): Long + + @Update + fun update(account: AccountEntity) + + @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") + fun recordSync(accountId: Long, at: Instant?, error: String?) + + @Query("DELETE FROM accounts WHERE id = :accountId") + fun delete(accountId: Long): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt new file mode 100644 index 0000000..f4eae17 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.TypeConverter +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.statusFromInt +import de.jeanlucmakiola.agendula.domain.toInt +import kotlin.time.Instant + +/** + * Storage encodings for the entity types SQLite has no column type for. Time is + * epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering + * keeps its single home. + * + * `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar + * integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy + * bucketing and a converter would apply it before the value reaches disk. + */ +object Converters { + + @TypeConverter + fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds() + + @TypeConverter + fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds) + + @TypeConverter + fun statusToInt(value: TaskStatus): Int = value.toInt() + + @TypeConverter + fun statusFrom(value: Int): TaskStatus = statusFromInt(value) + + @TypeConverter + fun alarmReferenceToString(value: AlarmReference): String = value.name + + @TypeConverter + fun alarmReferenceFrom(value: String): AlarmReference = + runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt new file mode 100644 index 0000000..f02144b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/DatabaseCheckpoint.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Folds the write-ahead log back into the database file when the app goes to the + * background. + * + * Room runs in WAL mode, and 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, which already makes a restore consistent; this narrows + * the window further by ensuring the `.db` alone is usually current, which is what + * a restore onto a device that drops the sidecars falls back to. + */ +@Singleton +class DatabaseCheckpoint @Inject constructor( + private val database: TasksDatabase, + @ApplicationScope private val scope: CoroutineScope, +) : DefaultLifecycleObserver { + + override fun onStop(owner: LifecycleOwner) { + scope.launch(Dispatchers.IO) { + runCatching { + // ⚠️ Stepped, not merely compiled. `query` hands back a lazy + // cursor and the statement runs on the first fill — closing it + // unread made this whole class a no-op, so the sidecar kept + // growing and the `.db` stayed stale, which is precisely the + // restore case above. + database.openHelper.writableDatabase + .query("PRAGMA wal_checkpoint(TRUNCATE)") + .use { it.moveToFirst() } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt new file mode 100644 index 0000000..972018d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -0,0 +1,222 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * A CalDAV account. Empty until an account is added, but the FK + * from [TaskListEntity] exists from v1 so turning sync on never needs a + * migration. The app password is never stored here — Keystore only. + */ +@Entity(tableName = "accounts") +data class AccountEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "principal_url") val principalUrl: String? = null, + @ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null, + @ColumnInfo(name = "username") val username: String? = null, + @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, + @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, +) + +/** + * A task list. [accountId] is nullable: `NULL` is a device-only list, and + * attaching one to an account later is a plain `UPDATE` rather than a data + * migration. + * + * Deleting an account detaches its lists (`SET NULL`) instead of deleting them, + * for the same reason [TaskEntity.parentId] does — removing an account is not + * an instruction to destroy the tasks it held. + */ +@Entity( + tableName = "task_lists", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["account_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [Index(value = ["account_id"])], +) +data class TaskListEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + /** ARGB. */ + @ColumnInfo(name = "color") val color: Int, + @ColumnInfo(name = "account_id") val accountId: Long? = null, + @ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true, + @ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true, + /** CalDAV owner display name. */ + @ColumnInfo(name = "owner") val owner: String? = null, + @ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false, + /** User ordering. */ + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + /** Collection URL, relative to the account root. */ + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "ctag") val ctag: String? = null, + /** RFC 6578 sync token, per collection. */ + @ColumnInfo(name = "sync_token") val syncToken: String? = null, + /** + * Unused, and kept only because dropping a column costs a migration. + * + * ⚠️ It was set by a rename and read by nobody. A collection's name and + * colour are now written server-first by + * [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], so there is + * no local edit left waiting to be pushed — and a flag that means "owed to + * the server" while nothing ever pays it is worse than no flag at all. + */ + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, +) + +/** + * A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an + * override is a row with [recurrenceId] set and [masterId] pointing at its + * master, sharing the master's [uid]. + * + * [masterId] and [parentId] are different things: [parentId] is task hierarchy + * (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both. + */ +@Entity( + tableName = "tasks", + foreignKeys = [ + ForeignKey( + entity = TaskListEntity::class, + parentColumns = ["id"], + childColumns = ["list_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a series takes its overrides with it — they would otherwise be + // unreachable rows that still sync. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["master_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a parent promotes its subtasks to top level rather than + // destroying work the user did not ask to lose. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["parent_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [ + Index(value = ["list_id", "is_deleted"]), + Index(value = ["parent_id"]), + Index(value = ["master_id", "recurrence_id"]), + Index(value = ["is_dirty"]), + // An override shares its master's UID, so (list_id, uid) alone would + // reject the very rows recurrence depends on. With recurrence_id NULL on + // the master and set on each override this reads as: one master and at + // most one override per occurrence, per UID, per list. Note SQLite treats + // NULLs as distinct in a unique index, so the master half is a statement + // of intent, not an enforced constraint. + Index(value = ["list_id", "uid", "recurrence_id"], unique = true), + ], +) +data class TaskEntity( + // identity + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "list_id") val listId: Long, + /** RFC 4122 UUID, minted at creation in every mode, synced or not. */ + @ColumnInfo(name = "uid") val uid: String, + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "etag") val etag: String? = null, + + // content + @ColumnInfo(name = "title") val title: String? = null, + @ColumnInfo(name = "description") val description: String? = null, + @ColumnInfo(name = "location") val location: String? = null, + @ColumnInfo(name = "url") val url: String? = null, + /** ARGB override for the list colour. */ + @ColumnInfo(name = "color") val color: Int? = null, + + // state + @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, + @ColumnInfo(name = "percent_complete") val percentComplete: Int? = null, + @ColumnInfo(name = "completed_at") val completedAt: Instant? = null, + /** + * Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed — + * [de.jeanlucmakiola.agendula.domain.Priority] folds 1–4 into HIGH, so + * converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and + * lose it on the next round-trip. The bucketing belongs to the mapper, which + * is where the UI needs it. + */ + @ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE, + /** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */ + @ColumnInfo(name = "classification") val classification: Int? = null, + + // time + @ColumnInfo(name = "dtstart") val dtstart: Instant? = null, + @ColumnInfo(name = "due") val due: Instant? = null, + /** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */ + @ColumnInfo(name = "duration") val duration: String? = null, + @ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false, + @ColumnInfo(name = "timezone") val timezone: String? = null, + + // recurrence + @ColumnInfo(name = "rrule") val rrule: String? = null, + @ColumnInfo(name = "rdate") val rdate: String? = null, + @ColumnInfo(name = "exdate") val exdate: String? = null, + /** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */ + @ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null, + /** The series this row overrides; `NULL` on a master. */ + @ColumnInfo(name = "master_id") val masterId: Long? = null, + + // hierarchy + @ColumnInfo(name = "parent_id") val parentId: Long? = null, + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + + // audit + @ColumnInfo(name = "created_at") val createdAt: Instant? = null, + @ColumnInfo(name = "last_modified") val lastModified: Instant? = null, + @ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0, + + // sync + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, + /** Tombstone: deleted locally, still owed to a server. */ + @ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false, + /** + * Raw unfolded iCalendar lines of every property we do not model, re-emitted + * verbatim on write so a round-trip cannot silently lose a field. + */ + @ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null, +) + +/** What [TaskAlarmEntity.minutesBefore] counts back from. */ +enum class AlarmReference { DUE, START } + +/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */ +@Entity( + tableName = "task_alarms", + foreignKeys = [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["task_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["task_id"])], +) +data class TaskAlarmEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "task_id") val taskId: Long, + @ColumnInfo(name = "minutes_before") val minutesBefore: Int, + @ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE, + @ColumnInfo(name = "message") val message: String? = null, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/LocalWriteListener.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/LocalWriteListener.kt new file mode 100644 index 0000000..cefe6ec --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/LocalWriteListener.kt @@ -0,0 +1,17 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +/** + * Told which lists a local task write touched, so an account-backed one can be + * pushed without waiting for the four-hour schedule. + * + * Called on the writing thread, after the write. Implementations must be cheap + * and must not throw. + */ +fun interface LocalWriteListener { + + fun onWritten(listIds: Set) + + companion object { + val NONE = LocalWriteListener { } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt new file mode 100644 index 0000000..74f69d4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A list plus its account's display name, which the domain + * [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by. + * `null` means a device-only list. + */ +data class TaskListRow( + @Embedded val list: TaskListEntity, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) + +/** + * A task plus the columns of its list the domain + * [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is + * one query rather than one per list. + */ +data class TaskRow( + @Embedded val task: TaskEntity, + @ColumnInfo(name = "list_name") val listName: String, + @ColumnInfo(name = "list_color") val listColor: Int, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, + @ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt new file mode 100644 index 0000000..dffe701 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.LocalAccount +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.priorityFromICal +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec +import kotlin.time.Instant + +/** Account type reported for a list attached to one of ours. */ +const val CALDAV_ACCOUNT_TYPE = "caldav" + +/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */ +object RoomTaskMapper { + + fun taskList(row: TaskListRow): TaskList = TaskList( + id = row.list.id, + name = row.list.name, + color = row.list.color, + // TaskList.accountName is non-null and the lists screen groups by it, so a + // list with no account still has to report something to group under. + accountName = row.accountDisplayName ?: LocalAccount.NAME, + accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE, + isSynced = row.list.isSynced, + isVisible = row.list.isVisible, + owner = row.list.owner, + accountId = row.list.accountId, + isReadOnly = row.list.isReadOnly, + ) + + /** + * One occurrence of [row]. [occurrenceStart] is the occurrence's + * `RECURRENCE-ID` anchor and `null` for a task that does not recur; + * [start] / [due] are that occurrence's resolved times. + */ + fun task( + row: TaskRow, + occurrenceStart: Instant? = null, + start: Instant? = row.task.dtstart, + due: Instant? = row.task.due, + distanceFromCurrent: Int? = null, + seriesId: Long? = if (row.task.isRecurring) row.task.id else null, + recurrenceRule: String? = row.task.rrule, + ): Task = Task( + taskId = row.task.id, + listId = row.task.listId, + title = row.task.title.orEmpty(), + description = row.task.description, + location = row.task.location, + url = row.task.url, + priority = priorityFromICal(row.task.priority), + status = row.task.status, + percentComplete = row.task.percentComplete, + start = start, + due = due, + isAllDay = row.task.isAllDay, + timeZone = row.task.timezone, + completedAt = row.task.completedAt, + listColor = row.listColor, + taskColor = row.task.color, + listName = row.listName, + accountName = row.accountDisplayName ?: LocalAccount.NAME, + parentId = row.task.parentId, + isRecurring = row.task.isRecurring, + occurrenceStart = occurrenceStart, + distanceFromCurrent = distanceFromCurrent, + created = row.task.createdAt, + lastModified = row.task.lastModified, + seriesId = seriesId, + recurrenceRule = recurrenceRule, + isReadOnly = row.listReadOnly, + ) + + fun exportTask(task: TaskEntity): ExportTask = ExportTask( + taskId = task.id, + uid = task.uid, + title = task.title.orEmpty(), + description = task.description, + location = task.location, + url = task.url, + priority = priorityFromICal(task.priority), + status = task.status, + percentComplete = task.percentComplete, + start = task.dtstart, + due = task.due, + isAllDay = task.isAllDay, + completedAt = task.completedAt, + created = task.createdAt, + lastModified = task.lastModified, + rrule = task.rrule, + rdate = task.rdate, + parentId = task.parentId?.takeIf { it > 0 }, + ) +} + +/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */ +val TaskEntity.isRecurring: Boolean + get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank() + +/** + * The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only + * a due date, and RFC 5545 then anchors the recurrence on it — matching how the + * dmfs provider instantiated the same series. + */ +val TaskEntity.recurrenceAnchor: Instant? + get() = dtstart ?: due + +/** The rule set of this series, or `null` when it does not recur. */ +fun TaskEntity.recurrenceSpec(): RecurrenceSpec? { + if (!isRecurring) return null + val anchor = recurrenceAnchor ?: return null + return RecurrenceSpec( + rrule = rrule, + rdate = rdate, + exdate = exdate, + anchor = anchor, + isAllDay = isAllDay, + timeZone = timezone, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt new file mode 100644 index 0000000..205cfa6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTasksDataSource.kt @@ -0,0 +1,554 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.InvalidationTracker +import de.jeanlucmakiola.agendula.data.tasks.TaskQuery +import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.data.tasks.editable +import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +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 de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander +import java.time.ZoneId +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +/** How far either side of now a series is expanded. */ +private val WINDOW_BACK = 365.days +private val WINDOW_FORWARD = 730.days + +/** Residue lines that describe the old series and must not follow the new one. */ +private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "UID") + +/** Enough to count every occurrence before a split point without bounding a real series. */ +internal const val SPLIT_COUNT_CEILING = 100_000 + +private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts") + +/** + * [TasksDataSource] over Agendula's own Room store. + * + * The one structural difference from [de.jeanlucmakiola.agendula.data.tasks + * .AndroidTasksDataSource]: there is no materialised instances table, so a + * recurring series is expanded here, at read time, by [RecurrenceExpander]. + * Nothing above this cares — the repository already filters and sorts in Kotlin. + */ +@Singleton +class RoomTasksDataSource @Inject constructor( + private val database: TasksDatabase, + private val writes: LocalWriteListener, +) : TasksDataSource { + + /** Without a listener, for tests that exercise the store alone. */ + constructor(database: TasksDatabase) : this(database, LocalWriteListener.NONE) + + private val clock: Clock = Clock.System + + private val tasks get() = database.tasks() + private val lists get() = database.taskLists() + private val alarms get() = database.alarms() + + // --- reads ---------------------------------------------------------------- + + override fun taskLists(): List = lists.lists().map(RoomTaskMapper::taskList) + + override fun tasks(query: TaskQuery): List { + val now = clock.now() + val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId } + return tasks.tasks(query.listId, query.includeCompleted) + .flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) } + .filter { query.includeCompleted || !it.isClosed } + } + + override fun task(taskId: Long): Task? { + val row = tasks.task(taskId) ?: return null + // An override row is one occurrence in its own right; it names the + // occurrence it replaces rather than expanding to a series. + row.task.recurrenceId?.let { return overrideTask(row, it) } + val now = clock.now() + val occurrences = occurrencesOf(row, tasks.overrides(taskId), now) + return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull() + } + + override fun task(taskId: Long, occurrenceStart: Instant?): Task? { + if (occurrenceStart == null) return task(taskId) + val row = tasks.task(taskId) ?: return null + if (row.task.recurrenceId != null || !row.task.isRecurring) return task(taskId) + return occurrencesOf(row, tasks.overrides(taskId), clock.now()) + .firstOrNull { it.occurrenceStart == occurrenceStart } + ?: task(taskId) + } + + private fun overrideTask(row: TaskRow, anchor: Instant): Task = + RoomTaskMapper.task( + row, + occurrenceStart = anchor, + seriesId = row.task.masterId, + recurrenceRule = row.task.masterId?.let { tasks.entity(it)?.rrule }, + ) + + override fun subtasks(parentTaskId: Long): List { + val now = clock.now() + return tasks.subtasks(parentTaskId) + .flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) } + } + + override fun exportTasks(listId: Long): List = + tasks.exportTasks(listId).map(RoomTaskMapper::exportTask) + + override fun reminders(): Map> = + alarms.all().groupBy({ it.taskId }) { + TaskReminder( + minutesBefore = it.minutesBefore, + fromStart = it.reference == AlarmReference.START, + ) + } + + override fun alarms(): Map = + reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap() + + /** + * Every occurrence of [row] inside the expansion window, with any + * `RECURRENCE-ID` override substituted for the occurrence it replaces. + * + * A non-recurring task is its own single occurrence and carries a null + * [Task.occurrenceStart], so it keys and edits by task id exactly as before. + */ + private fun occurrencesOf(row: TaskRow, overrides: List, now: Instant): List { + val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row)) + val window = ExpansionWindow( + from = now - WINDOW_BACK, + until = now + WINDOW_FORWARD, + pivot = now, + ) + val anchors = RecurrenceExpander.expand(spec, window) + if (anchors.isEmpty()) return emptyList() + + val distances = RecurrenceExpander.distancesFromCurrent(anchors, now) + val byAnchor = overrides.associateBy { it.recurrenceId } + + return anchors.mapIndexedNotNull { index, anchor -> + val override = byAnchor[anchor] + if (override != null) { + RoomTaskMapper.task( + row = row.copy(task = override), + occurrenceStart = anchor, + start = override.dtstart, + due = override.due, + distanceFromCurrent = distances[index], + seriesId = row.task.id, + recurrenceRule = row.task.rrule, + ) + } else { + val (start, due) = occurrenceTimes(row.task, anchor) + RoomTaskMapper.task( + row = row, + occurrenceStart = anchor, + start = start, + due = due, + distanceFromCurrent = distances[index], + ) + } + } + } + + /** + * One occurrence's resolved start and due. A timed series keeps each + * occurrence's duration; a due-anchored one has no start to offset from, so + * the anchor *is* the due date. + */ + private fun occurrenceTimes(master: TaskEntity, anchor: Instant): Pair { + if (master.dtstart == null) return null to anchor + val length = master.due?.let { it - master.dtstart } + return anchor to length?.let { anchor + it } + } + + // --- writes --------------------------------------------------------------- + + override fun insertTask(form: TaskForm): Long { + if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}") + val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone()) + return tasks.insert(TaskFormWriter.withRule(entity, form.rrule)).also { writes.onWritten(setOf(form.listId)) } + } + + override fun updateTask(taskId: Long, form: TaskForm) { + val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId") + val edited = TaskFormWriter.apply(current, form, clock.now(), zone()) + if (current.masterId == null && edited.listId != current.listId) { + if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}") + database.runInTransaction { move(current, TaskFormWriter.withRule(edited, form.rrule)) } + writes.onWritten(setOf(current.listId, edited.listId)) + return + } + // ⚠️ An override's list and parent are the master's, the rule + // [updateInstance] states and this path has to keep. An overridden + // occurrence maps with `isRecurring = false`, so the repository routes + // it here and the edit screen offers its list picker — and a row that + // took `list_id = B` while its `master_id` stayed 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. + tasks.update( + if (current.masterId == null) { + TaskFormWriter.withRule(edited, form.rrule) + } else { + edited.copy(listId = current.listId, parentId = current.parentId) + }, + ) + writes.onWritten(setOf(current.listId)) + } + + /** See [TaskMove]: the rows move, and the old collection is left a tombstone. */ + private fun move(current: TaskEntity, edited: TaskEntity) { + val plan = TaskMove.plan( + edited = edited, + previous = current, + overrides = tasks.allOverridesOf(current.id), + sourceSynced = lists.entity(current.listId)?.accountId != null, + targetTombstone = tasks.byUid(edited.listId, current.uid), + ) + if (plan.delete.isNotEmpty()) tasks.deleteAll(plan.delete) + plan.update.forEach { tasks.update(it) } + plan.tombstone?.let { tasks.insert(it) } + } + + override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = tx { + val master = seriesMaster(seriesId) + val (start, due) = resolvedTimes(master, occurrenceStart) + val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone()) + // A moved or re-ruled series no longer generates the anchors its + // overrides name, so they would hang off nothing. + val reshaped = edited.dtstart != master.dtstart || edited.due != master.due || edited.rrule != master.rrule + if (reshaped) tasks.overrides(master.id).forEach { tasks.delete(it.id) } + if (edited.listId != master.listId) { + if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move series: no list ${edited.listId}") + move(master, edited) + } else { + tasks.update(edited) + } + writes.onWritten(setOf(master.listId, edited.listId)) + } + + override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long { + var created = seriesId + tx { + val master = seriesMaster(seriesId) + val spec = master.recurrenceSpec() + val anchor = master.recurrenceAnchor + if (spec == null || anchor == null || occurrenceStart <= anchor) { + updateSeries(seriesId, occurrenceStart, form) + return@tx + } + val now = clock.now() + // COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3). + val spent = RecurrenceExpander.expand( + spec.copy(exdate = null), + ExpansionWindow(from = anchor, until = occurrenceStart, maxOccurrences = SPLIT_COUNT_CEILING), + ).size + val (start, due) = resolvedTimes(master, occurrenceStart) + tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, now, zone())) + + val sameRule = form.rrule == master.rrule + val rule = form.rrule?.let { if (sameRule) TaskFormWriter.remainingCount(it, spent) else it } + val base = master.copy( + id = 0, + uid = UUID.randomUUID().toString(), + href = null, + etag = null, + rrule = null, + exdate = null, + rdate = null, + createdAt = now, + sequence = 0, + isDeleted = false, + dtstart = start, + due = due, + status = TaskStatus.NEEDS_ACTION, + percentComplete = null, + completedAt = null, + // Recurrence lines belong to the old series; everything else carries over. + unknownProperties = master.unknownProperties?.lines() + ?.filterNot { line -> SERIES_LINES.any { line.startsWith(it, ignoreCase = true) } } + ?.joinToString("\r\n")?.ifBlank { null }, + ) + val applied = TaskFormWriter.apply(base, form.copy(percentComplete = null), now, zone()) + val delta = applied.dtstart?.let { newStart -> start?.let { newStart - it } } + val laterExceptions = TaskFormWriter.valuesFrom(master.exdate, occurrenceStart, master.timezone ?: zone()) + val exceptions = if (rule == null) null + else if (delta == null || delta == kotlin.time.Duration.ZERO) laterExceptions + else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone()) + val fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it } + val newId = tasks.insert(fresh) + alarms.forTask(master.id).forEach { alarms.insert(it.copy(id = 0, taskId = newId)) } + + val unmoved = fresh.dtstart == start && fresh.due == due && sameRule + tasks.overrides(master.id) + .filter { it.recurrenceId != null && it.recurrenceId >= occurrenceStart } + .forEach { override -> + if (unmoved && override.recurrenceId != occurrenceStart) { + tasks.update( + override.copy(masterId = newId, uid = fresh.uid, listId = fresh.listId, href = null, etag = null, isDirty = true), + ) + } else { + tasks.delete(override.id) + } + } + created = newId + writes.onWritten(setOf(master.listId, fresh.listId)) + } + return created + } + + override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = tx { + tasks.override(seriesId, occurrenceStart)?.let { + deleteTask(it.id) + return@tx + } + val master = tasks.entity(seriesId) ?: return@tx + val synced = lists.entity(master.listId)?.accountId != null + tasks.update(TaskFormWriter.excepting(master, occurrenceStart, clock.now(), zone(), dirty = synced)) + writes.onWritten(setOf(master.listId)) + } + + override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = tx { + val master = tasks.entity(seriesId) ?: return@tx + val anchor = master.recurrenceAnchor + if (anchor == null || occurrenceStart <= anchor) { + tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) } + deleteTask(seriesId) + return@tx + } + tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone())) + tasks.overrides(seriesId) + .filter { it.recurrenceId != null && it.recurrenceId >= occurrenceStart } + .forEach { tasks.delete(it.id) } + writes.onWritten(setOf(master.listId)) + } + + private inline fun tx(crossinline block: () -> Unit) = database.runInTransaction(Runnable { block() }) + + private fun seriesMaster(seriesId: Long): TaskEntity { + val row = tasks.entity(seriesId) ?: throw TaskWriteFailedException("series $seriesId") + return row.masterId?.let { tasks.entity(it) } ?: row + } + + /** The occurrence's own times: its override's if it has one, else generated. */ + private fun resolvedTimes(master: TaskEntity, occurrenceStart: Instant): Pair = + tasks.override(master.id, occurrenceStart)?.let { it.dtstart to it.due } + ?: occurrenceTimes(master, occurrenceStart) + + /** + * Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and + * what every other CalDAV client expects to receive. The dmfs provider + * detached the occurrence into a brand-new task with its own UID instead, + * which is the model least compatible with sync; the override shares its + * master's UID, which is exactly what makes it an override. + */ + override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) { + val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId") + val now = clock.now() + val existing = tasks.override(taskId, occurrenceStart) + if (existing != null) { + // The same rule the fork below applies: the list and parent are the + // master's, whatever the form was carrying. + val edited = TaskFormWriter.apply(existing, form, now, zone()) + tasks.update(edited.copy(listId = master.listId, parentId = master.parentId)) + writes.onWritten(setOf(master.listId)) + return + } + val (start, due) = occurrenceTimes(master, occurrenceStart) + val fork = TaskFormWriter.apply( + newOverride(master, taskId, occurrenceStart, start, due), + form, + now, + zone(), + ) + // The list and parent come from the master: moving one occurrence between + // lists or parents is not something the override model expresses. + val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId)) + alarms.replaceAllForTask(id, alarms.forTask(taskId)) + writes.onWritten(setOf(master.listId)) + } + + override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) { + alarms.replaceEditable( + taskId, + minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) }, + ) + } + + override fun setReminders(taskId: Long, reminders: List) { + alarms.replaceAllForTask( + taskId, + reminders.map { + TaskAlarmEntity( + taskId = taskId, + minutesBefore = it.minutesBefore, + reference = if (it.fromStart) AlarmReference.START else AlarmReference.DUE, + ) + }, + ) + } + + override fun setCancelled(taskId: Long, cancelled: Boolean) { + val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("cancel task $taskId") + tasks.update(TaskFormWriter.cancelled(current, cancelled, clock.now())) + writes.onWritten(setOf(current.listId)) + } + + override fun setCompleted(taskId: Long, completed: Boolean) { + val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId") + tasks.update(TaskFormWriter.completed(current, completed, clock.now())) + writes.onWritten(setOf(current.listId)) + } + + /** + * Ticking one occurrence forks a `RECURRENCE-ID` override carrying the + * completion — the same model [updateInstance] writes. Writing the status onto + * the master instead would close the series: the master is what + * [TaskDao.tasks] filters on, so every occurrence, past and future, would + * leave every list at once. + */ + override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = + writeInstance(taskId, occurrenceStart, "complete") { TaskFormWriter.completed(it, completed, clock.now()) } + + /** Same override model as [setCompletedInstance]. */ + override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = + writeInstance(taskId, occurrenceStart, "cancel") { TaskFormWriter.cancelled(it, cancelled, clock.now()) } + + /** Applies [write] to the override for [occurrenceStart], forking one if there is none yet. */ + private fun writeInstance( + taskId: Long, + occurrenceStart: Instant, + what: String, + write: (TaskEntity) -> TaskEntity, + ) { + val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("$what instance $taskId") + // Not a series master — an override, or a plain task the caller handed an + // anchor for. Either way this row *is* the occurrence. + if (master.recurrenceSpec() == null) { + tasks.update(write(master)) + writes.onWritten(setOf(master.listId)) + return + } + + tasks.override(taskId, occurrenceStart)?.let { + tasks.update(write(it)) + writes.onWritten(setOf(master.listId)) + return + } + val (start, due) = occurrenceTimes(master, occurrenceStart) + val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due))) + alarms.replaceAllForTask(id, alarms.forTask(taskId)) + writes.onWritten(setOf(master.listId)) + } + + /** + * A blank override row for one occurrence of [master]: same UID (that is what + * makes it an override rather than a separate task), the series fields + * stripped, and no `href`/`etag` because the server has never seen it. + */ + private fun newOverride( + master: TaskEntity, + masterId: Long, + occurrenceStart: Instant, + start: Instant?, + due: Instant?, + ): TaskEntity = master.copy( + id = 0, + masterId = masterId, + recurrenceId = occurrenceStart, + dtstart = start, + due = due, + rrule = null, + rdate = null, + exdate = null, + href = null, + etag = null, + ) + + /** + * Hard delete for a row no server knows about, tombstone for one that is + * still owed to a collection — and an `EXDATE` when what is being deleted is + * a single occurrence of a series. + * + * ⚠️ Removing an override does not delete the occurrence, it *un-overrides* + * it: per RFC 5545 the master's `RRULE` regenerates it as a plain instance, + * on the server and on every other client. The exception has to be written + * down on the master, which is also what keeps the occurrence hidden in a + * device-only list, where there is no tombstone to hide it. + */ + override fun deleteTask(taskId: Long) { + val current = tasks.entity(taskId) ?: return + val listAccount = lists.entity(current.listId)?.accountId + val masterId = current.masterId + val occurrence = current.recurrenceId + if (masterId != null && occurrence != null) { + tasks.entity(masterId)?.let { master -> + tasks.update( + TaskFormWriter.excepting( + master, + occurrence, + clock.now(), + zone(), + // A device-only list owes nobody a PUT. + dirty = listAccount != null, + ), + ) + } + // The master's EXDATE *is* the deletion, so the override has no job + // left. Leaving a tombstone behind would upload a body with the + // occurrence merely absent, which says the opposite. + tasks.delete(taskId) + writes.onWritten(setOf(current.listId)) + return + } + if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now()) + writes.onWritten(setOf(current.listId)) + } + + override fun createLocalList(name: String, color: Int): Long = + lists.insert(TaskListEntity(name = name.trim(), color = color)) + + /** + * ⚠️ Device-only lists. An account-backed collection is renamed through + * [de.jeanlucmakiola.agendula.data.sync.RemoteListRepository], which sends + * the PROPPATCH first and writes here only once the server has taken it. + * + * This used to set `is_dirty` for an account list instead, on the theory + * that a later sync would push it. Nothing ever read that flag: the rename + * never left the phone, and the next run that re-read the collection's + * `displayname` quietly put the old name back. + */ + override fun updateList(listId: Long, name: String, color: Int) { + val current = lists.entity(listId) ?: throw TaskWriteFailedException("update list $listId") + lists.update(current.copy(name = name.trim(), color = color)) + } + + override fun reorderLists(listIds: List) = tx { + listIds.forEachIndexed { index, id -> lists.setSortOrder(id, index) } + } + + /** `tasks.list_id` is `ON DELETE CASCADE`, so the list's tasks go with it. */ + override fun deleteList(listId: Long) = lists.delete(listId) + + // --- observation ---------------------------------------------------------- + + override fun registerObserver(onChange: () -> Unit): AutoCloseable { + val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) { + override fun onInvalidated(tables: Set) = onChange() + } + database.invalidationTracker.addObserver(observer) + return AutoCloseable { database.invalidationTracker.removeObserver(observer) } + } + + private fun zone(): String = ZoneId.systemDefault().id +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt new file mode 100644 index 0000000..b9091e1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** Reads and writes over `task_alarms`. */ +@Dao +interface TaskAlarmDao { + + /** Every reminder in the store, for one scheduler pass. */ + @Query("SELECT * FROM task_alarms ORDER BY id") + fun all(): List + + @Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id") + fun forTask(taskId: Long): List + + @Insert + fun insert(alarm: TaskAlarmEntity): Long + + @Query("DELETE FROM task_alarms WHERE id = :id") + fun delete(id: Long): Int + + @Query("DELETE FROM task_alarms WHERE task_id = :taskId") + fun deleteForTask(taskId: Long): Int + + /** Set the task's only reminder, or clear every one with `null`. */ + @Transaction + fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) = replaceAllForTask(taskId, listOfNotNull(alarm)) + + /** + * Replace every reminder on the task. The rows that land are always new ones — + * ids are cleared so alarms lifted off another task (forking an occurrence + * copies the master's) insert instead of colliding. + */ + @Transaction + fun replaceAllForTask(taskId: Long, alarms: List) { + deleteForTask(taskId) + alarms.forEach { insert(it.copy(id = 0, taskId = taskId)) } + } + + /** + * Set the task's editable reminder — its last before-due one — or clear it + * with `null`. Start-relative and additional reminders stay as they are. + */ + @Transaction + fun replaceEditable(taskId: Long, alarm: TaskAlarmEntity?) { + forTask(taskId).lastOrNull { it.reference == AlarmReference.DUE }?.let { delete(it.id) } + alarm?.let { insert(it.copy(id = 0, taskId = taskId)) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt new file mode 100644 index 0000000..b7309e9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -0,0 +1,198 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * Reads and writes over `tasks`. + * + * Reads split masters from overrides on purpose: [tasks] returns the rows a + * recurrence expander expands (a non-recurring task is its own single + * occurrence), and [allOverrides] / [overrides] return the + * `RECURRENCE-ID` rows that replace individual occurrences. Nothing here + * expands anything — that is phase 2's job, in Kotlin. + */ +@Dao +interface TaskDao { + + // --- reads ---------------------------------------------------------------- + + /** + * Master (and non-recurring) rows, optionally narrowed to one list. Closed + * tasks — `COMPLETED` and `CANCELLED` — are excluded unless + * [includeCompleted]; tombstones always are. + */ + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.is_deleted = 0 + AND t.master_id IS NULL + AND (:listId IS NULL OR t.list_id = :listId) + AND (:includeCompleted = 1 OR t.status NOT IN (2, 3)) + """ + ) + fun tasks(listId: Long?, includeCompleted: Boolean): List + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.id = :taskId AND t.is_deleted = 0 + """ + ) + fun task(taskId: Long): TaskRow? + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL + """ + ) + fun subtasks(parentTaskId: Long): List + + @Query("SELECT * FROM tasks WHERE id = :taskId") + fun entity(taskId: Long): TaskEntity? + + /** + * Every override, optionally narrowed to one list — read alongside [tasks] so + * expansion can replace the occurrences they override in one pass rather than + * querying per series. + */ + @Query( + """ + SELECT * FROM tasks + WHERE master_id IS NOT NULL AND is_deleted = 0 + AND (:listId IS NULL OR list_id = :listId) + """ + ) + fun allOverrides(listId: Long?): List + + @Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0") + fun overrides(masterId: Long): List + + /** Every override of a series, tombstones included — what a move has to carry. */ + @Query("SELECT * FROM tasks WHERE master_id = :masterId") + fun allOverridesOf(masterId: Long): List + + /** Lists holding a DELETE still owed to a server. */ + @Query("SELECT DISTINCT list_id FROM tasks WHERE is_deleted = 1") + fun listsWithTombstones(): List + + @Query( + "SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0" + ) + fun override(masterId: Long, recurrenceId: Instant?): TaskEntity? + + @Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId") + fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity? + + /** Masters only, tombstones excluded — what an `.ics` export writes. */ + @Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL") + fun exportTasks(listId: Long): List + + @Query("SELECT * FROM tasks WHERE is_dirty = 1") + fun dirty(): List + + /** + * Every row in a list, tombstones included. + * + * Sync needs the tombstones: a row with `is_deleted = 1` is a DELETE the + * server is still owed, and a query that filters them out is a client that + * resurrects deleted tasks on the next download. + */ + @Query("SELECT * FROM tasks WHERE list_id = :listId") + fun allIn(listId: Long): List + + // --- writes --------------------------------------------------------------- + + @Insert + fun insert(task: TaskEntity): Long + + @Update + fun update(task: TaskEntity): Int + + @Query( + """ + UPDATE tasks SET status = :status, percent_complete = :percentComplete, + completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty + WHERE id = :taskId + """ + ) + fun setCompletion( + taskId: Long, + status: TaskStatus, + percentComplete: Int?, + completedAt: Instant?, + lastModified: Instant?, + dirty: Boolean, + ): Int + + /** Hard delete. Used when the row was never on a server. */ + @Query("DELETE FROM tasks WHERE id = :taskId") + fun delete(taskId: Long): Int + + /** + * Tombstone, for a row a server still knows about — and for the whole series + * when [taskId] is a master. + * + * ⚠️ `master_id` cascades on *delete*, and a tombstone deletes nothing, so + * tombstoning the master alone left its overrides live. The resource then + * read as partly deleted: `LocalResource.isDeleted` is `rows.all { … }`, so + * it went to the upload phase instead of the delete phase, no DELETE was + * ever sent, and the master ended `is_deleted = 1, is_dirty = 0` with a + * matching ETag — beyond the reach of every phase. Other clients kept the + * task; here it was gone. + */ + @Query( + """ + UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at + WHERE id = :taskId OR master_id = :taskId + """ + ) + fun markDeleted(taskId: Long, at: Instant?): Int + + /** + * Re-points a subtask at its parent. + * + * Separate from [update] because `RELATED-TO` arrives as a UID and can only + * be resolved to a row id once every row of the sync is present — and + * resolving it must not disturb `is_dirty`, which a whole-entity update + * would. + */ + @Query("UPDATE tasks SET parent_id = :parentId WHERE id = :taskId") + fun setParent(taskId: Long, parentId: Long?): Int + + /** Hard delete of a whole resource's rows — a master and its overrides. */ + @Query("DELETE FROM tasks WHERE id IN (:taskIds)") + fun deleteAll(taskIds: List): Int + + /** + * Records where a resource lives and which version we hold. + * + * Applied to a resource's rows at once, because a master and its + * `RECURRENCE-ID` overrides share one href and one ETag — they are one file + * on the server. A tombstoned override is excluded by the caller: it was + * left out of the body, so the caller deletes the row instead. + * + * `is_dirty` is cleared **explicitly** rather than left to a default: a + * downstream write that leaves the flag set uploads what was just + * downloaded, which is how a sync loop starts. + */ + @Query("UPDATE tasks SET href = :href, etag = :etag, is_dirty = 0 WHERE id IN (:taskIds)") + fun markSynced(taskIds: List, href: String?, etag: String?): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt new file mode 100644 index 0000000..d0729f9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriter.kt @@ -0,0 +1,295 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.toICal +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** Floor to UTC midnight when [allDay], else pass through unchanged. */ +internal fun Instant.forAllDay(allDay: Boolean): Instant = + if (!allDay) this + else Instant.fromEpochMilliseconds( + Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY, + ) + +/** + * Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are + * testable on the JVM without a database. + * + * This is the Room counterpart of + * [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for + * External mode. It is a separate object rather than a shared one because most + * of what that mapper does is work around the provider — clearing `DURATION` + * because the provider validates a merged row, writing `STATUS` both ways + * because the provider auto-completes at 100% but will not reopen below it. Here + * those rules are ours to state directly. + */ +object TaskFormWriter { + + /** A brand-new task. [uid] is minted by the caller and never null. */ + fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity = + apply( + TaskEntity(listId = form.listId, uid = uid, createdAt = now), + form, + now, + tzId, + ) + + /** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */ + fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity { + val percent = form.percentComplete?.coerceIn(0, 100) + val timed = !form.isAllDay && (form.start != null || form.due != null) + return current.copy( + listId = form.listId, + title = form.title.trim(), + description = form.description?.trim()?.ifBlank { null }, + location = form.location?.trim()?.ifBlank { null }, + url = form.url?.trim()?.ifBlank { null }, + priority = form.priority.toICal(), + percentComplete = percent, + status = statusFor(percent, current.status), + completedAt = completedAtFor(percent, current, now), + dtstart = form.start?.forAllDay(form.isAllDay), + due = form.due?.forAllDay(form.isAllDay), + // DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2). + duration = null, + isAllDay = form.isAllDay, + timezone = if (timed) tzId else null, + parentId = form.parentId?.takeIf { it > 0 }, + lastModified = now, + isDirty = true, + ) + } + + /** + * [current] with its rule set to [rrule]. Only ever a master or a plain task: + * an override carries no rule of its own. + */ + fun withRule(current: TaskEntity, rrule: String?): TaskEntity { + val rule = rrule?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } + if (current.masterId != null || rule == current.rrule) return current + // Dropping the rule ends the series, and its exceptions with it. + return if (rule == null) current.copy(rrule = null, rdate = null, exdate = null) + else current.copy(rrule = rule) + } + + /** + * [master] edited through one of its occurrences for the whole series: every + * content field from [form], and the anchor moved by however far the user + * moved that occurrence, so the other occurrences move with it. + */ + fun seriesEdit( + master: TaskEntity, + occurrenceStart: Instant?, + occurrenceDue: Instant?, + form: TaskForm, + now: Instant, + tzId: String, + ): TaskEntity { + val edited = withRule(apply(master, form, now, tzId), form.rrule) + val dtstart = shifted(master.dtstart, occurrenceStart, form.start, form.isAllDay) + val delta = if (dtstart != null && master.dtstart != null) dtstart - master.dtstart else null + return edited.copy( + dtstart = dtstart, + due = shifted(master.due, occurrenceDue, form.due, form.isAllDay), + // The occurrence's completion is its own, not the series'. + status = master.status, + percentComplete = master.percentComplete, + completedAt = master.completedAt, + exdate = if (delta == null || delta == kotlin.time.Duration.ZERO) edited.exdate + else shiftValues(edited.exdate, delta, master.timezone ?: tzId), + ) + } + + /** + * An `EXDATE`/`RDATE` list with every value moved by [delta], each kept in the + * shape it was written in — so a series moved in time keeps its exceptions. + */ + fun shiftValues(values: String?, delta: kotlin.time.Duration, zone: String): String? = + mapValues(values, zone) { it + delta } + + /** The values of an `EXDATE`/`RDATE` list before [cut]. */ + fun valuesBefore(values: String?, cut: Instant, zone: String): String? = + mapValues(values, zone) { it.takeIf { at -> at < cut } } + + /** The values of an `EXDATE`/`RDATE` list at or after [cut]. */ + fun valuesFrom(values: String?, cut: Instant, zone: String): String? = + mapValues(values, zone) { it.takeIf { at -> at >= cut } } + + private fun mapValues(values: String?, zone: String, map: (Instant) -> Instant?): String? { + val list = values?.split(',')?.map(String::trim)?.filter(String::isNotEmpty) ?: return values + return list.mapNotNull { raw -> + val at = readValue(raw, zone) ?: return@mapNotNull raw + map(at)?.let { writeValueLike(raw, it, zone) } + }.joinToString(",").ifEmpty { null } + } + + private fun readValue(raw: String, zone: String): Instant? = runCatching { + val z = java.time.ZoneId.of(zone) + val instant = when { + !raw.contains('T') -> java.time.LocalDate.parse(raw, VALUE_DATE).atStartOfDay(java.time.ZoneOffset.UTC).toInstant() + raw.endsWith("Z") -> java.time.LocalDateTime.parse(raw.removeSuffix("Z"), VALUE_DATE_TIME).toInstant(java.time.ZoneOffset.UTC) + else -> java.time.LocalDateTime.parse(raw, VALUE_DATE_TIME).atZone(z).toInstant() + } + Instant.fromEpochMilliseconds(instant.toEpochMilli()) + }.getOrNull() + + private fun writeValueLike(raw: String, at: Instant, zone: String): String = when { + !raw.contains('T') -> ICalValues.formatDate(at) + raw.endsWith("Z") -> ICalValues.formatDateTime(at, null) + else -> ICalValues.formatDateTime(at, zone) + } + + private val VALUE_DATE = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd") + private val VALUE_DATE_TIME = java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss") + + private fun shifted(anchor: Instant?, was: Instant?, now: Instant?, allDay: Boolean): Instant? = when { + now == null -> null + anchor == null || was == null -> now.forAllDay(allDay) + else -> (anchor + (now - was)).forAllDay(allDay) + } + + /** + * [master] ended just before [occurrence] — "this and following" cuts the + * old series here and the rest continues as a new one. Any `COUNT` goes, as + * `UNTIL` and `COUNT` are mutually exclusive. + */ + fun endedBefore(master: TaskEntity, occurrence: Instant, now: Instant, floatingZone: String): TaskEntity { + val rule = master.rrule ?: return master + return master.copy( + rrule = ruleEndedBefore(rule, occurrence, master.isAllDay, master.timezone, floatingZone), + // UNTIL does not bound RDATEs. + rdate = valuesBefore(master.rdate, occurrence, master.timezone ?: floatingZone), + lastModified = now, + isDirty = true, + ) + } + + /** + * [rule] with its `COUNT`/`UNTIL` replaced by an `UNTIL` just before + * [occurrence], in the form the series' own values take. + */ + fun ruleEndedBefore( + rule: String, + occurrence: Instant, + isAllDay: Boolean, + timezone: String?, + floatingZone: String, + ): String { + val kept = rule.split(';').filter { part -> + val key = part.substringBefore('=').trim().uppercase() + part.isNotBlank() && key != "UNTIL" && key != "COUNT" + } + val until = when { + isAllDay -> ICalValues.formatDate(occurrence - 1.days) + // A floating series takes a floating UNTIL (RFC 5545 §3.3.10). + timezone == null -> ICalValues.formatDateTime(occurrence - 1.seconds, floatingZone) + else -> ICalValues.formatDateTime(occurrence - 1.seconds, null) + } + return (kept + "UNTIL=$until").joinToString(";") + } + + /** [rrule] with its `COUNT` lowered by [spent] occurrences, or unchanged without one. */ + fun remainingCount(rrule: String, spent: Int): String = rrule.split(';').joinToString(";") { part -> + if (part.substringBefore('=').trim().uppercase() != "COUNT") part + else "COUNT=${((part.substringAfter('=').trim().toIntOrNull() ?: 1) - spent).coerceAtLeast(1)}" + } + + /** Cancelled, or back to needs-action; either way no longer complete. */ + fun cancelled(current: TaskEntity, cancelled: Boolean, now: Instant): TaskEntity = current.copy( + status = if (cancelled) TaskStatus.CANCELLED else TaskStatus.NEEDS_ACTION, + percentComplete = if (cancelled) current.percentComplete else null, + completedAt = null, + lastModified = now, + isDirty = true, + ) + + /** The completion triple, for the standalone complete toggle. */ + fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy( + status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION, + percentComplete = if (completed) 100 else null, + completedAt = if (completed) now else null, + lastModified = now, + isDirty = true, + ) + + /** + * [master] with [occurrence] added to its `EXDATE` — how a single occurrence + * of a series is deleted. + * + * ⚠️ Removing the override row is not a deletion. RFC 5545 reads an absent + * `RECURRENCE-ID` component as "not overridden", so the master's `RRULE` + * regenerates that instance; only an `EXDATE` takes it out of the set. + * + * @param floatingZone what a series with no `TZID` means by its wall times — + * the same zone [de.jeanlucmakiola.agendula.domain.recurrence + * .RecurrenceExpander] resolves it in. + */ + fun excepting( + master: TaskEntity, + occurrence: Instant, + now: Instant, + floatingZone: String, + dirty: Boolean, + ): TaskEntity { + val existing = master.exdate?.trim()?.ifEmpty { null } + val value = exceptionValue(master, occurrence, existing, floatingZone) + if (existing != null && value in existing.split(',').map(String::trim)) return master + return master.copy( + exdate = if (existing == null) value else "$existing,$value", + lastModified = now, + isDirty = master.isDirty || dirty, + ) + } + + /** + * ⚠️ Written in the shape the list is already in, not in ours. + * + * `EXDATE` is stored as the bare property value, so a list is a run of one + * value type — and lib-recur parses the whole list or none of it. Appending + * a UTC `…Z` to a run of `DATE`s would drop every exception the series + * already had, this one included. + */ + private fun exceptionValue( + master: TaskEntity, + occurrence: Instant, + existing: String?, + floatingZone: String, + ): String { + val sample = existing?.substringBefore(',')?.trim() + return when { + sample == null -> + if (master.isAllDay) ICalValues.formatDate(occurrence) + else ICalValues.formatDateTime(occurrence, null) + !sample.contains('T') -> ICalValues.formatDate(occurrence) + sample.endsWith("Z") -> ICalValues.formatDateTime(occurrence, null) + // Local wall time: the TZID parameter was dropped on the way in, so + // the series' own zone is what those values mean. + else -> ICalValues.formatDateTime(occurrence, master.timezone ?: floatingZone) + } + } + + /** + * A form carrying no percent leaves status alone — the standalone toggle stays + * authoritative. Otherwise progress and status move together in both + * directions, which is the asymmetry the provider never had: it auto-completed + * at 100% but would not reopen below it, stranding a task "done at 75%". + */ + private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when { + percent == null -> current + percent >= 100 -> TaskStatus.COMPLETED + percent > 0 -> TaskStatus.IN_PROCESS + else -> TaskStatus.NEEDS_ACTION + } + + private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when { + percent == null -> current.completedAt + percent >= 100 -> current.completedAt ?: now + else -> null + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt new file mode 100644 index 0000000..863e507 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -0,0 +1,119 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */ +@Dao +interface TaskListDao { + + /** The home screen's own order; local only, so it never marks the list for sync. */ + @Query("UPDATE task_lists SET sort_order = :order WHERE id = :listId") + fun setSortOrder(listId: Long, order: Int): Int + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + ORDER BY a.display_name, l.sort_order, l.name + """ + ) + fun lists(): List + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + WHERE l.id = :listId + """ + ) + fun list(listId: Long): TaskListRow? + + @Query("SELECT * FROM task_lists WHERE id = :listId") + fun entity(listId: Long): TaskListEntity? + + @Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId") + fun exists(listId: Long): Int + + @Insert + fun insert(list: TaskListEntity): Long + + @Update + fun update(list: TaskListEntity) + + @Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId") + fun setVisible(listId: Long, visible: Boolean) + + /** Attach a list to an account, or detach it with `null`. */ + @Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId") + fun setAccount(listId: Long, accountId: Long?) + + @Query("DELETE FROM task_lists WHERE id = :listId") + fun delete(listId: Long) + + /** + * Every list belonging to [accountId]. + * + * Only for rolling back a half-created account. Removing a *working* account + * must leave its lists alone — `account_id` is `ON DELETE SET NULL` for that + * reason, and deleting an account is not an instruction to destroy the tasks + * it held. + */ + @Query("DELETE FROM task_lists WHERE account_id = :accountId") + fun deleteForAccount(accountId: Long) + + /** + * Refreshes just the ACL flag. + * + * Not [update]: sync holds a snapshot of the row from before it started, and + * writing that whole entity back would revert a rename or recolour the user + * made while it ran. + */ + @Query("UPDATE task_lists SET is_read_only = :readOnly WHERE id = :listId") + fun setReadOnly(listId: Long, readOnly: Boolean) + + /** + * Stores the RFC 6578 cursor. + * + * ⚠️ Written **after** a page's bodies are applied, never before — the RFC's + * own Appendix B has this backwards, and under WorkManager process death + * mid-sync is routine rather than exotic. A token stored ahead of its bodies + * is a permanent hole in the collection. + * + * One column and one statement, so there is nothing to be half-written. + */ + @Query("UPDATE task_lists SET sync_token = :token WHERE id = :listId") + fun setSyncToken(listId: Long, token: String?) + + /** + * Lists a collection URL may be pointed at instead of inserting a new row: + * the ones [accountId] already holds, and the ones a previous removal left + * detached. + * + * ⚠️ Both halves matter, and each is a duplicate bug on its own. + * `remove()` deliberately leaves lists as device-only ones, so re-adding the + * account must re-attach them or the user gets their old "Personal" full of + * tasks beside a freshly synced "Personal" holding the same tasks from the + * server. And re-*authenticating* walks the same picker, so a lookup that + * only saw detached rows would insert a second copy of every list the + * account already syncs — there is no unique index on `href` to catch it. + */ + @Query( + """ + SELECT * FROM task_lists + WHERE href IS NOT NULL AND (account_id IS NULL OR account_id = :accountId) + """ + ) + fun attachable(accountId: Long): List + + /** The synced collections of one account, in the order sync walks them. */ + @Query("SELECT * FROM task_lists WHERE account_id = :accountId AND is_synced = 1 ORDER BY id") + fun syncedForAccount(accountId: Long): List + + /** Every account's synced collections. */ + @Query("SELECT * FROM task_lists WHERE account_id IS NOT NULL AND is_synced = 1") + fun observeSynced(): Flow> +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskMove.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskMove.kt new file mode 100644 index 0000000..973b17a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskMove.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +/** + * Moving a task — a series master and its overrides — to another list. + * + * A calendar resource lives in exactly one collection, so a move is a delete in + * the old one and a create in the new one. Keeping the href would PUT the edit + * back into the collection the task just left. + * + * The rows move and keep their ids, so alarms and anything holding the task id + * stay put. What the old collection is owed is a **tombstone**: a detached copy + * of the master, left behind in the old list with the old href and ETag, which + * the next sync turns into a conditional DELETE like any other. + * + * Pure, so it is testable on the JVM; [RoomTasksDataSource] applies it. + */ +object TaskMove { + + data class Plan( + /** The moved rows, rewritten for the target list. */ + val update: List, + /** Rows that go: a revived tombstone, and overrides already tombstoned. */ + val delete: List, + /** The pending DELETE to insert into the source list, if the server has the task. */ + val tombstone: TaskEntity?, + ) + + /** + * @param edited the master with the form applied; its `listId` is the target. + * @param previous the master as it was, in the source list. + * @param overrides every override of [previous], tombstones included. + * @param sourceSynced whether the source list belongs to an account. + * @param targetTombstone the target list's pending DELETE for this UID. A task + * moved back before the sync ran takes its old resource back instead of + * deleting and recreating it. + */ + fun plan( + edited: TaskEntity, + previous: TaskEntity, + overrides: List, + sourceSynced: Boolean, + targetTombstone: TaskEntity?, + ): Plan { + val revived = targetTombstone?.takeIf { it.isDeleted && it.uid == previous.uid } + val href = revived?.href + val etag = revived?.etag + val (buried, live) = overrides.partition { it.isDeleted } + + val moved = listOf(edited.copy(href = href, etag = etag, isDirty = true)) + + live.map { + it.copy(listId = edited.listId, href = href, etag = etag, isDirty = true) + } + + val tombstone = previous.takeIf { sourceSynced && it.href != null }?.copy( + id = 0, + masterId = null, + parentId = null, + isDeleted = true, + isDirty = true, + ) + + return Plan( + update = moved, + delete = buried.map { it.id } + listOfNotNull(revived?.id), + tombstone = tombstone, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt new file mode 100644 index 0000000..7d6e597 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -0,0 +1,34 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +/** + * Agendula's own task store. Four tables, designed from + * what the app actually reads and writes plus RFC 5545's `VTODO`. + * + * Schemas are exported to `app/schemas/` and committed, so a future version can + * be migration-tested against this one. + */ +@Database( + entities = [ + AccountEntity::class, + TaskListEntity::class, + TaskEntity::class, + TaskAlarmEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class TasksDatabase : RoomDatabase() { + abstract fun taskLists(): TaskListDao + abstract fun tasks(): TaskDao + abstract fun alarms(): TaskAlarmDao + abstract fun accounts(): AccountDao + + companion object { + const val NAME = "agendula-tasks.db" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt new file mode 100644 index 0000000..8803571 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/ExternalImport.kt @@ -0,0 +1,277 @@ +package de.jeanlucmakiola.agendula.data.tasks.transfer + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import de.jeanlucmakiola.agendula.data.di.ExternalStore +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference +import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportTask +import de.jeanlucmakiola.agendula.domain.toICal +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.util.UUID +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** How much one copy moved. */ +data class TransferCounts(val lists: Int, val tasks: Int, val alarms: Int) + +/** The outcome of [ExternalImport.run]. */ +sealed interface TransferResult { + /** The external store holds no list to copy — nothing was written. */ + data object NothingToCopy : TransferResult + + data class Copied(val counts: TransferCounts) : TransferResult + + /** Nothing landed: the transaction rolled back and the source is untouched. */ + data class Failed(val cause: Throwable) : TransferResult +} + +/** + * Copies an external provider's tasks (OpenTasks, tasks.org) into Agendula's own + * Room store, once, when the user asks for it in Settings → Storage. + * + * This is the upgrade path 1.0.0 needs and [de.jeanlucmakiola.agendula.data + * .tasks.legacy.OneShotImport] does not provide: that one moves a *bundled dmfs + * provider's* SQLite file, which no released version ever shipped, so every + * existing install's tasks are in a third-party provider instead. Without this + * the only way onto the new store is to retype everything by hand. + * + * **A copy, not a sync, and deliberately one-directional.** The source is left + * exactly as it is — whatever syncs it (DAVx5 and friends) keeps doing so, and + * the two sets of rows drift apart from the moment this finishes. The reverse + * direction is not offered: writing a *list* into a third-party provider means + * impersonating its sync adapter, and the external store is already the one that + * can sync. + * + * **What does not come across**, because the read seam is + * [TasksDataSource.exportTasks] and that is shaped for iCalendar output: + * per-occurrence `RECURRENCE-ID` overrides (a series arrives as its master plus + * its rule, so an edited single occurrence reverts to the series' own values), + * `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact + * `PRIORITY` digit — [de.jeanlucmakiola.agendula.domain.Priority] buckets 1–4 as + * HIGH, so a `PRIORITY:3` lands as `1`. Nothing the app itself displays is lost. + * + * Task `uid`s *are* preserved, which is what lets these rows be re-attached to a + * CalDAV collection once sync lands rather than duplicating server-side. + */ +@Singleton +class ExternalImport @Inject constructor( + @ExternalStore private val external: Provider, + private val resolver: ProviderResolver, + private val database: TasksDatabase, + private val dataStore: DataStore, + @IoDispatcher private val io: CoroutineDispatcher, +) { + + /** + * Whether a copy has already succeeded. The UI uses this to stop offering the + * action, because a second run would duplicate every row: the rows it writes + * are ordinary tasks afterwards, indistinguishable from ones typed by hand, so + * there is nothing to reconcile a re-run against. + */ + val hasRun: Flow = dataStore.data.map { it[TRANSFER_DONE] ?: false } + + /** + * What a copy would move, for the confirmation to name real numbers — or + * `null` when there is no readable external provider to copy from. + * + * Counts masters, the same rows [run] writes, so the number the user agrees to + * is the number they get. + */ + suspend fun preview(): TransferCounts? = withContext(io) { + val provider = resolver.resolveExternal() ?: return@withContext null + if (!resolver.hasPermission(provider)) return@withContext null + runCatching { + val source = external.get() + val lists = source.taskLists() + val tasks = lists.sumOf { source.exportTasks(it.id).size } + TransferCounts(lists = lists.size, tasks = tasks, alarms = source.reminders().values.sumOf { it.size }) + }.getOrNull() + } + + /** + * Reads the external store, then writes everything into Room in **one + * transaction with verified counts** — the same discipline as the legacy + * import, for the same reason: a partial copy is worse than none, because the + * user cannot tell which half is missing. + * + * The flag is written only after the transaction commits. A crash in between + * leaves the rows in place and the action still on offer, which duplicates on + * a second run — the lesser of the two evils, since the alternative is + * claiming a copy that never happened. + */ + suspend fun run(): TransferResult = withContext(io) { + val snapshot = runCatching { read() } + .getOrElse { return@withContext TransferResult.Failed(it) } + if (snapshot.lists.isEmpty()) return@withContext TransferResult.NothingToCopy + val counts = runCatching { + database.runInTransaction { + val baseline = tableCounts() + val written = write(snapshot) + verify(written, baseline) + written + } + }.getOrElse { return@withContext TransferResult.Failed(it) } + markDone() + TransferResult.Copied(counts) + } + + // --- reading the external provider ---------------------------------------- + + private fun read(): Snapshot { + val source = external.get() + val lists = source.taskLists() + return Snapshot( + lists = lists, + tasksByList = lists.associate { it.id to source.exportTasks(it.id) }, + alarms = source.reminders(), + ) + } + + // --- writing into Room ---------------------------------------------------- + + /** + * Provider row ids are the source's own, and Room mints its own on insert, so + * `parentId` is remapped through the ids the inserts hand back. Tasks go in + * with their parent cleared and a second pass sets it, because a parent may + * sort after its child. + * + * Every list arrives as **device-only** (`account_id IS NULL`), including one + * that sat under a CalDAV account in the provider: the account belongs to the + * sync app, not to us, and claiming it here would suggest Agendula syncs it. + */ + private fun write(snapshot: Snapshot): TransferCounts { + val listDao = database.taskLists() + val taskDao = database.tasks() + val alarmDao = database.alarms() + + val listIds = snapshot.lists.associate { list -> + list.id to listDao.insert( + TaskListEntity( + name = list.name, + color = list.color, + accountId = null, + isVisible = list.isVisible, + isSynced = false, + owner = list.owner, + ), + ) + } + + val taskIds = mutableMapOf() + val inserted = mutableListOf>() + // A `RECURRENCE-ID` override reaches us as another master-shaped row + // sharing its series' UID, which the unique index would reject and take + // the whole copy down with it. A fresh uid costs that row nothing it + // still has. + val seen = mutableSetOf>() + + for ((sourceListId, tasks) in snapshot.tasksByList) { + val listId = listIds[sourceListId] ?: continue + for (task in tasks) { + val uid = task.uid?.takeIf { seen.add(listId to it) } ?: UUID.randomUUID().toString() + val entity = task.toEntity(listId, uid) + val newId = taskDao.insert(entity) + taskIds[task.taskId] = newId + inserted += task to entity.copy(id = newId) + } + } + + for ((task, entity) in inserted) { + val parentId = task.parentId?.let(taskIds::get) ?: continue + taskDao.update(entity.copy(parentId = parentId)) + } + + var alarmCount = 0 + for ((sourceTaskId, reminders) in snapshot.alarms) { + val taskId = taskIds[sourceTaskId] ?: continue + reminders.forEach { alarmDao.insert(it.toEntity(taskId)) } + alarmCount += reminders.size + } + + return TransferCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount) + } + + private fun ExportTask.toEntity(listId: Long, uid: String) = TaskEntity( + listId = listId, + uid = uid, + title = title, + description = description, + location = location, + url = url, + status = status, + percentComplete = percentComplete, + completedAt = completedAt, + priority = priority.toICal(), + dtstart = start, + due = due, + isAllDay = isAllDay, + rrule = rrule, + rdate = rdate, + createdAt = created, + lastModified = lastModified, + ) + + private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity( + taskId = taskId, + minutesBefore = minutesBefore, + reference = if (fromStart) AlarmReference.START else AlarmReference.DUE, + ) + + private fun verify(written: TransferCounts, before: TransferCounts) { + val after = tableCounts() + check(after.lists - before.lists == written.lists) { + "list count mismatch: ${after.lists - before.lists} != ${written.lists}" + } + check(after.tasks - before.tasks == written.tasks) { + "task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}" + } + check(after.alarms - before.alarms == written.alarms) { + "alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}" + } + } + + private fun tableCounts() = TransferCounts( + lists = count("task_lists"), + tasks = count("tasks"), + alarms = count("task_alarms"), + ) + + private fun count(table: String): Int = + database.query("SELECT COUNT(*) FROM $table", null).use { + if (it.moveToFirst()) it.getInt(0) else 0 + } + + private suspend fun markDone() { + dataStore.edit { it[TRANSFER_DONE] = true } + } + + /** Clears the guard so the action is offered again. Test and support hook. */ + suspend fun clearCompletion() { + dataStore.edit { it.remove(TRANSFER_DONE) } + } + + private class Snapshot( + val lists: List, + val tasksByList: Map>, + val alarms: Map>, + ) + + private companion object { + val TRANSFER_DONE = booleanPreferencesKey("external_copy_done") + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt new file mode 100644 index 0000000..41c4929 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImport.kt @@ -0,0 +1,280 @@ +package de.jeanlucmakiola.agendula.data.tasks.transfer + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource +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 de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.UUID +import java.util.zip.ZipInputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** One `.ics` document, with the name it arrived under (for a new list's name). */ +data class IcsSource(val name: String?, val text: String) + +/** One task to import: a series master (or a lone task) and its `RECURRENCE-ID` overrides. */ +class ImportedTask( + val uid: String, + val master: VTodoMapper.Mapped, + val overrides: List, +) + +/** What a picked file holds, before anything is written. */ +data class ParsedIcs( + val tasks: List, + /** `VEVENT`s and `VJOURNAL`s — a task app has nowhere to put them. */ + val skippedComponents: Int, + /** Documents (or zip entries) that did not parse as iCalendar at all. */ + val unreadableDocuments: Int, + /** Copies of a UID the file itself repeats, e.g. the same task in two zipped lists. */ + val duplicatesInFile: Int, + /** `X-WR-CALNAME` when every calendar agrees on one, else the file name. */ + val suggestedListName: String?, + /** The picked file's own name, for the screen to show. */ + val fileName: String? = null, +) + +/** Where the tasks go. */ +sealed interface ImportTarget { + data class Existing(val listId: Long) : ImportTarget + + /** A new device-only list. */ + data class NewList(val name: String, val color: Int) : ImportTarget +} + +data class IcsImportResult( + val listId: Long, + val imported: Int, + /** Tasks whose UID the target list already held. */ + val skippedExisting: Int, +) + +/** The database, as the import needs it — a seam so the logic is testable on the JVM. */ +interface IcsImportStore { + fun transaction(block: () -> T): T + fun createList(name: String, color: Int): Long + fun hasUid(listId: Long, uid: String): Boolean + fun insert(row: TaskEntity): Long + fun masterByUid(listId: Long, uid: String): TaskEntity? + fun setParent(taskId: Long, parentId: Long?) +} + +/** + * Imports `.ics` files — or a zip of them, as the export writes — into the own + * Room store. + * + * Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync + * uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every + * property the mapper does not claim (`VALARM`s included) survive as they would + * on a download. `VALARM`s stay in the residue: like sync, the import does not + * turn them into local reminders. + * + * Tasks whose UID the target list already holds are skipped, so importing the + * same file twice is harmless. + */ +@Singleton +class IcsImport @Inject constructor( + @ApplicationContext private val context: Context, + private val database: TasksDatabase, + @IoDispatcher private val io: CoroutineDispatcher, +) { + + /** Reads and parses [uri]; null when it cannot be read at all. */ + suspend fun read(uri: Uri): ParsedIcs? = withContext(io) { + runCatching { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readCapped() } + ?: return@runCatching null + val name = displayName(uri) + parse(sourcesOf(name, bytes), name).copy(fileName = name) + }.getOrNull() + } + + suspend fun import(parsed: ParsedIcs, target: ImportTarget): IcsImportResult = withContext(io) { + write(parsed, target, RoomIcsImportStore(database)) + } + + private fun displayName(uri: Uri): String? = runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + }.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/') + + companion object { + + /** Refuses anything larger — a task export is kilobytes, not tens of megabytes. */ + const val MAX_BYTES = 32L * 1024 * 1024 + + /** Splits a zip into its `.ics` entries; anything else is one document. */ + fun sourcesOf(name: String?, bytes: ByteArray): List { + if (!isZip(bytes)) return listOf(IcsSource(name, decode(bytes))) + val sources = mutableListOf() + ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.endsWith(".ics", ignoreCase = true)) { + sources += IcsSource(entry.name.substringAfterLast('/'), decode(zip.readCapped())) + } + entry = zip.nextEntry + } + } + return sources + } + + /** [fileName] names a multi-document zip when its calendars carry no name. */ + fun parse(sources: List, fileName: String? = null): ParsedIcs { + var unreadable = 0 + var skipped = 0 + var duplicates = 0 + val calendarNames = mutableListOf() + val groups = LinkedHashMap>() + + for (source in sources) { + val calendars = runCatching { CalendarResource.parse(source.text) }.getOrNull() + if (calendars.isNullOrEmpty()) { + unreadable++ + continue + } + calendars.forEach { calendar -> + calendarNames += calendar.property("X-WR-CALNAME")?.value?.trim()?.takeIf { it.isNotEmpty() } + skipped += calendar.components("VEVENT").size + calendar.components("VJOURNAL").size + } + CalendarResource.todosIn(calendars).forEach { vtodo -> + val mapped = VTodoMapper.read(vtodo).let { + if (it.uidWasMissing) { + it.copy(entity = it.entity.copy(uid = UUID.randomUUID().toString())) + } else { + it + } + } + val group = groups.getOrPut(mapped.entity.uid) { mutableListOf() } + // The same component twice — within one document, or across + // two zipped lists — lands once. + if (group.none { it.entity.recurrenceId == mapped.entity.recurrenceId }) { + group += mapped + } else if (mapped.entity.recurrenceId == null) { + duplicates++ + } + } + } + + val tasks = groups.map { (uid, mapped) -> + val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first() + ImportedTask(uid, master, mapped.filter { it !== master }) + } + val sourceName = sources.singleOrNull()?.name ?: fileName + return ParsedIcs( + tasks = tasks, + skippedComponents = skipped, + unreadableDocuments = unreadable, + duplicatesInFile = duplicates, + suggestedListName = calendarNames.distinct().singleOrNull() + ?: sourceName?.let(::nameFromFile), + ) + } + + /** + * Writes [parsed] into [target] in one transaction: a failure leaves the + * store as it was. + */ + fun write(parsed: ParsedIcs, target: ImportTarget, store: IcsImportStore): IcsImportResult = + store.transaction { + val listId = when (target) { + is ImportTarget.Existing -> target.listId + is ImportTarget.NewList -> store.createList(target.name.trim(), target.color) + } + var imported = 0 + var skipped = 0 + val parents = mutableListOf>() + for (task in parsed.tasks) { + if (store.hasUid(listId, task.uid)) { + skipped++ + continue + } + val masterId = store.insert(task.master.entity.forImport(listId, masterId = null)) + task.overrides.forEach { store.insert(it.entity.forImport(listId, masterId)) } + task.master.parentUid?.let { parents += masterId to it } + imported++ + } + // After every insert: a parent may come later in the file than its child. + parents.forEach { (childId, parentUid) -> + val parent = store.masterByUid(listId, parentUid) + if (parent != null && parent.id != childId) store.setParent(childId, parent.id) + } + IcsImportResult(listId, imported, skipped) + } + + private fun TaskEntity.forImport(listId: Long, masterId: Long?) = copy( + id = 0L, + listId = listId, + masterId = masterId, + parentId = null, + href = null, + etag = null, + // A list attached to an account uploads what lands in it. + isDirty = true, + isDeleted = false, + ) + + /** "Groceries-3.ics" → "Groceries-3"; the export's id suffix is kept, it may be a real name. */ + internal fun nameFromFile(fileName: String): String? = + fileName.substringAfterLast('/') + .replace(Regex("\\.(ics|ical|ifb|icalendar|zip)$", RegexOption.IGNORE_CASE), "") + .trim() + .takeIf { it.isNotEmpty() } + + private fun isZip(bytes: ByteArray): Boolean = + bytes.size >= 4 && bytes[0] == 'P'.code.toByte() && bytes[1] == 'K'.code.toByte() && + bytes[2] == 3.toByte() && bytes[3] == 4.toByte() + + private fun decode(bytes: ByteArray): String = + bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF") + + private fun InputStream.readCapped(): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + val read = read(buffer) + if (read < 0) break + total += read + check(total <= MAX_BYTES) { "file too large" } + out.write(buffer, 0, read) + } + return out.toByteArray() + } + } +} + +class RoomIcsImportStore(private val database: TasksDatabase) : IcsImportStore { + + override fun transaction(block: () -> T): T = database.runInTransaction { block() } + + override fun createList(name: String, color: Int): Long = + database.taskLists().insert(TaskListEntity(name = name, color = color)) + + // Tombstones and overrides count: either would collide on the unique index + // or resurrect something the user deleted. + override fun hasUid(listId: Long, uid: String): Boolean = + database.query( + "SELECT 1 FROM tasks WHERE list_id = ? AND uid = ? LIMIT 1", + arrayOf(listId, uid), + ).use { it.moveToFirst() } + + override fun insert(row: TaskEntity): Long = database.tasks().insert(row) + + override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid) + + override fun setParent(taskId: Long, parentId: Long?) { + database.tasks().setParent(taskId, parentId) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt new file mode 100644 index 0000000..e86fca0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/AllDayTime.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.agendula.domain + +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.time.Instant + +/** + * All-day tasks are date-only in iCalendar. OpenTasks reads them back through + * `DateTime.toAllDay()`, which discards the time-of-day and resolves the + * remaining date against UTC — so the storage convention is **UTC midnight of + * the intended calendar date, with a null timezone**. Timed tasks, by contrast, + * are ordinary instants rendered in the device's zone. + * + * These two conventions disagree about which day a given instant is, which is + * why every all-day value needs an explicit conversion rather than a raw + * `Instant` passed straight through. + */ + +/** UTC midnight of [date] — the storage form for an all-day value. */ +fun allDayInstantOf(date: java.time.LocalDate): Instant = + Instant.fromEpochMilliseconds(date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()) + +/** + * The calendar date this instant denotes: read in UTC for [allDay] values, + * in [zone] for timed ones. + */ +fun Instant.calendarDate(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalDate = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()) + .atZone(if (allDay) ZoneOffset.UTC else zone) + .toLocalDate() + +/** + * Move an instant across the two conventions when the all-day switch flips, so + * the day the user is looking at stays put. Without this, toggling all-day off + * turns a UTC-midnight value into "02:00" in Berlin (or the previous day, 19:00, + * in New York) — reading to the user as "the time reset itself". + */ +fun Instant.rebasedForAllDay(allDay: Boolean, zone: ZoneId = ZoneId.systemDefault()): Instant = + if (allDay) allDayInstantOf(calendarDate(allDay = false, zone = zone)) + else Instant.fromEpochMilliseconds( + calendarDate(allDay = true).atStartOfDay(zone).toInstant().toEpochMilli(), + ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/HtmlText.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/HtmlText.kt new file mode 100644 index 0000000..ef21c35 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/HtmlText.kt @@ -0,0 +1,56 @@ +package de.jeanlucmakiola.agendula.domain + +// A tag name ends at whitespace, `>` or `/`; `` is not a tag. +private const val TAG_END = "(?=[\\s>/])" + +// Markup, not prose with a `<` in it: a known element closed, or a line break. +private val htmlTag = Regex( + "|<(br|hr)\\s*/?>", + RegexOption.IGNORE_CASE, +) +private val lineBreak = Regex("|]*>|", RegexOption.IGNORE_CASE) +private val listItem = Regex("]*>", RegexOption.IGNORE_CASE) +private val anchor = Regex("]*href\\s*=\\s*[\"']([^\"']*)[\"'][^>]*>(.*?)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) +private val hidden = Regex("<(head|style|script)$TAG_END.*?", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) +private val anyTag = Regex("]*>|", RegexOption.DOT_MATCHES_ALL) +private val entity = Regex("&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);") +private val namedEntities = mapOf("amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'", "nbsp" to " ") + +/** Whether this looks like markup another client wrote, rather than text that merely contains a `<`. */ +fun String.looksLikeHtml(): Boolean = htmlTag.containsMatchIn(this) + +/** + * The text a reader would see in this HTML: block ends and `
` become line + * breaks, list items bullets, and a link whose text is not its address keeps the + * address after it. + */ +fun String.htmlToPlainText(): String { + // Markup that breaks its own lines means its newlines are only source layout. + val structured = lineBreak.containsMatchIn(this) || listItem.containsMatchIn(this) + val text = replace("\r\n", "\n").let { if (structured) it.replace('\n', ' ') else it } + .replace(hidden, "") + .replace(anchor) { m -> + val href = m.groupValues[1].removePrefix("mailto:") + val label = m.groupValues[2].replace(anyTag, "").trim() + if (label.isEmpty() || label == href) href else "$label ($href)" + } + .replace(listItem, "\n• ") + .replace(lineBreak, "\n") + .replace(anyTag, "") + .replace(entity) { m -> decodeEntity(m.groupValues[1]) ?: m.value } + return text.lines().joinToString("\n") { it.replace(Regex("[ \\t\\u00A0]+"), " ").trim() } + .replace(Regex("\n{3,}"), "\n\n") + .trim() +} + +private fun decodeEntity(name: String): String? = when { + name.startsWith("#x", ignoreCase = true) -> name.drop(2).toIntOrNull(16)?.let(::codePoint) + name.startsWith("#") -> name.drop(1).toIntOrNull()?.let(::codePoint) + else -> namedEntities[name.lowercase()] +} + +private fun codePoint(value: Int): String? = + if (Character.isValidCodePoint(value)) String(Character.toChars(value)) else null + +/** The description as it should be read: HTML flattened to text, anything else as written. */ +fun String.readableDescription(): String = if (looksLikeHtml()) htmlToPlainText() else this diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt index 47e8e68..05b70db 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/Models.kt @@ -1,6 +1,5 @@ package de.jeanlucmakiola.agendula.domain -import de.jeanlucmakiola.agendula.data.tasks.TasksContract import kotlin.time.Instant /** A task list (the `tasklists` table). Lists group under their account. */ @@ -13,9 +12,27 @@ data class TaskList( val isSynced: Boolean, val isVisible: Boolean, val owner: String?, + /** + * The CalDAV account this list belongs to, or null for a device-only one. + * + * ⚠️ Distinct from [accountName], which is a label and is filled in with a + * placeholder for a device-only list. This is what a write has to be aimed + * at, so it has to be able to say "nowhere". + */ + val accountId: Long? = null, + /** + * A share we may read and not write. Renaming or deleting it is the owner's + * to do, and attempting either answers 403. + */ + val isReadOnly: Boolean = false, ) { /** A device-only list Agendula (or another app) created locally, not synced. */ - val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE + val isLocal: Boolean + get() = accountType == LocalAccount.TYPE || accountType == LocalAccount.DMFS_TYPE + + /** Whether a task may be created in, moved to, or edited within this list. */ + val acceptsWrites: Boolean + get() = !isReadOnly } enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } @@ -24,11 +41,11 @@ enum class TaskStatus { NEEDS_ACTION, IN_PROCESS, COMPLETED, CANCELLED } enum class Priority { NONE, LOW, MEDIUM, HIGH } /** - * A task occurrence as read from the `instances` view. [id] is the instance row - * id; [taskId] is the underlying `tasks._id` and the stable target for edits. + * One occurrence of a task. [taskId] is the underlying task row and the stable + * target for edits and navigation; [occurrenceStart] distinguishes occurrences of + * the same series. */ data class Task( - val id: Long, val taskId: Long, val listId: Long, val title: String, @@ -48,7 +65,22 @@ data class Task( val listName: String?, val accountName: String?, val parentId: Long?, + /** + * This row carries a recurrence rule, so it is one occurrence of a series and + * [start]/[due] are that occurrence's resolved times — *not* the master's + * anchor. Edits go through + * [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance], which + * forks a `RECURRENCE-ID` override instead of re-anchoring the series. + */ val isRecurring: Boolean, + /** + * This occurrence's `RECURRENCE-ID` anchor — what identifies it within its + * series — or `null` when the task does not recur. Together with [taskId] it + * is a stable, collision-free identity for an occurrence, which is what list + * keys and [de.jeanlucmakiola.agendula.data.tasks.TasksDataSource.updateInstance] + * address it by. + */ + val occurrenceStart: Instant? = null, val distanceFromCurrent: Int?, val created: Instant?, val lastModified: Instant?, @@ -59,12 +91,37 @@ data class Task( */ val subtaskTotal: Int = 0, val subtaskDone: Int = 0, + /** + * The series master's row id when this is an occurrence of a recurring task — + * a generated occurrence *or* a `RECURRENCE-ID` override — else `null`. + * Series-wide writes ("this and following", "all") address this row. + */ + val seriesId: Long? = null, + /** The series' `RRULE`, for display and for prefilling the editor. */ + val recurrenceRule: String? = null, + /** + * The task's list is a read-only share. An edit here would never reach the + * server, and the dirty row would then block every later download of it. + */ + val isReadOnly: Boolean = false, ) { + /** One occurrence of a series, whether generated or overridden. */ + val isOccurrence: Boolean get() = seriesId != null && occurrenceStart != null + val isCompleted: Boolean get() = status == TaskStatus.COMPLETED val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED val isSubtask: Boolean get() = parentId != null && parentId > 0 /** The task's own colour if set, else the list colour. */ val effectiveColor: Int get() = taskColor ?: listColor + + /** + * Stable identity for a lazy-list key. Two occurrences of one series can show + * up in the same list, so [taskId] alone is not unique — and folding + * `(taskId, occurrenceStart)` into a Long could collide, which as a Compose + * key is a visible bug. + */ + val occurrenceKey: String + get() = if (occurrenceStart == null) "$taskId" else "$taskId@${occurrenceStart.toEpochMilliseconds()}" } /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */ @@ -85,22 +142,22 @@ fun priorityFromICal(value: Int?): Priority = when { /** Representative iCalendar priority for a bucket (1 high, 5 medium, 9 low). */ fun Priority.toICal(): Int = when (this) { - Priority.NONE -> TasksContract.PRIORITY_NONE + Priority.NONE -> PRIORITY_NONE Priority.HIGH -> 1 Priority.MEDIUM -> 5 Priority.LOW -> 9 } fun statusFromInt(value: Int?): TaskStatus = when (value) { - TasksContract.STATUS_IN_PROCESS -> TaskStatus.IN_PROCESS - TasksContract.STATUS_COMPLETED -> TaskStatus.COMPLETED - TasksContract.STATUS_CANCELLED -> TaskStatus.CANCELLED + ICalStatus.IN_PROCESS -> TaskStatus.IN_PROCESS + ICalStatus.COMPLETED -> TaskStatus.COMPLETED + ICalStatus.CANCELLED -> TaskStatus.CANCELLED else -> TaskStatus.NEEDS_ACTION } fun TaskStatus.toInt(): Int = when (this) { - TaskStatus.NEEDS_ACTION -> TasksContract.STATUS_NEEDS_ACTION - TaskStatus.IN_PROCESS -> TasksContract.STATUS_IN_PROCESS - TaskStatus.COMPLETED -> TasksContract.STATUS_COMPLETED - TaskStatus.CANCELLED -> TasksContract.STATUS_CANCELLED + TaskStatus.NEEDS_ACTION -> ICalStatus.NEEDS_ACTION + TaskStatus.IN_PROCESS -> ICalStatus.IN_PROCESS + TaskStatus.COMPLETED -> ICalStatus.COMPLETED + TaskStatus.CANCELLED -> ICalStatus.CANCELLED } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/SeriesCollapse.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SeriesCollapse.kt new file mode 100644 index 0000000..2c7abe3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/SeriesCollapse.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.agendula.domain + +/** + * What a task list shows of a recurring series: one open occurrence — the most + * recent one that is still due, else the next — plus the occurrences someone + * actually closed. Every other generated occurrence stays out; a daily series + * would otherwise fill Overdue with a year of rows and Upcoming with two more. + */ +object SeriesCollapse { + + fun visible(tasks: List): List { + val (series, plain) = tasks.partition { it.isOccurrence } + val kept = series.groupBy { it.seriesId }.values.flatMap(::pick) + return plain + kept + } + + private fun pick(occurrences: List): List { + val ordered = occurrences.sortedBy { it.occurrenceStart } + val open = ordered.filter { !it.isClosed } + val current = open.firstOrNull { (it.distanceFromCurrent ?: 0) >= -1 } ?: open.lastOrNull() + // A closed override is a real completion worth listing; a closed generated + // occurrence only means the whole series is closed, which one row says. + val closed = ordered.filter { it.isClosed && it.taskId != it.seriesId } + val seriesClosed = open.isEmpty() && closed.isEmpty() + val fallback = if (seriesClosed) ordered.firstOrNull { it.distanceFromCurrent == 0 } ?: ordered.lastOrNull() else null + return closed + listOfNotNull(current ?: fallback) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt new file mode 100644 index 0000000..fd3e8c3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskConstants.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.domain + +/** + * iCalendar `STATUS` values for a `VTODO`, as integers. + * + * These live in `domain` rather than being read out of a provider contract: the + * numbering is Agendula's own storage encoding as much as it is dmfs's, and the + * domain layer must not depend on the data layer to map its own enums. + */ +object ICalStatus { + const val NEEDS_ACTION = 0 + const val IN_PROCESS = 1 + const val COMPLETED = 2 + const val CANCELLED = 3 +} + +/** Priority 0 means "no priority"; 1 is highest, 9 lowest (RFC 5545 §3.8.1.9). */ +const val PRIORITY_NONE = 0 + +/** How a device-only list identifies its (non-existent) account. */ +object LocalAccount { + /** Shown as the section header above device-only lists. */ + const val NAME = "Local" + + /** What Agendula's own store reports for a list with no account. */ + const val TYPE = "local" + + /** What a dmfs-derived provider reports in External mode. */ + const val DMFS_TYPE = "org.dmfs.account.LOCAL" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt index 3d78fc2..c0c1c69 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskForm.kt @@ -18,20 +18,25 @@ data class TaskForm( val parentId: Long? = null, /** 0..100 progress; `null` leaves it unset (cleared). */ val percentComplete: Int? = null, - /** Minutes before [due] to fire a reminder; `null` = no reminder. */ - val reminderMinutesBeforeDue: Int? = null, + /** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */ + val reminders: List = emptyList(), + val location: String? = null, + val url: String? = null, + /** The series' `RRULE` value; `null` = does not repeat. Ignored for a single-occurrence edit. */ + val rrule: String? = null, ) { fun validate(): Set = buildSet { if (title.isBlank()) add(TaskFormError.BLANK_TITLE) if (listId <= 0L) add(TaskFormError.NO_LIST) if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START) - if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE) + if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE) + if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE) } val isValid: Boolean get() = validate().isEmpty() } -enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE } +enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE } /** * Optional edit-form sections that can be shown or tucked behind "More fields" @@ -39,7 +44,7 @@ enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITH * a setting; the rest unfold on demand. Declaring these as an enum keeps the * disclosure generic, so adding a field later is one entry plus its card. */ -enum class TaskFormField { Description, Priority, Progress, Parent, Reminder } +enum class TaskFormField { Description, Recurrence, Priority, Progress, Parent, Reminder, Location, Url } /** The optional fields that already carry a value — auto-revealed when editing. */ fun TaskForm.populatedFields(): Set = buildSet { @@ -47,5 +52,8 @@ fun TaskForm.populatedFields(): Set = buildSet { if (priority != Priority.NONE) add(TaskFormField.Priority) if ((percentComplete ?: 0) > 0) add(TaskFormField.Progress) if (parentId != null && parentId > 0) add(TaskFormField.Parent) - if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder) + if (reminders.isNotEmpty()) add(TaskFormField.Reminder) + if (rrule != null) add(TaskFormField.Recurrence) + if (!location.isNullOrBlank()) add(TaskFormField.Location) + if (!url.isNullOrBlank()) add(TaskFormField.Url) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSorting.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSorting.kt index 82055d4..69bf990 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSorting.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/TaskSorting.kt @@ -11,3 +11,22 @@ object TaskSorting { { it.title.lowercase() }, ) } + +/** The orders a user can pick for a task list. Open tasks always come before closed ones. */ +enum class TaskSortOrder { DUE, PRIORITY, TITLE, CREATED } + +fun TaskSortOrder.comparator(): Comparator = when (this) { + TaskSortOrder.DUE -> TaskSorting.DEFAULT + TaskSortOrder.PRIORITY -> compareBy( + { it.isCompleted }, + { -it.priority.ordinal }, + { it.due?.toEpochMilliseconds() ?: Long.MAX_VALUE }, + { it.title.lowercase() }, + ) + TaskSortOrder.TITLE -> compareBy({ it.isCompleted }, { it.title.lowercase() }) + TaskSortOrder.CREATED -> compareBy( + { it.isCompleted }, + { -(it.created?.toEpochMilliseconds() ?: 0L) }, + { it.title.lowercase() }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt new file mode 100644 index 0000000..153a34c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ExportModels.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.agendula.domain.export + +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * One task as it goes out to iCalendar — a **master** task, not an occurrence. + * + * Deliberately not [de.jeanlucmakiola.agendula.domain.Task]. That model is read + * from the `instances` view, where a recurring task appears once per occurrence + * with resolved times and no rule; exporting from it would write the same task + * fifty times and lose the RRULE that generated them. Export reads the `tasks` + * table instead, and needs two fields the UI never asks for ([uid], [rrule]). + */ +data class ExportTask( + /** `tasks._id` — the fallback identity when [uid] is absent. */ + val taskId: Long, + /** + * The iCalendar UID, or `null` for a task created on this device and never + * synced. The dmfs provider only lets a *sync adapter* assign one, so in Local + * mode this is null for everything — see [ICalendarWriter.uidFor], which + * synthesises a stable substitute rather than emitting a VTODO with no UID. + */ + val uid: String?, + val title: String, + val description: String?, + val location: String?, + val url: String?, + val priority: Priority, + val status: TaskStatus, + val percentComplete: Int?, + val start: Instant?, + val due: Instant?, + val isAllDay: Boolean, + val completedAt: Instant?, + val created: Instant?, + val lastModified: Instant?, + /** Raw `RRULE` value as stored, without the `RRULE:` name. Null when non-recurring. */ + val rrule: String?, + /** Raw `RDATE` value as stored. Null when absent. */ + val rdate: String?, + /** `tasks._id` of the parent, for `RELATED-TO;RELTYPE=PARENT`. */ + val parentId: Long?, +) + +/** A task list and everything in it, ready to become one `.ics` document. */ +data class ExportList( + val listId: Long, + val name: String, + val accountName: String, + val tasks: List, +) + +/** A single file the export produced: [fileName] and its finished bytes. */ +data class ExportDocument( + val fileName: String, + val content: ByteArray, +) { + // ByteArray gets identity equals/hashCode, which makes this data class lie. + override fun equals(other: Any?): Boolean = + this === other || + (other is ExportDocument && fileName == other.fileName && content.contentEquals(other.content)) + + override fun hashCode(): Int = 31 * fileName.hashCode() + content.contentHashCode() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt new file mode 100644 index 0000000..e2700a4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriter.kt @@ -0,0 +1,166 @@ +package de.jeanlucmakiola.agendula.domain.export + +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.calendarDate +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import de.jeanlucmakiola.agendula.domain.ical.ICalValues +import de.jeanlucmakiola.agendula.domain.toICal +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.time.Instant + +/** + * Writes a task list as an RFC 5545 `VCALENDAR` of `VTODO` components. + * + * Pure Kotlin and deliberately free of any Android type, so the format — the part + * that decides whether an exported backup can actually be read again — is + * unit-testable on the JVM. Serialising tasks is task-domain and stays here; the + * SAF/file plumbing that carries the bytes out is not, and lives in the data + * layer (and is a floret-kit candidate). + * + * **Times are always written in UTC.** Emitting a local `TZID` would oblige us to + * also emit a matching `VTIMEZONE` component with its full transition rules, and + * a `TZID` referencing an absent definition is what actually breaks importers. UTC + * is unambiguous and universally accepted, so the exported instant is exact even + * though the original wall-clock zone is not carried. All-day values keep their + * `VALUE=DATE` form and stay date-only, which is the only representation that + * survives a timezone change intact. + */ +object ICalendarWriter { + + private const val PRODUCT_ID = "-//Jean-Luc Makiola//Agendula//EN" + + private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd") + private val DATE_TIME_UTC = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + + /** Serialises [list] to a complete `.ics` document. */ + fun write(list: ExportList): String = buildString { + line("BEGIN:VCALENDAR") + line("VERSION:2.0") + line("PRODID:$PRODUCT_ID") + line("CALSCALE:GREGORIAN") + // Non-standard but near-universally understood, and the only way the list's + // name survives into a calendar app. Importers that don't know it skip it. + property("X-WR-CALNAME", list.name) + + // Parents must be addressable by UID, and a subtask may appear before its + // parent in the list, so resolve every id up front. + val uidsById = list.tasks.associate { it.taskId to uidFor(it) } + list.tasks.forEach { task -> writeTask(task, uidsById) } + + line("END:VCALENDAR") + } + + /** + * The UID to write for [task]. + * + * Local tasks have none, because nothing assigns one: the provider never + * generates a `_uid` itself, and our write path does not set it either, so in + * Local mode every task arrives here with `uid == null`. + * + * Note this is a gap we leave open, not one the provider imposes. + * `processors/tasks/Validating.java:92-96` restricts `_uid` to sync adapters + * on *update* only; `insert` does not check it, so any caller may assign a UID + * at creation. Doing that would be strictly better than synthesising here — + * a real UID minted at + * creation is what lets a local task later be pushed to CalDAV without + * duplicating. + * + * Until then: a VTODO without a UID is invalid and, worse, un-mergeable — + * re-importing a backup would duplicate every task instead of matching it. So + * we synthesise one from the row id, which is stable for as long as the row + * is, and tag it with our own domain so a synthesised UID is recognisable as + * such. + */ + fun uidFor(task: ExportTask): String = + task.uid?.takeIf { it.isNotBlank() } ?: "agendula-${task.taskId}@jeanlucmakiola.de" + + private fun StringBuilder.writeTask(task: ExportTask, uidsById: Map) { + line("BEGIN:VTODO") + property("UID", uidFor(task)) + // DTSTAMP is mandatory. It means "when this representation was written", + // which for an export is now — not the task's own timestamps. + property("DTSTAMP", formatUtc(Instant.fromEpochMilliseconds(System.currentTimeMillis()))) + property("SUMMARY", task.title) + + task.description?.takeIf { it.isNotBlank() }?.let { property("DESCRIPTION", it) } + task.location?.takeIf { it.isNotBlank() }?.let { property("LOCATION", it) } + // URL is a URI, not TEXT: it must not be escaped like one. + task.url?.takeIf { it.isNotBlank() }?.let { rawProperty("URL", it) } + + task.start?.let { dateProperty("DTSTART", it, task.isAllDay) } + task.due?.let { dateProperty("DUE", it, task.isAllDay) } + task.created?.let { rawProperty("CREATED", formatUtc(it)) } + task.lastModified?.let { rawProperty("LAST-MODIFIED", formatUtc(it)) } + // COMPLETED is defined as UTC date-time even for an all-day task. + task.completedAt?.let { rawProperty("COMPLETED", formatUtc(it)) } + + rawProperty("STATUS", task.status.toICalName()) + if (task.priority != Priority.NONE) rawProperty("PRIORITY", task.priority.toICal().toString()) + task.percentComplete?.coerceIn(0, 100)?.let { rawProperty("PERCENT-COMPLETE", it.toString()) } + + // Passed through as stored. The provider keeps these in iCalendar form + // already, and re-deriving them would risk changing what the user's + // recurrence actually means. + task.rrule?.takeIf { it.isNotBlank() }?.let { rawProperty("RRULE", it) } + task.rdate?.takeIf { it.isNotBlank() }?.let { rawProperty("RDATE", it) } + + // Only emit the link when the parent is in this same document; a + // RELATED-TO pointing outside the file would dangle on import. + task.parentId?.let { uidsById[it] }?.let { + property("RELATED-TO;RELTYPE=PARENT", it) + } + + line("END:VTODO") + } + + private fun StringBuilder.dateProperty(name: String, instant: Instant, allDay: Boolean) { + if (allDay) { + // Read in UTC, matching the storage convention (see AllDayTime): an + // all-day value *is* UTC midnight of the intended calendar date. + rawProperty("$name;VALUE=DATE", instant.calendarDate(allDay = true).format(DATE)) + } else { + rawProperty(name, formatUtc(instant)) + } + } + + private fun formatUtc(instant: Instant): String = + java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC) + .format(DATE_TIME_UTC) + + /** A property whose value is TEXT, and so must be escaped. */ + private fun StringBuilder.property(name: String, value: String) = + line("$name:${escapeText(value)}") + + /** A property whose value is already in its final form (dates, numbers, URIs, rules). */ + private fun StringBuilder.rawProperty(name: String, value: String) = line("$name:$value") + + private fun StringBuilder.line(content: String) { + append(fold(content)) + append(CRLF) + } + + /** + * TEXT escaping and folding are the same operations the sync mapper needs, + * and having two implementations of either is how the two halves drift. + * These delegate; the implementations live in `domain/ical/`. + * + * This writer itself stays separate from `VTodoMapper` on purpose: it + * serialises **domain** export models, which exist in both storage modes, + * whereas the mapper serialises Room entities, which exist only in one. + */ + internal fun escapeText(value: String): String = ICalValues.escapeText(value) + + internal fun fold(content: String): String = ICalSerializer.fold(content) + + private fun TaskStatus.toICalName(): String = when (this) { + TaskStatus.NEEDS_ACTION -> "NEEDS-ACTION" + TaskStatus.IN_PROCESS -> "IN-PROCESS" + TaskStatus.COMPLETED -> "COMPLETED" + TaskStatus.CANCELLED -> "CANCELLED" + } + + private const val CRLF = "\r\n" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalModel.kt new file mode 100644 index 0000000..bd655fa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalModel.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain.ical + +/** + * A parameter on a content line: `TZID=Europe/Berlin`, `MEMBER="a","b"`. + * + * Values are held **unquoted**. Quoting is optional in RFC 5545 and carries no + * meaning, so it is normalised away on parse and reapplied on serialise only + * where the grammar forces it, which is why parameter quoting is on the + * round-trip allowlist. + */ +data class ICalParam(val name: String, val values: List) { + constructor(name: String, value: String) : this(name, listOf(value)) +} + +/** + * One content line. + * + * [value] is kept **exactly as it arrived**, unfolded but still escaped. That is + * deliberate and it is the whole reason this model exists instead of a typed + * one: a property we do not model is re-emitted from this string verbatim, so it + * cannot be normalised, reordered inside itself, or lost. Decoding happens in + * the mapper, for the properties the mapper actually claims. + */ +data class ICalProperty( + val name: String, + val params: List = emptyList(), + val value: String, +) { + /** First value of [name], unquoted, or `null`. */ + fun param(name: String): String? = + params.firstOrNull { it.name.equals(name, ignoreCase = true) }?.values?.firstOrNull() +} + +/** A `BEGIN:`/`END:` block — `VCALENDAR`, `VTODO`, `VALARM`, `VTIMEZONE`, or one we don't know. */ +data class ICalComponent( + val name: String, + val properties: List = emptyList(), + val components: List = emptyList(), +) { + fun property(name: String): ICalProperty? = + properties.firstOrNull { it.name.equals(name, ignoreCase = true) } + + fun properties(name: String): List = + properties.filter { it.name.equals(name, ignoreCase = true) } + + fun components(name: String): List = + components.filter { it.name.equals(name, ignoreCase = true) } + + /** This component with every property in [names] removed. Sub-components are untouched. */ + fun without(names: Set): ICalComponent { + val upper = names.map { it.uppercase() }.toSet() + return copy(properties = properties.filterNot { it.name.uppercase() in upper }) + } + + /** True when nothing survives — no properties and no sub-components worth keeping. */ + fun isEmpty(): Boolean = properties.isEmpty() && components.isEmpty() +} + +/** Thrown when input is not recoverable as iCalendar at all. */ +class ICalParseException(message: String) : Exception(message) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt new file mode 100644 index 0000000..08f2520 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalParser.kt @@ -0,0 +1,156 @@ +package de.jeanlucmakiola.agendula.domain.ical + +/** + * Parses RFC 5545 text into an [ICalComponent] tree. + * + * Lexical only: it splits content lines into name, parameters and value and + * nests `BEGIN`/`END` blocks. It does not interpret a single value — that is the + * mapper's job, and keeping the two apart is what lets an unrecognised property + * survive a read-modify-write cycle untouched. + * + * Deliberately tolerant, because servers and other clients are not careful: + * unknown components nest like any other, a stray `END` without its `BEGIN` is + * ignored rather than fatal, and a line with no colon is skipped. Only a missing + * outer component is fatal. + */ +object ICalParser { + + /** + * Written as an escape, not as the character itself: a literal BOM anywhere + * but byte 0 of a file is what Android Lint's `ByteOrderMark` check exists to + * catch, and it fails the build. + */ + private const val BOM = "\uFEFF" + + fun parse(text: String): ICalComponent = + parseAll(text).firstOrNull() ?: throw ICalParseException("no component found") + + /** Every top-level component in [text]. Normally one `VCALENDAR`. */ + fun parseAll(text: String): List { + val roots = mutableListOf() + val stack = ArrayDeque() + + for (line in unfold(text)) { + val property = parseLine(line) ?: continue + when { + property.name.equals("BEGIN", ignoreCase = true) -> + stack.addLast(Builder(property.value.trim().uppercase())) + + property.name.equals("END", ignoreCase = true) -> { + // Close by *name*, not by position. A resource with a missing + // END:VTODO would otherwise have its END:VCALENDAR close the + // VTODO, leave VCALENDAR open, and end with no component at + // all — discarding a whole multiget response over one + // malformed task. + val name = property.value.trim().uppercase() + val depth = stack.indexOfLast { it.name == name } + if (depth < 0) continue + repeat(stack.size - depth) { close(stack, roots) } + } + + // A property before any BEGIN is malformed; dropping it is the only + // option that doesn't invent a component to hang it on. + else -> stack.lastOrNull()?.properties?.add(property) + } + } + // Anything still open at end of input is missing its END. Keeping it is + // strictly better than dropping it. + while (stack.isNotEmpty()) close(stack, roots) + return roots + } + + private fun close(stack: ArrayDeque, roots: MutableList) { + val component = stack.removeLast().build() + val parent = stack.lastOrNull() + if (parent == null) roots += component else parent.components += component + } + + /** + * Splits [text] into unfolded content lines. + * + * RFC 5545 §3.1: a CRLF followed by a single space or tab is a fold and both + * are removed. Bare LF is accepted because plenty of real files carry it, and + * a leading BOM is stripped. + */ + internal fun unfold(text: String): List { + val lines = mutableListOf() + val normalised = text.removePrefix(BOM) + for (raw in normalised.split("\r\n", "\n", "\r")) { + if (raw.isEmpty()) continue + val continuation = raw[0] == ' ' || raw[0] == '\t' + if (continuation && lines.isNotEmpty()) { + lines.last().append(raw, 1, raw.length) + } else { + lines += StringBuilder(raw) + } + } + return lines.map { it.toString() } + } + + /** + * Splits one unfolded line into name, parameters and value. + * + * The value separator is the first colon **outside a quoted parameter value** + * — `ATTENDEE;CN="Smith, J:r":mailto:x` has three colons and only the third + * one ends the parameter list. + */ + internal fun parseLine(line: String): ICalProperty? { + var quoted = false + var colon = -1 + for (i in line.indices) { + val c = line[i] + if (c == '"') quoted = !quoted + else if (c == ':' && !quoted) { colon = i; break } + } + // An unbalanced quote in the parameter section leaves the scan stuck + // inside a quoted string forever. Falling back to the first colon keeps + // the line instead of dropping it whole. + if (colon < 0) colon = line.indexOf(':') + if (colon < 0) return null + + val head = line.substring(0, colon) + val value = line.substring(colon + 1) + + val segments = splitUnquoted(head, ';') + val name = segments.firstOrNull()?.trim().orEmpty() + if (name.isEmpty()) return null + + val params = segments.drop(1).mapNotNull { segment -> + val eq = segment.indexOf('=') + // A parameter with no '=' is non-conformant. Keep it as a valueless + // parameter rather than dropping it — it is still someone's data. + if (eq < 0) return@mapNotNull ICalParam(segment.trim(), emptyList()) + val paramName = segment.substring(0, eq).trim() + if (paramName.isEmpty()) return@mapNotNull null + val values = splitUnquoted(segment.substring(eq + 1), ',').map { it.unquote() } + ICalParam(paramName, values) + } + + return ICalProperty(name, params, value) + } + + /** Splits on [delimiter], ignoring delimiters inside double quotes. */ + private fun splitUnquoted(text: String, delimiter: Char): List { + val out = mutableListOf() + val current = StringBuilder() + var quoted = false + for (c in text) { + when { + c == '"' -> { quoted = !quoted; current.append(c) } + c == delimiter && !quoted -> { out += current.toString(); current.clear() } + else -> current.append(c) + } + } + out += current.toString() + return out + } + + private fun String.unquote(): String = + if (length >= 2 && startsWith('"') && endsWith('"')) substring(1, length - 1) else this + + private class Builder(val name: String) { + val properties = mutableListOf() + val components = mutableListOf() + fun build() = ICalComponent(name, properties.toList(), components.toList()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalSerializer.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalSerializer.kt new file mode 100644 index 0000000..c16b8db --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalSerializer.kt @@ -0,0 +1,100 @@ +package de.jeanlucmakiola.agendula.domain.ical + +/** + * Serialises an [ICalComponent] tree back to RFC 5545 text. + * + * The inverse of [ICalParser] for everything the parser preserves: property + * order within a component, parameter order, and property values byte-for-byte. + * What it does *not* preserve is fold position and parameter quoting, neither of + * which carries information — both are on the round-trip allowlist. + */ +object ICalSerializer { + + private const val CRLF = "\r\n" + + /** RFC 5545 §3.1 caps a content line at 75 octets, excluding the CRLF. */ + private const val MAX_LINE_OCTETS = 75 + + fun serialize(component: ICalComponent): String = buildString { + write(component) + } + + /** Serialises [components] one after another — the form the residue is stored in. */ + fun serializeAll(components: List): String = buildString { + components.forEach { write(it) } + } + + /** Serialises bare properties with no enclosing component. */ + fun serializeProperties(properties: List): String = buildString { + properties.forEach { line(render(it)) } + } + + private fun StringBuilder.write(component: ICalComponent) { + line("BEGIN:${component.name}") + component.properties.forEach { line(render(it)) } + component.components.forEach { write(it) } + line("END:${component.name}") + } + + private fun StringBuilder.line(content: String) { + append(fold(content)).append(CRLF) + } + + internal fun render(property: ICalProperty): String = buildString { + append(property.name) + for (param in property.params) { + append(';').append(param.name) + if (param.values.isNotEmpty()) { + append('=') + append(param.values.joinToString(",") { quoteIfNeeded(it) }) + } + } + append(':').append(property.value) + } + + /** + * A parameter value is quoted only when the grammar forces it — it may not + * contain a colon, semicolon or comma unquoted. Any embedded double quote is + * dropped, because RFC 5545 gives it no escape and emitting one produces a + * line no parser can read back. + */ + private fun quoteIfNeeded(value: String): String { + if (value.none { it == ':' || it == ';' || it == ',' }) return value + return "\"" + value.replace("\"", "") + "\"" + } + + /** + * Folds a content line to at most [MAX_LINE_OCTETS] octets, continuing with + * CRLF + a single space. + * + * Counted in **octets, not characters** — the limit is defined that way, and + * an emoji in a task title is four of them. Splits stay on character + * boundaries so a fold can never cut a UTF-8 sequence in half. + */ + internal fun fold(content: String): String { + if (content.utf8Size() <= MAX_LINE_OCTETS) return content + + val out = StringBuilder() + var octets = 0 + // The first line takes the full budget; every continuation loses one octet + // to its leading space. + var budget = MAX_LINE_OCTETS + var index = 0 + while (index < content.length) { + val codePoint = content.codePointAt(index) + val charCount = Character.charCount(codePoint) + val size = String(Character.toChars(codePoint)).utf8Size() + if (octets + size > budget) { + out.append(CRLF).append(' ') + octets = 0 + budget = MAX_LINE_OCTETS - 1 + } + out.append(content, index, index + charCount) + octets += size + index += charCount + } + return out.toString() + } + + private fun String.utf8Size(): Int = toByteArray(Charsets.UTF_8).size +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt new file mode 100644 index 0000000..037eb5e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/ICalValues.kt @@ -0,0 +1,135 @@ +package de.jeanlucmakiola.agendula.domain.ical + +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import kotlin.time.Instant + +/** Value-level codecs: RFC 5545 TEXT escaping and the DATE / DATE-TIME forms. */ +object ICalValues { + + private val DATE = DateTimeFormatter.ofPattern("yyyyMMdd") + private val DATE_TIME = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss") + + /** + * RFC 5545 §3.3.11. Note the asymmetry with [unescapeText]: a literal colon + * needs no escape in a property value, and escaping it is a common bug that + * other clients then have to undo. + */ + fun escapeText(value: String): String = value + .replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n") + + fun unescapeText(value: String): String { + val out = StringBuilder(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + if (c == '\\' && i + 1 < value.length) { + when (val next = value[i + 1]) { + 'n', 'N' -> out.append('\n') + '\\', ';', ',' -> out.append(next) + // An unknown escape is left as it was found; inventing a + // meaning for it would corrupt the value on write-back. + else -> out.append(c).append(next) + } + i += 2 + } else { + out.append(c) + i++ + } + } + return out.toString() + } + + /** How a DATE / DATE-TIME property was written, and whether we can reproduce it. */ + sealed interface TimeValue { + /** `VALUE=DATE` — date-only. */ + data class Date(val instant: Instant) : TimeValue + + /** A UTC instant (`…Z`), or a zoned one whose `TZID` the device knows. */ + data class Timed(val instant: Instant, val tzid: String?) : TimeValue + + /** + * A form we can read but not reproduce: a floating time (no `Z`, no + * `TZID`), or a `TZID` absent from the device's tzdb. [instant] is a + * best-effort reading for the UI; the property itself stays in the + * residue and is re-emitted verbatim, so nothing is lost on write-back. + */ + data class Unrepresentable(val instant: Instant?, val reason: String) : TimeValue + } + + /** + * Reads a DATE or DATE-TIME property. + * + * The unknown-`TZID` case is the one that matters: RFC 5545 lets a file + * carry its own `VTIMEZONE` for a zone the device has never heard of, and + * guessing UTC there silently moves the user's task by hours. It is reported + * as [TimeValue.Unrepresentable] instead. + */ + fun parseTime(property: ICalProperty): TimeValue { + val raw = property.value.trim() + val isDate = property.param("VALUE").equals("DATE", ignoreCase = true) || + (raw.length == 8 && !raw.contains('T')) + + if (isDate) { + val date = runCatching { LocalDate.parse(raw, DATE) }.getOrNull() + ?: return TimeValue.Unrepresentable(null, "unparseable DATE") + return TimeValue.Date(allDayInstantOf(date)) + } + + val utc = raw.endsWith("Z") + val local = runCatching { LocalDateTime.parse(raw.removeSuffix("Z"), DATE_TIME) }.getOrNull() + ?: return TimeValue.Unrepresentable(null, "unparseable DATE-TIME") + + if (utc) return TimeValue.Timed(local.toInstant(ZoneOffset.UTC).toKotlin(), null) + + val tzid = property.param("TZID") + ?: return TimeValue.Unrepresentable( + local.toInstant(ZoneOffset.UTC).toKotlin(), + "floating time", + ) + + val zone = runCatching { ZoneId.of(tzid) }.getOrNull() + ?: return TimeValue.Unrepresentable( + local.toInstant(ZoneOffset.UTC).toKotlin(), + "unknown TZID $tzid", + ) + + return TimeValue.Timed(local.atZone(zone).toInstant().toKotlin(), tzid) + } + + /** The instant this value denotes, however well it could be read. */ + fun readInstant(value: TimeValue): Instant? = when (value) { + is TimeValue.Date -> value.instant + is TimeValue.Timed -> value.instant + is TimeValue.Unrepresentable -> value.instant + } + + /** `20260904` — the all-day form. */ + fun formatDate(instant: Instant): String = + java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()) + .atZone(ZoneOffset.UTC) + .format(DATE) + + /** `20260904T080000Z`, or the local form when [tzid] names a zone we know. */ + fun formatDateTime(instant: Instant, tzid: String?): String { + val moment = java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()) + val zone = tzid?.let { runCatching { ZoneId.of(it) }.getOrNull() } + return if (zone == null) { + moment.atZone(ZoneOffset.UTC).format(DATE_TIME) + "Z" + } else { + moment.atZone(zone).format(DATE_TIME) + } + } + + private fun java.time.Instant.toKotlin(): Instant = + Instant.fromEpochMilliseconds(toEpochMilli()) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/VTimeZones.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/VTimeZones.kt new file mode 100644 index 0000000..c21e8b4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/ical/VTimeZones.kt @@ -0,0 +1,160 @@ +package de.jeanlucmakiola.agendula.domain.ical + +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.zone.ZoneOffsetTransitionRule + +/** + * Builds a `VTIMEZONE` for an IANA zone id. + * + * ⚠️ This is not optional decoration. RFC 5545 §3.2.19: a `TZID` parameter + * without a leading solidus **must** reference a `VTIMEZONE` in the same + * calendar object. The mapper emits `DTSTART;TZID=Europe/Berlin:…` whenever a + * task carries a zone, so a resource that ships those without a definition is + * malformed — and `Prefer: handling=strict`, which we send precisely so servers + * stop silently repairing our bytes, turns "malformed" from a shrug into a + * rejection. + * + * The definition is generated from `java.time`'s own rules rather than carried + * from the server, which is a deliberate trade. A server's `VTIMEZONE` is opaque + * to us and lives at `VCALENDAR` level, outside the `VTODO` the mapper stores + * residue for; regenerating it means the zone we write always agrees with the + * zone we compute occurrences in. The round-trip corpus already treats + * `VTIMEZONE` bodies as opaque for exactly this reason. + * + * **Bounded on purpose:** only the currently-effective rules are emitted, not + * the full historical transition table. Tasks are dated now or later; reproducing + * a century of political time changes to place a due date is not a trade worth + * making. + */ +object VTimeZones { + + /** The `VTIMEZONE` for [zoneId], or null when the id is not resolvable. */ + fun forZone(zoneId: String): ICalComponent? { + val zone = runCatching { ZoneId.of(zoneId) }.getOrNull() ?: return null + val rules = zone.rules + + val observances = if (rules.transitionRules.isEmpty()) { + // Either a fixed-offset zone, or one whose DST was abolished and + // whose rules therefore ended. Both are a single standard offset from + // here on, which is the only period a task can fall in. + val offset = rules.getStandardOffset(java.time.Instant.now()) + listOf(fixedObservance(offset)) + } else { + rules.transitionRules.map(::observance) + } + + return ICalComponent( + name = "VTIMEZONE", + properties = listOf(ICalProperty("TZID", emptyList(), zoneId)), + components = observances, + ) + } + + /** The `VTIMEZONE`s referenced by any `TZID` parameter under [component]. */ + fun forComponent(component: ICalComponent): List = + tzids(component).sorted().mapNotNull(::forZone) + + private fun tzids(component: ICalComponent): Set { + val here = component.properties.mapNotNull { it.param("TZID") } + return (here + component.components.flatMap { tzids(it) }) + .filter { it.isNotBlank() } + .toSet() + } + + private fun fixedObservance(offset: ZoneOffset) = ICalComponent( + name = "STANDARD", + properties = listOf( + ICalProperty("DTSTART", emptyList(), "19700101T000000"), + ICalProperty("TZOFFSETFROM", emptyList(), format(offset)), + ICalProperty("TZOFFSETTO", emptyList(), format(offset)), + ), + components = emptyList(), + ) + + private fun observance(rule: ZoneOffsetTransitionRule): ICalComponent { + val daylight = rule.offsetAfter.totalSeconds > rule.standardOffset.totalSeconds + // The rule's own first transition, so DTSTART is a real instance of the + // recurrence rather than an arbitrary date that happens to share a month. + val start = rule.createTransition(ANCHOR_YEAR).dateTimeBefore + return ICalComponent( + name = if (daylight) "DAYLIGHT" else "STANDARD", + properties = listOfNotNull( + ICalProperty("DTSTART", emptyList(), formatLocal(start)), + ICalProperty("TZOFFSETFROM", emptyList(), format(rule.offsetBefore)), + ICalProperty("TZOFFSETTO", emptyList(), format(rule.offsetAfter)), + rrule(rule)?.let { ICalProperty("RRULE", emptyList(), it) }, + ), + components = emptyList(), + ) + } + + private fun rrule(rule: ZoneOffsetTransitionRule): String? { + val month = rule.month.value + val day = rule.dayOfMonthIndicator + val dow = rule.dayOfWeek ?: return "FREQ=YEARLY;BYMONTH=$month;BYMONTHDAY=$day" + + val byDay = when { + // ⚠️ java.time does not encode "the last " as -1. The EU rule + // arrives as dayOfMonthIndicator = 25 — "the first Sunday on or after + // the 25th" — and only the fact that a seven-day window ending on the + // last day of the month *is* the last occurrence identifies it. + // Missing this emits a seven-value BYMONTHDAY list where every other + // client writes BYDAY=-1SU. + day > 0 && day + 6 == rule.month.maxLength() -> "-1${abbreviate(dow)}" + day == -1 -> "-1${abbreviate(dow)}" + // "the nth ", which java.time encodes as "on or after day + // 1 / 8 / 15 / 22". + day > 0 && (day - 1) % 7 == 0 -> "${(day - 1) / 7 + 1}${abbreviate(dow)}" + else -> null + } + if (byDay != null) return "FREQ=YEARLY;BYMONTH=$month;BYDAY=$byDay" + + // Anything else is "the first on or after day N", which iCalendar + // can only say as a day-of-week plus the seven dates it could land on. + if (day <= 0) return null + val days = (day until day + 7).joinToString(",") + return "FREQ=YEARLY;BYMONTH=$month;BYDAY=${abbreviate(dow)};BYMONTHDAY=$days" + } + + private fun abbreviate(day: DayOfWeek) = when (day) { + DayOfWeek.MONDAY -> "MO" + DayOfWeek.TUESDAY -> "TU" + DayOfWeek.WEDNESDAY -> "WE" + DayOfWeek.THURSDAY -> "TH" + DayOfWeek.FRIDAY -> "FR" + DayOfWeek.SATURDAY -> "SA" + DayOfWeek.SUNDAY -> "SU" + } + + /** `+HHMM`, or `+HHMMSS` for the handful of zones with a sub-minute offset. */ + private fun format(offset: ZoneOffset): String { + val total = offset.totalSeconds + val sign = if (total < 0) "-" else "+" + val abs = kotlin.math.abs(total) + val hours = abs / 3600 + val minutes = (abs % 3600) / 60 + val seconds = abs % 60 + return buildString { + append(sign) + append("%02d%02d".format(hours, minutes)) + if (seconds != 0) append("%02d".format(seconds)) + } + } + + private fun formatLocal(time: LocalDateTime): String = + "%04d%02d%02dT%02d%02d%02d".format( + time.year, time.monthValue, time.dayOfMonth, + time.hour, time.minute, time.second, + ) + + /** + * The year the observance `DTSTART`s are anchored to. + * + * Any year the rule applies in produces an equivalent definition; 1970 is + * the conventional choice and keeps generated bodies stable across runs. + */ + private const val ANCHOR_YEAR = 1970 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt new file mode 100644 index 0000000..9b8031b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpander.kt @@ -0,0 +1,189 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import org.dmfs.rfc5545.DateTime +import org.dmfs.rfc5545.recur.RecurrenceRule +import org.dmfs.rfc5545.recurrenceset.RecurrenceList +import org.dmfs.rfc5545.recurrenceset.RecurrenceRuleAdapter +import org.dmfs.rfc5545.recurrenceset.RecurrenceSet +import java.time.ZoneId +import java.util.TimeZone +import kotlin.time.Instant + +private const val MILLIS_PER_SECOND = 1000L +private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 + +/** The rule set of one task series, as stored. All strings are raw iCalendar values. */ +data class RecurrenceSpec( + val rrule: String?, + val rdate: String?, + val exdate: String?, + /** The series anchor: DTSTART if present, else DUE. Never null for a recurring task. */ + val anchor: Instant, + val isAllDay: Boolean, + /** IANA zone id the anchor is expressed in; null means floating/local. */ + val timeZone: String?, +) + +/** + * The window expansion is bounded to: [from] inclusive, [until] exclusive, and + * never more than [maxOccurrences] results — so an unbounded `RRULE` terminates. + * + * [pivot] is where "now" sits inside the window, and it is what the occurrence + * budget is spent around. Without it a series firing more often than about + * once a day exhausts [maxOccurrences] inside the past alone — an eight-hourly + * task would stop expanding months before today, so it would never appear in + * Today or Upcoming at all. At most a quarter of the budget goes to occurrences + * before [pivot], and the most recent of those are the ones kept. + */ +data class ExpansionWindow( + val from: Instant, + val until: Instant, + val maxOccurrences: Int = 500, + val pivot: Instant = from, +) + +/** + * Expands a task series into its occurrences in memory, over `lib-recur`. + * + * There is no materialised instances table behind this: the repository already + * filters and sorts in Kotlin, so occurrences are computed at read time and the + * whole class of staleness bugs a cached table brings never exists. + */ +object RecurrenceExpander { + + /** + * Every occurrence of [spec] inside [window], as its `RECURRENCE-ID` anchor — + * the instant identifying that occurrence within the series. Ascending, + * deduplicated, `EXDATE` applied. + * + * The anchor itself is always part of the set (RFC 5545 §3.8.5.3: `DTSTART` + * is the first instance), so a spec with no rule and no `RDATE` expands to + * exactly its anchor. A malformed `RRULE`, `RDATE` or `EXDATE` is dropped + * rather than thrown — a task whose stored rule cannot be parsed still has + * to appear. + * + * [floatingZone] resolves a series with no [RecurrenceSpec.timeZone]; it is a + * parameter rather than a `TimeZone.getDefault()` lookup so expansion is + * deterministic under test. + */ + fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow, + floatingZone: ZoneId = ZoneId.systemDefault(), + ): List { + val zone = zoneOf(spec, floatingZone) + val anchorMillis = anchorMillis(spec) + + val set = RecurrenceSet() + spec.rrule.orNull()?.let { raw -> ruleOf(raw, zone)?.let { set.addInstances(RecurrenceRuleAdapter(it)) } } + spec.rdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addInstances) } + spec.exdate.orNull()?.let { raw -> datesOf(raw, zone)?.let(set::addExceptions) } + + val iterator = set.iterator(zone, anchorMillis, window.until.toEpochMilliseconds()) + iterator.fastForward(window.from.toEpochMilliseconds()) + + // Occurrences arrive ascending, so everything before the pivot lands first + // and `past` is final by the time the first future one appears. Past is a + // sliding window (the newest are the ones worth keeping); the rest of the + // budget then goes to the future, undiminished when there is no past. + val pastCap = window.maxOccurrences / 4 + val past = ArrayDeque() + val future = ArrayList() + var previous = Long.MIN_VALUE + while (iterator.hasNext()) { + val millis = iterator.next() + if (millis == previous) continue + previous = millis + val at = Instant.fromEpochMilliseconds(millis) + if (at < window.pivot) { + if (past.size == pastCap) past.removeFirst() + if (pastCap > 0) past.addLast(at) + } else { + future += at + if (past.size + future.size >= window.maxOccurrences) break + } + } + return past + future + } + + /** + * Index of the current occurrence in an ascending [occurrences] list: the + * first one at or after [now], or the last one when the whole series is in + * the past. `-1` when there are no occurrences at all. + */ + fun currentOccurrenceIndex(occurrences: List, now: Instant): Int { + if (occurrences.isEmpty()) return -1 + val next = occurrences.indexOfFirst { it >= now } + return if (next >= 0) next else occurrences.lastIndex + } + + /** + * Each occurrence's distance from the current one, index-aligned with + * [occurrences]. `0` is the current occurrence, negative counts back into the + * past and positive counts forward — the convention `Task.distanceFromCurrent` + * carries and the data sources pick the current occurrence by. + * + * Purely positional: unlike the dmfs provider, which drove the same number off + * each instance's closed state, this knows only times. Completion-aware + * refinement belongs where overrides carry their status. + */ + fun distancesFromCurrent(occurrences: List, now: Instant): List { + val current = currentOccurrenceIndex(occurrences, now) + if (current < 0) return emptyList() + return occurrences.indices.map { it - current } + } + + private fun zoneOf(spec: RecurrenceSpec, floatingZone: ZoneId): TimeZone { + if (spec.isAllDay) return TimeZone.getTimeZone(ZoneId.of("UTC")) + val stored = spec.timeZone?.let { runCatching { ZoneId.of(it) }.getOrNull() } + return TimeZone.getTimeZone(stored ?: floatingZone) + } + + /** + * All-day series are date-anchored: pin the anchor to UTC midnight, as it is + * stored. A timed one is floored to the second, because RFC 5545 DATE-TIME + * has no sub-second field — carrying millis in makes lib-recur emit the raw + * anchor *and* its truncated self, doubling the first occurrence, and mints + * `RECURRENCE-ID`s no other client could address. + */ + private fun anchorMillis(spec: RecurrenceSpec): Long { + val millis = spec.anchor.toEpochMilliseconds() + val unit = if (spec.isAllDay) MILLIS_PER_DAY else MILLIS_PER_SECOND + return Math.floorDiv(millis, unit) * unit + } + + private fun ruleOf(value: String, zone: TimeZone): RecurrenceRule? = runCatching { + RecurrenceRule(value).also { rule -> + // lib-recur refuses to iterate a floating UNTIL against a zoned start, + // and RFC 5545 §3.3.10 forbids that pairing — but stored rules carry it + // anyway. Re-read the UNTIL's local fields in the series zone. + val until = rule.until + if (until != null && until.isFloating) { + // ⚠️ A DATE-valued UNTIL bounds the whole of its last day, not + // midnight on it. Its hour, minute and second fields are all + // zero, so rebuilding from them silently dropped every + // occurrence on the final day of a series anchored at any other + // time — five daily occurrences where there should be six, only + // away from UTC, which is to say for almost everyone. §3.3.10 + // pairs a DATE UNTIL with a DATE DTSTART; Google and Apple emit + // it beside a timed one anyway, and end-of-day is the only + // reading of that which keeps the day the user can see. + val date = until.isAllDay + rule.until = DateTime( + zone, + until.year, + until.month, + until.dayOfMonth, + if (date) 23 else until.hours, + if (date) 59 else until.minutes, + if (date) 59 else until.seconds, + ) + } + } + }.getOrNull() + + private fun datesOf(value: String, zone: TimeZone): RecurrenceList? = + runCatching { RecurrenceList(value, zone) }.getOrNull() + + private fun String?.orNull(): String? = this?.trim()?.ifEmpty { null } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceOccurrences.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceOccurrences.kt new file mode 100644 index 0000000..8806524 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceOccurrences.kt @@ -0,0 +1,106 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus + +/** + * The first [limit] dates a [SimpleRecurrence] fires on, starting at [start] + * (DTSTART), for previewing a rule as dates instead of as words. A preview only, + * kept to the shapes the picker can build; the expander stays the authority. + * + * Mirrors RFC 5545: [start] is always the first occurrence (§3.8.5.3), even when + * the rule's own picks miss it; a monthly or yearly rule *skips* a period the + * start day doesn't exist in rather than clamping; a weekly rule repeats in + * blocks of `interval` weeks beginning on Monday (the default WKST, since + * [toRRule] never writes one); [RecurrenceEnd.Count] counts real occurrences and + * [RecurrenceEnd.Until] is inclusive. + * + * Returns fewer than [limit] dates when the series ends first, and an empty list + * only when the rule yields nothing at all (an UNTIL before [start]). + */ +fun SimpleRecurrence.upcomingOccurrences(start: LocalDate, limit: Int): List { + if (limit <= 0) return emptyList() + val until = (end as? RecurrenceEnd.Until)?.date + val maxCount = (end as? RecurrenceEnd.Count)?.times ?: Int.MAX_VALUE + val wanted = minOf(limit, maxCount) + if (wanted <= 0) return emptyList() + if (until != null && start > until) return emptyList() + + // DTSTART is in the recurrence set whatever the rule picks, so seed with it + // and let the walk skip anything landing on or before it. + val result = mutableListOf(start) + var period = 0 + // Periods can yield nothing (a skipped 31st), so the cap counts periods + // examined rather than dates found. + while (result.size < wanted && period < MAX_PERIODS) { + for (date in occurrencesInPeriod(period, start)) { + if (date <= start) continue + if (until != null && date > until) return result + result += date + if (result.size == wanted) return result + } + period++ + } + return result +} + +/** The dates this rule's [period]-th repetition yields (empty when skipped). */ +private fun SimpleRecurrence.occurrencesInPeriod(period: Int, start: LocalDate): List = + when (freq) { + RecurrenceFreq.Daily -> listOf(start.plus(period * interval, DateTimeUnit.DAY)) + RecurrenceFreq.Weekly -> weeklyOccurrences(period, start) + RecurrenceFreq.Monthly -> { + val month = start.plus(period * interval, DateTimeUnit.MONTH) + // plus() clamps into the shorter month, but the rule skips such a + // period — so a clamped date means "not this month". + listOfNotNull(dateOrNull(month.year, month.month.number, start.day)) + } + RecurrenceFreq.Yearly -> + listOfNotNull(dateOrNull(start.year + period * interval, start.month.number, start.day)) + } + +/** + * One weekly block: every picked weekday inside the week that begins + * `period * interval` weeks after the start's own week, in weekday order. With + * no picks the rule simply repeats the start's weekday. + */ +private fun SimpleRecurrence.weeklyOccurrences(period: Int, start: LocalDate): List { + if (byDays.isEmpty()) return listOf(start.plus(period * interval, DateTimeUnit.WEEK)) + val daysIntoWeek = (start.dayOfWeek.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % + DAYS_PER_WEEK + val weekStart = start + .minus(daysIntoWeek, DateTimeUnit.DAY) + .plus(period * interval, DateTimeUnit.WEEK) + return byDays.sortedBy { it.isoDayNumber }.map { day -> + weekStart.plus( + (day.isoDayNumber - DayOfWeek.MONDAY.isoDayNumber + DAYS_PER_WEEK) % DAYS_PER_WEEK, + DateTimeUnit.DAY, + ) + } +} + +/** + * Whether a run of [occurrences] starting at [start] leaves its starting year, + * i.e. whether showing them without a year would be ambiguous — a yearly rule + * would otherwise read as the same date repeated. + */ +fun occurrencesSpanYears(occurrences: List, start: LocalDate): Boolean = + occurrences.any { it.year != start.year } || occurrences.map { it.year }.distinct().size > 1 + +/** [LocalDate] for a day-of-month that may not exist in that month; null if it doesn't. */ +private fun dateOrNull(year: Int, month: Int, day: Int): LocalDate? = + runCatching { LocalDate(year, month, day) }.getOrNull() + +private const val DAYS_PER_WEEK = 7 + +/** + * How many repetitions to examine before giving up: generous enough for the + * sparsest rule the picker can build, bounded so a rule whose occurrences all + * fall outside its own UNTIL can't spin. + */ +private const val MAX_PERIODS = 2_000 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurringScope.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurringScope.kt new file mode 100644 index 0000000..bb1d5e7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurringScope.kt @@ -0,0 +1,4 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +/** How far a write to one occurrence of a recurring task reaches. */ +enum class RecurringScope { ThisOccurrence, ThisAndFollowing, AllOccurrences } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/SimpleRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/SimpleRecurrence.kt new file mode 100644 index 0000000..0a03bd3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/domain/recurrence/SimpleRecurrence.kt @@ -0,0 +1,194 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.number +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** + * The recurrence shapes the simple picker can express: a frequency, + * an interval, weekly weekday picks, and an optional end. Anything beyond + * that (ordinal BYDAY like "2TH", BYMONTHDAY, EXDATE rules, …) stays a raw + * RRULE string the picker shows as "custom" and leaves untouched unless the + * user replaces it. + */ +data class SimpleRecurrence( + val freq: RecurrenceFreq, + val interval: Int = 1, + val end: RecurrenceEnd = RecurrenceEnd.Never, + /** + * Weekly only: the weekdays the rule fires on (RRULE BYDAY). Empty means + * no BYDAY part — the day is derived from DTSTART. + */ + val byDays: Set = emptySet(), +) + +enum class RecurrenceFreq { + Daily, + Weekly, + Monthly, + Yearly, +} + +sealed interface RecurrenceEnd { + data object Never : RecurrenceEnd + + /** Last day on which an occurrence may fall (inclusive). */ + data class Until(val date: LocalDate) : RecurrenceEnd + + /** Total number of occurrences, counting the first. */ + data class Count(val times: Int) : RecurrenceEnd +} + +/** + * Parse an RRULE into the picker's simple shape, or null when the rule uses + * parts the picker can't represent (so the UI preserves the original string). + * Accepts an optional leading "RRULE:" and an ignored WKST part. A datetime + * UNTIL is converted from UTC into [zone] before its date is taken, mirroring + * [toRRule]. + */ +fun parseSimpleRecurrence( + rrule: String, + zone: TimeZone = TimeZone.currentSystemDefault(), +): SimpleRecurrence? { + val parts = rrule.removePrefix("RRULE:").split(';') + .filter { it.isNotBlank() } + .associate { token -> + val eq = token.indexOf('=') + if (eq <= 0) return null + token.substring(0, eq).uppercase() to token.substring(eq + 1) + } + if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { + return null + } + + val freq = when (parts["FREQ"]?.uppercase()) { + "DAILY" -> RecurrenceFreq.Daily + "WEEKLY" -> RecurrenceFreq.Weekly + "MONTHLY" -> RecurrenceFreq.Monthly + "YEARLY" -> RecurrenceFreq.Yearly + else -> return null + } + val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1 + + // BYDAY is simple only as plain weekday picks on a weekly rule; ordinal + // forms ("2TH" = second Thursday) and BYDAY on other frequencies are not. + val byDays = parts["BYDAY"]?.let { raw -> + if (freq != RecurrenceFreq.Weekly) return null + raw.split(',').map { token -> rruleDay(token.trim()) ?: return null }.toSet() + } ?: emptySet() + + val until = parts["UNTIL"] + val count = parts["COUNT"] + if (until != null && count != null) return null + val end = when { + until != null -> RecurrenceEnd.Until(parseUntilDate(until, zone) ?: return null) + count != null -> RecurrenceEnd.Count(count.toIntOrNull()?.takeIf { it >= 1 } ?: return null) + else -> RecurrenceEnd.Never + } + return SimpleRecurrence(freq, interval, end, byDays) +} + +/** + * Render as a bare RRULE value (no "RRULE:" prefix — the store keeps the bare + * value). UNTIL is written as the end of the chosen day *in [zone]*, expressed + * in UTC, so a plain `T235959Z` cannot leak one extra day for zones ahead of UTC. + */ +fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()): String = buildString { + append("FREQ=") + append( + when (freq) { + RecurrenceFreq.Daily -> "DAILY" + RecurrenceFreq.Weekly -> "WEEKLY" + RecurrenceFreq.Monthly -> "MONTHLY" + RecurrenceFreq.Yearly -> "YEARLY" + }, + ) + if (interval > 1) append(";INTERVAL=$interval") + if (freq == RecurrenceFreq.Weekly && byDays.isNotEmpty()) { + append(";BYDAY=") + append( + byDays.sortedBy { it.isoDayNumber } + .joinToString(",") { RRULE_DAY_CODES.getValue(it) }, + ) + } + when (val e = end) { + RecurrenceEnd.Never -> Unit + is RecurrenceEnd.Until -> { + val utc = LocalDateTime(e.date, LocalTime(23, 59, 59)) + .toInstant(zone) + .toLocalDateTime(TimeZone.UTC) + append( + ";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format( + utc.year, utc.month.number, utc.day, + utc.hour, utc.minute, utc.second, + ), + ) + } + is RecurrenceEnd.Count -> append(";COUNT=${e.times}") + } +} + +internal val RRULE_DAY_CODES: Map = mapOf( + DayOfWeek.MONDAY to "MO", + DayOfWeek.TUESDAY to "TU", + DayOfWeek.WEDNESDAY to "WE", + DayOfWeek.THURSDAY to "TH", + DayOfWeek.FRIDAY to "FR", + DayOfWeek.SATURDAY to "SA", + DayOfWeek.SUNDAY to "SU", +) + +/** Exact two-letter BYDAY token → weekday; ordinal forms ("2TH") return null. */ +private fun rruleDay(token: String): DayOfWeek? = + RRULE_DAY_CODES.entries.firstOrNull { it.value == token.uppercase() }?.key + +/** + * End an arbitrary RRULE (simple or not) at [untilUtcMillis]: any existing + * UNTIL/COUNT is dropped, every other part (BYDAY, INTERVAL, …) survives. + * Used for "delete this and all following occurrences" — the caller passes a + * moment just before the first occurrence to remove. + */ +fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String { + val kept = rrule.removePrefix("RRULE:").split(';') + .filter { it.isNotBlank() } + .filterNot { part -> + val key = part.substringBefore('=').trim().uppercase() + key == "UNTIL" || key == "COUNT" + } + val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC) + val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format( + until.year, until.month.number, until.day, + until.hour, until.minute, until.second, + ) + return (kept + untilPart).joinToString(";") +} + +/** + * Date of an RRULE UNTIL value ("20260801" or "20260801T215959Z"). Datetime + * forms are UTC (RFC 5545); the date is taken after converting into [zone] so + * a [toRRule]-rendered value round-trips to the day the user picked. + */ +private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching { + val date = LocalDate( + raw.substring(0, 4).toInt(), + raw.substring(4, 6).toInt(), + raw.substring(6, 8).toInt(), + ) + if (raw.length >= 15 && raw[8] == 'T') { + val time = LocalTime( + raw.substring(9, 11).toInt(), + raw.substring(11, 13).toInt(), + raw.substring(13, 15).toInt(), + ) + LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date + } else { + date + } +}.getOrNull() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/qs/NewTaskTileService.kt b/app/src/main/java/de/jeanlucmakiola/agendula/qs/NewTaskTileService.kt new file mode 100644 index 0000000..1fe684a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/qs/NewTaskTileService.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.agendula.qs + +import android.app.PendingIntent +import android.os.Build +import android.service.quicksettings.TileService +import de.jeanlucmakiola.agendula.MainActivity + +/** + * Quick Settings tile: tapping it opens a new task — the same action as the + * launcher "New task" shortcut. Stateless, so there is no on/off state to keep. + */ +class NewTaskTileService : TileService() { + + @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated") + override fun onClick() { + super.onClick() + val intent = MainActivity.newTaskIntent(this) + unlockAndRun { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pending = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + startActivityAndCollapse(pending) + } else { + startActivityAndCollapse(intent) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt index e481bd7..0e6da56 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/RootScreen.kt @@ -1,29 +1,41 @@ package de.jeanlucmakiola.agendula.ui +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.background import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost +import de.jeanlucmakiola.agendula.ui.navigation.ReadableWidth +import de.jeanlucmakiola.agendula.ui.navigation.NavRequest +import de.jeanlucmakiola.agendula.ui.onboarding.OnboardingFlow +import de.jeanlucmakiola.agendula.ui.onboarding.OnboardingViewModel +import de.jeanlucmakiola.agendula.ui.onboarding.SquircleHero import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel -import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen -import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace /** * App root: gates on the tasks-provider permission, then hands off to @@ -32,6 +44,8 @@ import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel @Composable fun RootScreen( modifier: Modifier = Modifier, + navRequest: NavRequest? = null, + onNavRequestConsumed: () -> Unit = {}, permissionViewModel: PermissionViewModel = hiltViewModel(), ) { val permission by permissionViewModel.state.collectAsStateWithLifecycle() @@ -39,62 +53,125 @@ fun RootScreen( ActivityResultContracts.RequestMultiplePermissions(), ) { permissionViewModel.refresh() } + // Re-check on every resume, not just after the in-app request: the user may + // have granted the permission (or installed a provider) in system Settings and + // come back, and otherwise the gate would hold until the process restarts. + OnResume { permissionViewModel.refresh() } + + // Neither gate can show in OWN mode (that store is always READY), so the way + // out of one is always our own store. Without it a user whose provider app + // went away is held on this screen with Settings behind it. + val fallback = stringResource(R.string.onboarding_use_own_store) + when (permission.status) { ProviderStatus.NO_PROVIDER -> Gate( modifier = modifier, + icon = Icons.Rounded.CloudOff, title = stringResource(R.string.onboarding_no_provider_title), body = stringResource(R.string.onboarding_no_provider_body), + action = fallback, + onAction = permissionViewModel::useOwnStore, ) ProviderStatus.NEEDS_PERMISSION -> Gate( modifier = modifier, + icon = Icons.Rounded.Lock, title = stringResource(R.string.onboarding_permission_title), body = stringResource(R.string.onboarding_permission_body), action = stringResource(R.string.onboarding_permission_button), onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, + secondaryAction = fallback, + onSecondaryAction = permissionViewModel::useOwnStore, + ) + ProviderStatus.READY -> ReadyGate( + modifier = modifier, + navRequest = navRequest, + onNavRequestConsumed = onNavRequestConsumed, ) - ProviderStatus.READY -> ReadyGate(modifier = modifier) } } /** - * Second one-time gate after the provider grant: the reminder onboarding step. - * [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first - * emission — render nothing for that frame rather than flash the wrong screen. - * A cross-fade eases the hand-off to the app instead of snapping. + * Second one-time gate after the provider grant: the first-run flow. + * [OnboardingViewModel.done] is null until DataStore's first emission — render + * nothing for that frame rather than flash the wrong screen. A cross-fade eases + * the hand-off to the app instead of snapping. */ @Composable private fun ReadyGate( modifier: Modifier = Modifier, - onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(), + navRequest: NavRequest? = null, + onNavRequestConsumed: () -> Unit = {}, + onboardingViewModel: OnboardingViewModel = hiltViewModel(), ) { - val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle() - Crossfade(targetState = done, label = "reminderOnboardingGate") { state -> + val done by onboardingViewModel.done.collectAsStateWithLifecycle() + Crossfade(targetState = done, label = "onboardingGate") { state -> when (state) { - true -> AgendulaNavHost(modifier = modifier) - false -> ReminderOnboardingScreen( - onFinished = onboardingViewModel::finish, - modifier = modifier, + // A request waits out onboarding; the host consumes it once it exists. + // Each destination sizes itself: most keep a readable width, the task + // list spreads into two panes on a wide window. + true -> AgendulaNavHost( + modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + navRequest = navRequest, + onNavRequestConsumed = onNavRequestConsumed, ) + false -> ReadableWidth(modifier) { + OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel) + } null -> {} } } } +/** + * A dead end the External store can put the app in: no provider installed, or + * its permission refused. Built on the same shell as onboarding — it is the + * first and only screen a user in this state sees, and a bare column reads as a + * crash rather than a choice. + * + * Only reachable by having chosen External in Settings, so the way out — + * Agendula's own store — is always among the actions, and is the primary one + * where there is nothing else to try. + */ @Composable private fun Gate( + icon: ImageVector, title: String, body: String, + action: String, + onAction: () -> Unit, modifier: Modifier = Modifier, - action: String? = null, - onAction: () -> Unit = {}, + secondaryAction: String? = null, + onSecondaryAction: () -> Unit = {}, ) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + OnboardingScaffold( + modifier = modifier, + hero = { SquircleHero(icon) }, + topSpacing = OnboardingSpace.xl, + actions = { + Button( + onClick = onAction, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text(action, style = MaterialTheme.typography.titleMedium) + } + if (secondaryAction != null) { + TextButton(onClick = onSecondaryAction, modifier = Modifier.fillMaxWidth()) { + Text(secondaryAction) + } + } + }, ) { - Text(title, style = MaterialTheme.typography.headlineSmall) - Text(body, style = MaterialTheme.typography.bodyMedium) - if (action != null) Button(onClick = onAction) { Text(action) } + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 12.dp), + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt new file mode 100644 index 0000000..b23322b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt @@ -0,0 +1,481 @@ +package de.jeanlucmakiola.agendula.ui.accounts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.List +import androidx.compose.material.icons.automirrored.rounded.Login +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.HistoryToggleOff +import androidx.compose.material.icons.rounded.Inventory2 +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.SyncProblem +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit +import de.jeanlucmakiola.agendula.data.sync.SyncNotice +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.agendula.ui.accounts.add.message +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf + +/** + * One account: who it is, how its last sync went, and what can be done to it. + * + * Tapping a row in the list opens this rather than a destructive prompt — a list + * row leads somewhere, it does not fire an irreversible action — so removing an + * account is a button on the account's own screen. + */ +@Composable +internal fun AccountDetailScreen( + accountId: Long, + onBack: () -> Unit, + onRemoved: () -> Unit, + onSignInAgain: (AccountEntity) -> Unit, + onOpenStorage: () -> Unit, + viewModel: AccountsViewModel, +) { + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() + val push by viewModel.push.collectAsStateWithLifecycle() + OnResume(viewModel::refreshPush) + val row = accounts?.firstOrNull { it.account.id == accountId } + + // The row is gone the instant it is removed, while this screen is still + // sliding away. Keeping the last one it had stops that exit animating an + // empty screen. + var lastKnown by remember(accountId) { mutableStateOf(row) } + LaunchedEffect(row) { if (row != null) lastKnown = row } + val shown = row ?: lastKnown ?: return + + val account = shown.account + val identity = account.identity() + // The confirmation sheet. The removal it starts outlives this screen, and is + // shown on the accounts list rather than here. + var confirming by remember { mutableStateOf(false) } + + CollapsingScaffold(title = identity.title, onBack = onBack) { + AccountHero(identity) + Spacer(Modifier.height(24.dp)) + + val actions = buildList<@Composable (Position) -> Unit> { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_last_sync), + summary = syncState(shown), + position = position, + leading = { Icon(Icons.Rounded.History, contentDescription = null) }, + ) + } + if (!usable) return@buildList + push?.let { pushState -> + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_push), + summary = accountPushSummary(pushState, shown.lists), + position = position, + leading = { Icon(Icons.Rounded.Bolt, contentDescription = null) }, + ) + } + } + if (shown.needsSignIn) { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_sign_in_again), + position = position, + leading = { Icon(Icons.AutoMirrored.Rounded.Login, contentDescription = null) }, + onClick = { onSignInAgain(account) }, + ) + } + } else { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_sync_now), + position = position, + leading = { Icon(Icons.Rounded.CloudSync, contentDescription = null) }, + onClick = { viewModel.syncNow(account) }, + ) + } + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_lists), + summary = stringResource(R.string.accounts_lists_summary), + position = position, + leading = { Icon(Icons.AutoMirrored.Rounded.List, contentDescription = null) }, + onClick = { viewModel.openLists(account.id) }, + ) + } + } + } + actions.forEachIndexed { index, action -> action(positionOf(index, actions.size)) } + + if (!usable) { + Spacer(Modifier.height(24.dp)) + ExternalStorageNotice(onOpenStorage) + } + + // ⚠️ Above the removal, below the state — because it is the one thing on + // this screen the user did not already know. Conflicts are server-wins, + // and this group is the other half of it: + // without it a discarded edit is indistinguishable from lost work. + if (shown.notices.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + SyncNotices( + notices = shown.notices, + onDismiss = { viewModel.dismissNotices(account) }, + onRetry = viewModel::retry, + ) + } + + Spacer(Modifier.height(24.dp)) + + // Its own group, away from the things that are safe to press twice. + // + // ⚠️ Nothing here shows the removal in flight, deliberately. Confirming + // hands off to `onRemoved`, which puts this screen away in the same + // frame — so a pending state drawn here could never appear, and the + // dimming and spinner that were written for it were dead code claiming + // to prevent a second tap that cannot happen. The wait is visible where + // the user actually ends up: the row on `AccountsScreen`. + GroupedRow( + title = errorTitle(stringResource(R.string.accounts_remove)), + position = Position.Alone, + leading = { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { confirming = true }, + ) + Spacer(Modifier.height(24.dp)) + } + + val lists by viewModel.lists.collectAsStateWithLifecycle() + lists?.let { state -> + SyncedListsPicker(state = state, viewModel = viewModel) + } + + if (confirming) { + RemoveAccountPicker( + identity = identity, + onDismiss = { confirming = false }, + onRemove = { deleteLocalData -> + viewModel.remove(account, deleteLocalData) + confirming = false + onRemoved() + }, + ) + } +} + +/** + * What the last syncs replaced or gave up on, one row each. + * + * Named, never counted. "3 edits were replaced" leaves the user to work out + * which three across every list they own, which is the same as not telling them + * — so each row carries the task's own title, the list it lives in, and the + * reason, and "Got it" is what clears them. + */ +@Composable +private fun SyncNotices( + notices: List, + onDismiss: () -> Unit, + onRetry: (SyncNotice) -> Unit, +) { + Text( + stringResource(R.string.sync_notices_title), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp), + ) + // The dismissal is a row of the same group: it is the last thing you do to + // this list, and a floating button beside it would read as belonging to the + // screen rather than to these notices. + val rows = notices.size + 1 + notices.forEachIndexed { index, notice -> + GroupedRow( + title = notice.subject.ifBlank { stringResource(R.string.task_untitled) }, + summary = stringResource( + R.string.accounts_summary, + notice.listName, + stringResource(notice.cause.message), + ), + position = positionOf(index, rows), + leading = { + Icon( + if (notice.kind == SyncNotice.Kind.QUARANTINED) { + Icons.Rounded.SyncProblem + } else { + Icons.Rounded.HistoryToggleOff + }, + contentDescription = null, + ) + }, + trailing = if (notice.kind == SyncNotice.Kind.QUARANTINED) { + { + IconButton(onClick = { onRetry(notice) }) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.sync_notice_retry), + ) + } + } + } else { + null + }, + ) + } + GroupedRow( + title = stringResource(R.string.sync_notices_dismiss), + position = positionOf(notices.size, rows), + leading = { Icon(Icons.Rounded.Check, contentDescription = null) }, + onClick = onDismiss, + ) +} + +/** + * Why an edit is gone, in words rather than an enum name. + * + * A quarantine has no cause of its own — nothing chose it, a resource simply + * kept failing — so the null branch is the sentence for that, not a fallback. + */ +private val DiscardedEdit.Cause?.message: Int + get() = when (this) { + DiscardedEdit.Cause.SERVER_NEWER -> R.string.sync_notice_cause_server_newer + DiscardedEdit.Cause.DELETED_ON_SERVER -> R.string.sync_notice_cause_deleted_on_server + DiscardedEdit.Cause.DELETE_LOST -> R.string.sync_notice_cause_delete_lost + null -> R.string.sync_notice_cause_quarantined + } + +/** The account's logo at full size, over the two things the title bar left out. */ +@Composable +private fun AccountHero(identity: AccountIdentity) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProviderLogo(identity.provider, size = 72.dp) + Spacer(Modifier.height(12.dp)) + identity.user?.let { + Text(it, style = MaterialTheme.typography.titleMedium) + } + Text( + identity.secondary ?: stringResource(R.string.accounts_generic_provider), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Removing an account is two different things — the lists stay behind as + * device-only lists, or they go with it — so it is a browse-style choice, and + * those are full-screen. Each row says what it does and does it; leaving without + * choosing is back. + */ +@Composable +private fun RemoveAccountPicker( + identity: AccountIdentity, + onDismiss: () -> Unit, + onRemove: (deleteLocalData: Boolean) -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.accounts_remove), + onDismiss = onDismiss, + predictiveBack = true, + ) { + Text( + stringResource(R.string.accounts_remove_body, identity.title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.accounts_remove_keep), + summary = stringResource(R.string.accounts_remove_keep_body), + position = Position.Top, + leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) }, + onClick = { onRemove(false) }, + ) + GroupedRow( + title = errorTitle(stringResource(R.string.accounts_remove_wipe)), + summary = AnnotatedString(stringResource(R.string.accounts_remove_wipe_body)), + position = Position.Bottom, + leading = { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { onRemove(true) }, + ) + } +} + +/** + * Which of the account's collections sync, re-read from the server — so lists + * made there after setup can be added, and synced ones dropped. + */ +@Composable +private fun SyncedListsPicker(state: AccountsViewModel.ListsState, viewModel: AccountsViewModel) { + var confirmingDrop by remember { mutableStateOf(false) } + val ready = state as? AccountsViewModel.ListsState.Ready + + FullScreenPicker( + title = stringResource(R.string.accounts_lists), + onDismiss = viewModel::closeLists, + predictiveBack = true, + actions = { + if (ready != null) { + Button( + onClick = { + if (ready.dropping) confirmingDrop = true else viewModel.saveLists(keepUnticked = true) + }, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } + } + }, + ) { + when (state) { + AccountsViewModel.ListsState.Loading -> Row( + modifier = Modifier.padding(horizontal = GroupedListInset), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(Modifier.size(20.dp)) + Spacer(Modifier.width(12.dp)) + Text( + stringResource(R.string.accounts_lists_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AccountsViewModel.ListsState.NeedsSignIn -> PickerNote(stringResource(R.string.accounts_needs_sign_in)) + + is AccountsViewModel.ListsState.Failed -> PickerNote( + stringResource(state.cause?.message ?: R.string.add_account_error_server), + ) + + is AccountsViewModel.ListsState.Ready -> state.collections.forEachIndexed { index, collection -> + val checked = collection.url in state.selected + GroupedRow( + title = collection.displayName ?: collection.url.encodedPath, + summary = when { + collection.readOnly -> stringResource(R.string.add_account_lists_read_only) + collection.isShared -> stringResource(R.string.add_account_lists_shared) + else -> null + }, + position = positionOf(index, state.collections.size), + selected = checked, + trailing = { if (checked) SelectedCheck() }, + onClick = { viewModel.toggleList(collection.url) }, + ) + } + } + Spacer(Modifier.height(16.dp)) + } + + if (confirmingDrop) { + DropListsPicker( + onDismiss = { confirmingDrop = false }, + onChoose = { keep -> + confirmingDrop = false + viewModel.saveLists(keepUnticked = keep) + }, + ) + } +} + +@Composable +private fun PickerNote(text: String) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) +} + +/** What happens to the tasks of lists that stop syncing — the same two ways removing an account offers. */ +@Composable +private fun DropListsPicker(onDismiss: () -> Unit, onChoose: (keep: Boolean) -> Unit) { + FullScreenPicker( + title = stringResource(R.string.accounts_lists_drop_title), + onDismiss = onDismiss, + predictiveBack = true, + ) { + PickerNote(stringResource(R.string.accounts_lists_drop_body)) + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.accounts_lists_drop_keep), + summary = stringResource(R.string.accounts_lists_drop_keep_body), + position = Position.Top, + leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) }, + onClick = { onChoose(true) }, + ) + GroupedRow( + title = errorTitle(stringResource(R.string.accounts_lists_drop_wipe)), + summary = AnnotatedString(stringResource(R.string.accounts_lists_drop_wipe_body)), + position = Position.Bottom, + leading = { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { onChoose(false) }, + ) + } +} + +/** A destructive row's title, in the error colour the row itself cannot take. */ +@Composable +private fun errorTitle(text: String): AnnotatedString { + val error = MaterialTheme.colorScheme.error + return remember(text, error) { + buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt new file mode 100644 index 0000000..6506346 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt @@ -0,0 +1,225 @@ +package de.jeanlucmakiola.agendula.ui.accounts + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.caldav.CalDavProvider +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * What an account is called on screen, and by what mark. + * + * `display_name` is `user@host` — the right identity for the system account and + * for the sync trigger that keys off it, and far too long for a row title. Both + * halves are stored separately anyway, so the screen shows the one that + * identifies the account and demotes the other to the supporting line. + */ +internal data class AccountIdentity( + val provider: CalDavProvider?, + /** The name: a hosted service's brand, or the host of a server the user runs. */ + val title: String, + /** The half [title] left out — the host under a brand, the software under a host. */ + val secondary: String?, + /** Who, on that server. */ + val user: String?, +) + +internal fun AccountEntity.identity(): AccountIdentity { + val url = principalUrl?.toHttpUrlOrNull() + val provider = url?.let { CalDavProvider.forPrincipal(it) } + val host = url?.host?.removePrefix("www.") + val hosted = provider?.hosted == true + return AccountIdentity( + provider = provider, + // The fallback is the stored name: an account with no principal URL + // never got past discovery, so there is nothing better to call it. + title = if (hosted) provider.label else host ?: displayName, + secondary = if (hosted) host else provider?.label, + user = username?.takeIf { it.isNotBlank() }, + ) +} + +/** + * The provider's logo: its mark, in white, on a disc of its own brand colour — + * the 40dp leading avatar Calendula gives a synced calendar, in colour, so a + * list of accounts is scannable by mark rather than by reading hostnames. + * + * White-on-brand rather than a brand-tinted glyph on a neutral chip, because it + * is the shape the marks are actually drawn in and it is the one treatment that + * needs no second colour for dark mode. Providers with no brand colour of their + * own take the app's [MaterialTheme] accent. + * + * There are three treatments, and which one a provider gets is decided by how + * that provider actually draws itself: + * + * 1. **A badge** — [CalDavProvider.badge] — fills the circle edge to edge in its + * own colours. Fastmail's icon *is* a ring, so a disc behind it would be a + * ring inside a circle, and knocking it back to white would throw away the + * logo's larger half. + * 2. **A mark** — [CalDavProvider.mark] — is tinted white on the brand's disc, + * which is how Nextcloud, Apple and Posteo draw these marks themselves. + * 3. **A lettermark** for everything else. Material's `AlternateEmail` was doing + * duty for six providers at once, so a list meant to be read by mark showed + * one glyph six times; the brand's own initial tells them apart and claims + * nothing. The case comes from [CalDavProvider.label], which is why iCloud + * and mailbox.org keep their lowercase letterforms. + * + * A real logo beats a letter; an *approximated* logo beats neither, which is why + * the rest wait for their own art rather than for a good guess at it — and why + * every mark here is generated from the vendor's own file, not traced by eye. + */ +@Composable +internal fun ProviderLogo(provider: CalDavProvider?, size: Dp = 40.dp) { + val badge = provider?.badge + if (badge != null) { + // ⚠️ On white, not on nothing. The badge is a ring with a transparent + // middle, drawn for a white page — dropped straight onto the row it lets + // the surface through, and in dark mode the navy envelope inside it goes + // very nearly invisible. White is the background the art is drawn for, so + // it is the background it gets, in both themes. + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(Color.White), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(badge), + contentDescription = null, + modifier = Modifier.size(size), + ) + } + return + } + + val accent = provider?.accent + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(accent ?: MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + val tint = if (accent != null) Color.White else MaterialTheme.colorScheme.onPrimary + val mark = provider?.mark + when { + mark != null -> Icon( + painter = painterResource(mark.res), + contentDescription = null, + tint = tint, + modifier = Modifier.size(size * mark.fraction), + ) + + // A server we know nothing about has no initial to wear. + provider == null -> Icon( + imageVector = Icons.Rounded.CloudSync, + contentDescription = null, + tint = tint, + modifier = Modifier.size(size * GLYPH_FRACTION), + ) + + else -> Text( + text = provider.letter, + color = tint, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + fontSize = with(LocalDensity.current) { (size * LETTER_FRACTION).toSp() }, + // The name is on the row beside it. A screen reader announcing a + // bare "F" before "Fastmail" is noise, not information. + modifier = Modifier.clearAndSetSemantics { }, + ) + } + } +} + +/** A provider whose official icon is a finished badge, colours and all. */ +private val CalDavProvider.badge: Int? + get() = when (this) { + CalDavProvider.FASTMAIL -> R.drawable.ic_provider_fastmail + CalDavProvider.MAILBOX_ORG -> R.drawable.ic_provider_mailbox + else -> null + } + +/** + * The letter a service is known by. + * + * Its own initial, except where the service's actual mark *is* a different + * letter: Yandex's is a Cyrillic Я, which is something we can set rather than + * art we would have to trace — the only vector they publish is a 64px raster. + */ +private val CalDavProvider.letter: String + get() = when (this) { + CalDavProvider.YANDEX -> "Я" + else -> label.take(1) + } + +/** + * A monochrome mark and how much of the disc it is given. + * + * The fraction is not one number because the marks are not one shape: a wide + * mark squared off into the same box reads smaller than a compact one, so it is + * given more room to land on the same optical weight. + */ +private data class Mark(val res: Int, val fraction: Float) + +private val CalDavProvider.mark: Mark? + get() = when (this) { + CalDavProvider.NEXTCLOUD -> Mark(R.drawable.ic_provider_nextcloud, WIDE_MARK_FRACTION) + CalDavProvider.ICLOUD -> Mark(R.drawable.ic_provider_icloud, WIDE_MARK_FRACTION) + CalDavProvider.POSTEO -> Mark(R.drawable.ic_provider_posteo, GLYPH_FRACTION) + else -> null + } + +/** + * The provider's own brand colour, where it publishes one recognisable enough to + * be worth carrying. Null means "we would be inventing it" — Baïkal, DAViCal and + * SOGo have no colour anyone would recognise, so they take the theme's accent + * instead of a made-up one. + * + * Each is dark enough to carry a white mark, which is the whole treatment: no + * dark-mode variant is needed because neither colour in the pair moves. + */ +private val CalDavProvider.accent: Color? + get() = when (this) { + CalDavProvider.NEXTCLOUD -> Color(0xFF0082C9) + CalDavProvider.ICLOUD -> Color(0xFF007AFF) + CalDavProvider.GOOGLE -> Color(0xFF1A73E8) + CalDavProvider.FASTMAIL -> Color(0xFF2B6CB0) + CalDavProvider.MAILBOX_ORG -> Color(0xFF0069B4) + // Their own, off their app icon — not the darker green that was + // guessed at before the art arrived. + CalDavProvider.POSTEO -> Color(0xFFA9D158) + CalDavProvider.ZOHO -> Color(0xFFE42527) + CalDavProvider.YANDEX -> Color(0xFFFF2500) + CalDavProvider.BAIKAL, CalDavProvider.DAVICAL, CalDavProvider.SOGO -> null + } + +/** The mark sits on the disc the way a launcher icon does — a little over half. */ +private const val GLYPH_FRACTION = 0.55f + +/** A letter reads smaller than a glyph of the same box, so it is given less. */ +private const val LETTER_FRACTION = 0.44f + +/** A wide mark squared off into the same box has to be given more to match. */ +private const val WIDE_MARK_FRACTION = 0.72f diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt new file mode 100644 index 0000000..a45c598 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsScreen.kt @@ -0,0 +1,318 @@ +package de.jeanlucmakiola.agendula.ui.accounts + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Login +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.SYNC_INTERVAL_PRESETS +import de.jeanlucmakiola.agendula.data.sync.SyncFailure +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.agendula.ui.settings.SettingsHint +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf + +/** The CalDAV accounts this device syncs with. */ +@Composable +internal fun AccountsScreen( + onAddAccount: () -> Unit, + onSignInAgain: (AccountEntity) -> Unit, + onOpenAccount: (Long) -> Unit, + onOpenStorage: () -> Unit, + onBack: () -> Unit, + viewModel: AccountsViewModel, +) { + val accounts by viewModel.accounts.collectAsStateWithLifecycle() + val usable by viewModel.accountsUsable.collectAsStateWithLifecycle() + val syncInterval by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle() + val push by viewModel.push.collectAsStateWithLifecycle() + var showInterval by remember { mutableStateOf(false) } + var showPush by remember { mutableStateOf(false) } + OnResume(viewModel::refreshPush) + + CollapsingScaffold( + title = stringResource(R.string.settings_section_accounts), + onBack = onBack, + ) { + val loaded = accounts ?: return@CollapsingScaffold + + if (loaded.isEmpty()) { + Icon( + Icons.Rounded.CloudSync, + contentDescription = null, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.accounts_empty_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.accounts_empty_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) + Spacer(Modifier.height(24.dp)) + } else { + loaded.forEachIndexed { index, row -> + val account = row.account + val identity = account.identity() + GroupedRow( + title = identity.title, + // One line, so it says the most useful thing it can. A row + // on its way out has one piece of news; an account holding + // unread sync reports has another, and either outranks + // "synced 5 minutes ago" — but neither outranks a sign-in + // that has stopped, which `syncState` already puts first. + summary = when { + row.removing -> stringResource(R.string.accounts_removing) + row.notices.isNotEmpty() && !row.needsSignIn -> accountSummary( + identity.user, + pluralStringResource( + R.plurals.accounts_notices, + row.notices.size, + row.notices.size, + ), + ) + else -> accountSummary(identity.user, syncState(row)) + }, + position = positionOf(index, loaded.size), + // A row whose account is on its way out is not a row you can + // act on, so it stops looking like one: dimmed, its action + // replaced by the progress, and nothing to tap. + dimmed = row.removing, + leading = { ProviderLogo(identity.provider) }, + trailing = { + when { + row.removing -> CircularProgressIndicator(Modifier.size(20.dp)) + !usable -> Unit + row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) { + Icon( + Icons.AutoMirrored.Rounded.Login, + contentDescription = stringResource(R.string.accounts_sign_in_again), + ) + } + else -> IconButton(onClick = { viewModel.syncNow(account) }) { + Icon( + Icons.Rounded.Sync, + contentDescription = stringResource(R.string.accounts_sync_now), + ) + } + } + }, + onClick = { onOpenAccount(account.id) }.takeUnless { row.removing }, + ) + } + Spacer(Modifier.height(24.dp)) + if (usable) { + val pushState = push + GroupedRow( + title = stringResource(R.string.accounts_sync_interval), + summary = syncIntervalLabel(syncInterval), + position = if (pushState == null) Position.Alone else Position.Top, + onClick = { showInterval = true }, + ) + if (pushState != null) { + GroupedRow( + title = stringResource(R.string.accounts_push), + summary = pushSummary(pushState), + position = Position.Bottom, + onClick = { showPush = true }, + ) + } + Spacer(Modifier.height(24.dp)) + } + } + + if (usable) { + GroupedRow( + title = stringResource(R.string.accounts_add), + position = Position.Alone, + leading = { Icon(Icons.Rounded.Add, contentDescription = null) }, + onClick = onAddAccount, + ) + } else { + ExternalStorageNotice(onOpenStorage) + } + } + + if (showInterval) { + OptionPicker( + title = stringResource(R.string.accounts_sync_interval), + options = SYNC_INTERVAL_PRESETS, + selected = syncInterval, + label = { syncIntervalLabel(it) }, + header = { + SettingsHint(stringResource(R.string.accounts_sync_interval_hint)) + Spacer(Modifier.height(8.dp)) + }, + onSelect = viewModel::setSyncInterval, + onDismiss = { showInterval = false }, + ) + } + + val pushState = push + if (showPush && pushState != null) { + PushPicker( + push = pushState, + onSelect = viewModel::choosePushDistributor, + onDismiss = { showPush = false }, + ) + } +} + +@Composable +private fun PushPicker( + push: AccountsViewModel.PushUi, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + OptionPicker( + title = stringResource(R.string.accounts_push), + options = listOf(PUSH_OFF) + push.installed.map { it.packageName }, + // Nothing is ticked while push is on but no distributor is chosen yet. + selected = if (push.enabled) push.current?.packageName else PUSH_OFF, + label = { option -> + if (option == PUSH_OFF) { + stringResource(R.string.accounts_push_off) + } else { + push.installed.first { it.packageName == option }.label + } + }, + header = { + SettingsHint(stringResource(R.string.accounts_push_hint)) + if (push.installed.isEmpty()) SettingsHint(stringResource(R.string.accounts_push_none_installed)) + Spacer(Modifier.height(8.dp)) + }, + onSelect = { option -> onSelect(option.takeUnless { it == PUSH_OFF }) }, + onDismiss = onDismiss, + ) +} + +/** The picker's "Off" row, which no package can be named. */ +private const val PUSH_OFF = "" + +@Composable +internal fun pushSummary(push: AccountsViewModel.PushUi): String { + val current = push.current + val lists = push.lists + return when { + !push.enabled -> stringResource(R.string.accounts_push_off) + push.needsChoice -> stringResource(R.string.accounts_push_choose) + current == null -> stringResource(R.string.accounts_push_no_distributor) + lists.capable == 0 -> stringResource(R.string.accounts_push_unsupported) + lists.subscribed == 0 -> stringResource(R.string.accounts_push_pending) + lists.subscribed < lists.synced -> pluralStringResource( + R.plurals.accounts_push_partial, + lists.synced, + lists.subscribed, + lists.synced, + current.label, + ) + else -> stringResource(R.string.accounts_push_via, current.label) + } +} + +/** The same, for one account's lists. */ +@Composable +internal fun accountPushSummary(push: AccountsViewModel.PushUi, lists: AccountsViewModel.ListPush): String = + when { + !push.enabled -> stringResource(R.string.accounts_push_off) + push.needsChoice -> stringResource(R.string.accounts_push_choose) + push.current == null -> stringResource(R.string.accounts_push_no_distributor) + lists.capable == 0 -> stringResource(R.string.accounts_push_account_unsupported) + lists.subscribed == 0 -> stringResource(R.string.accounts_push_pending) + lists.subscribed < lists.synced -> pluralStringResource( + R.plurals.accounts_push_account_partial, + lists.synced, + lists.subscribed, + lists.synced, + ) + else -> stringResource(R.string.accounts_push_account_on) + } + +@Composable +private fun syncIntervalLabel(minutes: Int): String = when { + minutes <= 0 -> stringResource(R.string.accounts_sync_interval_manual) + minutes % 60 == 0 -> pluralStringResource(R.plurals.accounts_sync_every_hours, minutes / 60, minutes / 60) + else -> pluralStringResource(R.plurals.accounts_sync_every_minutes, minutes, minutes) +} + +/** In External mode accounts stay, but sync into a store nothing is showing. */ +@Composable +internal fun ExternalStorageNotice(onOpenStorage: () -> Unit) { + GroupedRow( + title = stringResource(R.string.accounts_external_storage_title), + summary = stringResource(R.string.accounts_external_storage), + position = Position.Alone, + leading = { Icon(Icons.Rounded.Storage, contentDescription = null) }, + onClick = onOpenStorage, + ) +} + +/** + * Never the raw values: `lastSyncError` is an exception string and `lastSyncAt` + * renders as an ISO-8601 UTC instant, and both bypass `strings.xml` entirely. + */ +@Composable +internal fun syncState(row: AccountsViewModel.AccountRow): String = when { + // Distinct from a failed sync on purpose: this one has stopped retrying, and + // only the user can restart it. + row.needsSignIn -> stringResource(R.string.accounts_needs_sign_in) + row.account.lastSyncError != null -> failureText(SyncFailure.of(row.account.lastSyncError)) + row.account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString( + row.account.lastSyncAt.toEpochMilliseconds(), + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() + + else -> stringResource(R.string.accounts_never_synced) +} + +/** Why the last sync failed, by class — never the engine's own words. */ +@Composable +private fun failureText(failure: SyncFailure): String = when (failure.kind) { + SyncFailure.Kind.SIGN_IN -> stringResource(R.string.accounts_failed_sign_in) + SyncFailure.Kind.UNREACHABLE -> stringResource(R.string.accounts_failed_unreachable) + SyncFailure.Kind.CERTIFICATE -> stringResource(R.string.accounts_failed_certificate) + SyncFailure.Kind.SERVER -> failure.httpCode + ?.let { stringResource(R.string.accounts_failed_server_code, it) } + ?: stringResource(R.string.accounts_failed_server) + SyncFailure.Kind.MISCONFIGURED -> stringResource(R.string.accounts_failed_misconfigured) + SyncFailure.Kind.OTHER -> stringResource(R.string.accounts_sync_failed) +} + +/** Who, then how it last went — the two things the title does not already say. */ +@Composable +private fun accountSummary(user: String?, state: String): String = + if (user == null) state else stringResource(R.string.accounts_summary, user, state) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt new file mode 100644 index 0000000..4a4e062 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt @@ -0,0 +1,356 @@ +package de.jeanlucmakiola.agendula.ui.accounts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.sync.AccountRepository +import de.jeanlucmakiola.agendula.data.sync.AccountStateStore +import de.jeanlucmakiola.agendula.data.sync.SyncAvailability +import de.jeanlucmakiola.agendula.data.sync.SyncNotice +import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore +import de.jeanlucmakiola.agendula.data.sync.SyncTrigger +import de.jeanlucmakiola.agendula.data.sync.push.PushDistributors +import de.jeanlucmakiola.agendula.data.sync.push.PushRegistrar +import de.jeanlucmakiola.agendula.data.sync.push.PushStore +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import de.jeanlucmakiola.caldav.CalDavDiscovery +import de.jeanlucmakiola.caldav.TaskCollection +import okhttp3.HttpUrl +import javax.inject.Inject + +@HiltViewModel +class AccountsViewModel @Inject constructor( + private val repository: AccountRepository, + private val syncTrigger: SyncTrigger, + private val accountState: AccountStateStore, + private val notices: SyncNoticeStore, + private val settings: SettingsPrefs, + private val distributors: PushDistributors, + private val pushRegistrar: PushRegistrar, + pushStore: PushStore, + availability: SyncAvailability, + @IoDispatcher io: CoroutineDispatcher, +) : ViewModel() { + + /** Minutes between background syncs; 0 = manual only. */ + val syncIntervalMinutes: StateFlow = settings.settings.map { it.syncIntervalMinutes }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = DEFAULT_SYNC_INTERVAL_MINUTES, + ) + + /** Store the new interval and put every account on it. */ + fun setSyncInterval(minutes: Int) { + viewModelScope.launch { + settings.setSyncIntervalMinutes(minutes) + repository.rescheduleAll(intervalChanged = true) + } + } + + /** + * An account's synced lists, how many its server offers push for, and how + * many actually hold a subscription. + */ + data class ListPush(val synced: Int, val capable: Int, val subscribed: Int) { + companion object { + val NONE = ListPush(synced = 0, capable = 0, subscribed = 0) + } + } + + private val listPush: Flow> = + combine(repository.observeSyncedLists(), pushStore.observe()) { lists, pushes -> + lists.groupBy { it.accountId!! }.mapValues { (_, synced) -> + ListPush( + synced = synced.size, + capable = synced.count { pushes[it.id]?.support != null }, + subscribed = synced.count { pushes[it.id]?.subscription != null }, + ) + } + }.distinctUntilChanged() + + /** Push as the Accounts screen shows it. */ + data class PushUi( + val enabled: Boolean, + val installed: List, + /** The distributor in use; null when push is off or none is chosen. */ + val current: PushDistributors.Distributor?, + val lists: ListPush, + ) { + /** On, distributors installed, and none chosen or set as default. */ + val needsChoice: Boolean get() = enabled && current == null && installed.isNotEmpty() + } + + private data class DistributorState( + val enabled: Boolean, + val installed: List, + val current: PushDistributors.Distributor?, + ) + + /** Distributors are installed and removed outside the app; bumped on resume. */ + private val pushRefresh = MutableStateFlow(0) + + /** Package queries only on a setting change or a refresh, and off the main thread. */ + private val distributorState: Flow = combine( + settings.settings.map { it.pushEnabled }.distinctUntilChanged(), + pushRefresh, + ) { enabled, _ -> + val installed = distributors.installed() + val current = distributors.saved().takeIf { enabled }?.let { chosen -> + installed.firstOrNull { it.packageName == chosen } + ?: PushDistributors.Distributor(chosen, distributors.labelOf(chosen)) + } + DistributorState(enabled, installed, current) + }.flowOn(io) + + val push: StateFlow = combine(distributorState, listPush) { state, perAccount -> + PushUi( + enabled = state.enabled, + installed = state.installed, + current = state.current, + lists = ListPush( + synced = perAccount.values.sumOf { it.synced }, + capable = perAccount.values.sumOf { it.capable }, + subscribed = perAccount.values.sumOf { it.subscribed }, + ), + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = null, + ) + + /** + * Re-reads the installed distributors. With push on and none chosen yet, a + * newly installed one is adopted and registered first, so the row never + * names a distributor nothing has registered with. + */ + fun refreshPush() { + viewModelScope.launch { + if (settings.settings.first().pushEnabled && distributors.saved() == null) { + runCatching { pushRegistrar.updateAll() } + } + pushRefresh.update { it + 1 } + } + } + + /** @param packageName the distributor to use, or null to turn push off. */ + fun choosePushDistributor(packageName: String?) { + viewModelScope.launch { + if (packageName == null) distributors.disable() else distributors.select(packageName) + runCatching { pushRegistrar.updateAll() } + pushRefresh.update { it + 1 } + } + } + + /** False in External storage mode, where accounts are kept but neither added nor synced. */ + val accountsUsable: StateFlow = availability.observe().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = true, + ) + + /** An account row, plus the two things the row cannot read from the entity. */ + data class AccountRow( + val account: AccountEntity, + val needsSignIn: Boolean, + /** + * A removal is under way for this account. + * + * The row survives it: `remove()` revokes the app password over the + * network before it touches any store, so several seconds pass — on a + * server that stalls, longer — during which the row sat there looking + * completely untouched. + */ + val removing: Boolean = false, + /** + * What the last syncs destroyed or gave up on, newest first. + * + * ⚠️ On the row rather than in a flow of its own, because it has to + * survive the account it belongs to going away: an account is removed + * by id, and a notice keyed to an id nothing lists any more is a line + * of text about nothing. + */ + val notices: List = emptyList(), + val lists: ListPush = ListPush.NONE, + ) + + /** + * Accounts whose removal has started and not finished. + * + * ⚠️ Also the double-tap guard. Nothing else stops a second Remove from + * starting a second revocation of a credential the first one is already + * handing back — harmless since `c2166b9`, because the sequence is + * idempotent either way, but it spends a second network round trip and + * leaves the user watching two things happen to one account. + */ + private val _removing = MutableStateFlow>(emptySet()) + + /** + * `null` until the first load, so the empty state does not flash. + * + * ⚠️ Observed, not fetched. The sync that a tap on this screen starts + * finishes on a background thread some seconds later, and a snapshot taken + * when the screen opened cannot show it — the row went on saying "never + * synced" until the user left and came back. This also carries a *background* + * sync, and a 401 that stops an account, onto a screen already open. + */ + val accounts: StateFlow?> = + combine( + repository.observeAll(), + accountState.observeNeedingSignIn(), + _removing, + notices.observeAll(), + listPush, + ) { accounts, stopped, removing, allNotices, perAccount -> + val byAccount = allNotices.groupBy { it.accountId } + accounts.map { + AccountRow( + account = it, + needsSignIn = it.id in stopped, + removing = it.id in removing, + notices = byAccount[it.id].orEmpty(), + lists = perAccount[it.id] ?: ListPush.NONE, + ) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = null, + ) + + /** + * The app's own sync trigger. + * + * Not `ContentResolver.requestSync`: that is gated behind + * `hasAuthorityAccess()` at our targetSdk and returns silently when it + * refuses, which would leave the user pressing a button that does nothing. + */ + fun syncNow(account: AccountEntity) { + // Expedited: the user is looking at the button. Every other trigger is + // ordinary work, so the exhaustible per-app quota is spent here or not + // at all. + syncTrigger.enqueue(account.displayName, expedited = true) + } + + /** The user has read what the last sync changed. */ + fun dismissNotices(account: AccountEntity) { + viewModelScope.launch { notices.dismiss(account.id) } + } + + /** Clears a quarantined task's failure count and syncs, so it is tried again. */ + fun retry(notice: SyncNotice) { + viewModelScope.launch { repository.retryQuarantined(notice.accountId, notice.key) } + } + + fun remove(account: AccountEntity, deleteLocalData: Boolean) { + if (account.id in _removing.value) return + _removing.update { it + account.id } + // No refresh: the row disappears because the query behind `accounts` + // re-emits. + viewModelScope.launch { + try { + repository.remove(account.id, account.displayName, deleteLocalData) + } finally { + // ⚠️ In a `finally`, and not only on the happy path. `remove()` + // finishes its destructive tail uncancellable, so the id would + // otherwise be stranded in the set by the one case that reaches + // here without completing normally — leaving a row that is gone + // from Room but pending for ever if it ever came back. + _removing.update { it - account.id } + } + } + } + + /** The account's collections, re-read from the server for the lists picker. */ + sealed interface ListsState { + data object Loading : ListsState + + data class Ready( + val accountId: Long, + val collections: List, + val attached: Set, + val selected: Set, + ) : ListsState { + /** Synced lists this selection would stop syncing — the ones worth asking about. */ + val dropping: Boolean + get() = collections.any { it.url.toString() in attached && it.url !in selected } + } + + data object NeedsSignIn : ListsState + + data class Failed(val cause: CalDavDiscovery.Outcome.Cause?) : ListsState + } + + private val _lists = MutableStateFlow(null) + + /** Null while the picker is closed. */ + val lists: StateFlow = _lists + + fun openLists(accountId: Long) { + _lists.value = ListsState.Loading + viewModelScope.launch { + val loaded = when (val found = repository.collections(accountId)) { + is AccountRepository.Collections.Found -> ListsState.Ready( + accountId = accountId, + collections = found.collections, + attached = found.attached, + selected = found.collections + .filter { it.url.toString() in found.attached } + .map { it.url } + .toSet(), + ) + AccountRepository.Collections.NeedsSignIn -> ListsState.NeedsSignIn + is AccountRepository.Collections.Failed -> ListsState.Failed(found.cause) + } + // Closed while it loaded: stay closed. + if (_lists.value == ListsState.Loading) _lists.value = loaded + } + } + + fun toggleList(url: HttpUrl) { + _lists.update { state -> + if (state !is ListsState.Ready) return@update state + val selected = if (url in state.selected) state.selected - url else state.selected + url + state.copy(selected = selected) + } + } + + fun closeLists() { + _lists.value = null + } + + /** @param keepUnticked what happens to the tasks of lists that stop syncing. */ + fun saveLists(keepUnticked: Boolean) { + val state = _lists.value as? ListsState.Ready ?: return + _lists.value = null + viewModelScope.launch { + repository.setSyncedCollections( + accountId = state.accountId, + offered = state.collections, + selected = state.selected, + keepUnticked = keepUnticked, + ) + } + } + + private companion object { + /** Survives a configuration change without re-subscribing. */ + const val STOP_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt new file mode 100644 index 0000000..5813587 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountMessage.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import de.jeanlucmakiola.caldav.ServerQuirk + +/** + * Something the add-account flow has to tell the user, in a form the UI can + * translate. + * + * ⚠️ A type, not a `String`, and not a `@StringRes Int` either. The flow used to + * build its sentences in the ViewModel, so a dozen of them shipped in English to + * every locale — the same defect `CalDavDiscovery.Outcome.Cause` was introduced + * to fix, which it only ever fixed for the address step. Nothing lints for it: + * `HardcodedText` reads XML layout attributes, and this app has none. Making the + * state fields carry this makes a literal a compile error, which is the only + * guard available. + * + * A resource id would work too, but two of these need a format argument and an + * `Int` accepts any other `Int` — this way the argument travels with the message + * that needs it, and a test can assert on meaning rather than on prose. + */ +sealed interface AddAccountMessage { + + /** Something is happening, and the step says which. */ + sealed interface Progress : AddAccountMessage { + data object Discovering : Progress + data object SigningIn : Progress + data object ReadingLists : Progress + data object Saving : Progress + } + + /** Something went wrong, and the step says what. */ + sealed interface Problem : AddAccountMessage + + data object GoogleUnsupported : Problem + data object AlreadyExists : Problem + data object ExternalStorage : Problem + data object NoUsableLists : Problem + data object NotSaved : Problem + data object KeystoreRefused : Problem + data object CredentialsRejected : Problem + data object BrowserApprovalExpired : Problem + data object BrowserRateLimited : Problem + data object BrowserMaintenance : Problem + data object BrowserFailed : Problem + + /** No browser could be opened at all — AOSP, GrapheneOS, a locked-down profile. */ + data object BrowserUnavailable : Problem + + /** + * A home set on a host the credential is not scoped to. + * + * The host travels with the message rather than being baked into a sentence, + * so the translation decides where it goes. + */ + data class OutsideCredentialScope(val host: String) : Problem + + /** A provider whose real requirement is not "wrong password". */ + data class Quirk(val quirk: ServerQuirk) : Problem +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt new file mode 100644 index 0000000..66a1e5e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountParts.kt @@ -0,0 +1,172 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.InstructionSteps +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.Position + +/** + * The family's text input: a tonal grouped surface with a borderless field in + * it, never Material's outlined box. + * + * The label sits above the value rather than floating into a notch, because a + * `GroupedSurface` has no outline for a notch to interrupt. + */ +@Composable +internal fun FieldRow( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + position: Position = Position.Alone, + placeholder: String = "", + error: String? = null, + hint: String? = null, + keyboardType: KeyboardType = KeyboardType.Text, + onImeAction: (() -> Unit)? = null, +) { + // ⚠️ KeyboardType.Password only tells the IME to drop suggestions; it does + // not mask anything. Without the transformation the app password renders in + // the clear on screen. + val masked = keyboardType == KeyboardType.Password + Column(modifier) { + GroupedSurface(position = position) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + InlineTextField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + keyboardType = keyboardType, + // A server address and a password are both case-sensitive, + // and sentence-casing either is a support ticket. + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Go, + onImeAction = onImeAction, + visualTransformation = if (masked) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + ) + } + } + // ⚠️ Outside the surface, not inside it. An error rendered within the + // field's own card reads as part of the value the user typed. + (error ?: hint)?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (error != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp), + ) + } + } +} + +/** The shell's primary action shape, so every step ends identically. */ +@Composable +internal fun PrimaryAction(label: String, enabled: Boolean, onClick: () -> Unit) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text(label, style = MaterialTheme.typography.titleMedium) + } +} + +/** + * What the chosen service requires, before it is required. + * + * ⚠️ Steps, not a paragraph. Fastmail and iCloud both reject the account + * password with a plain 401, and the fix is a four-action errand in someone + * else's web app — the exact shape of instruction that gets skimmed and missed + * when it is written as prose. Google is the odd one out and stays a sentence: + * there is no procedure, because there is nothing the user can do. + * + * ⚠️ Collapsed. By the time this draws, the errand has had a whole screen of its + * own — [SetupStep] — so what is left to do here is let someone re-read it + * without going back, not print it a second time under the field they are + * trying to fill in. + */ +@Composable +internal fun QuirkGuidance(quirk: ServerQuirk) { + when (quirk) { + ServerQuirk.FASTMAIL_APP_PASSWORD -> InstructionSteps( + title = stringResource(R.string.add_account_setup_fastmail_title), + steps = stringArrayResource(R.array.add_account_setup_fastmail_steps).asList(), + footnote = stringResource(R.string.add_account_setup_fastmail_footnote), + collapsible = true, + ) + + ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD -> InstructionSteps( + title = stringResource(R.string.add_account_setup_icloud_title), + steps = stringArrayResource(R.array.add_account_setup_icloud_steps).asList(), + footnote = stringResource(R.string.add_account_setup_icloud_footnote), + collapsible = true, + ) + + ServerQuirk.GOOGLE_UNSUPPORTED -> + QuirkNote(stringResource(R.string.add_account_quirk_google)) + + // A pre-flight warning for the engine, never something to read. + ServerQuirk.NEXTCLOUD_BRUTE_FORCE_PROTECTED -> Unit + } +} + +/** + * A single fact the user has to read — one that is not a procedure, so it is a + * card rather than an [InstructionSteps] list of one. + * + * ⚠️ Inset to `GroupedListInset`, like a grouped row. Without it the note ran to + * the screen edge while every row above it stopped 16dp short, so a caller that + * is already inside a padded column must not add its own — see [BrowserStep]. + */ +@Composable +internal fun QuirkNote(text: String) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = GroupedListInset), + // Not tertiaryContainer: under dynamic colour that is the low-chroma + // role, and against a light wallpaper-derived surface the card's own + // edge disappears even though its text pairing is fine. + color = MaterialTheme.colorScheme.secondaryContainer, + gapBelow = false, + ) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(GroupedListInset), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt new file mode 100644 index 0000000..c3a5232 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountScreen.kt @@ -0,0 +1,460 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.OpenInBrowser +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo +import de.jeanlucmakiola.floret.components.OnboardingProgress +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * Adding a CalDAV account: one flow, one back-stack entry. + * + * A stepper rather than several destinations, because the steps are not + * independently reachable — you cannot pick lists before signing in, and going + * "back" from the browser step means abandoning a server-side flow rather than + * popping a screen. The steps that *have* written nothing do step back, in the + * flow rather than out of it; [AddAccountViewModel.onBackWithin] decides which, + * and the shell falls out only when it says there is nowhere left to go. The + * back arrow and the system back gesture are the same action and run the same + * code — a stepper whose gesture skips the steps is a stepper with one way in + * and no way back. + * + * [stepOffset] and [totalSteps] let a longer flow host this one inline — first + * run offers the account before it has finished onboarding — so the wizard's + * four steps report their place in the *outer* progress rather than restarting + * the count at one. Left at their defaults it stands alone. + */ +@Composable +internal fun AddAccountScreen( + onDone: () -> Unit, + onBack: () -> Unit, + stepOffset: Int = 0, + // Null when the wizard stands alone: it then counts its own steps, which + // vary by provider. A host that draws one bar for a longer flow passes the + // whole denominator, having already asked the wizard how long it is. + totalSteps: Int? = null, + viewModel: AddAccountViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(state.step) { + if (state.step !is AddAccountStep.Done) return@LaunchedEffect + onDone() + // The ViewModel is scoped to the Settings back-stack entry and survives + // this section being hidden, so a finished flow left at Done would bounce + // the next "Add account" straight back out — and would reuse this + // account's username and app password for the next one. + viewModel.onStartOver() + } + + // Custom Tabs needs three things beyond launchUrl: the entry in the + // manifest (or provider detection silently finds nothing on API 30+), a + // fallback for devices with no Custom Tabs browser at all — realistic on + // GrapheneOS, CalyxOS and plain AOSP, which is disproportionately this app's + // audience — and an explicit way back, since a dismissed tab returns nothing. + LaunchedEffect(state.openInBrowser) { + val url = state.openInBrowser ?: return@LaunchedEffect + val uri = url.toString().toUri() + // ⚠️ Neither failure may escape, and neither may be ignored. An + // exception out of a LaunchedEffect takes the app down — and the outer + // catch named only ActivityNotFoundException, so a SecurityException + // from a locked-down profile did exactly that. Reporting the failure is + // the other half: clearing openInBrowser regardless left the user on + // "waiting for your browser" with a spinner and no browser, on the very + // devices this fallback exists for. + val launched = runCatching { + CustomTabsIntent.Builder().build().launchUrl(context, uri) + }.recoverCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, uri)) + }.isSuccess + if (launched) viewModel.onBrowserLaunched() else viewModel.onBrowserUnavailable() + } + + // Backing out abandons a flow that may still be polling the server every two + // seconds for the rest of its twenty-minute window. + val abandon = { + viewModel.onStartOver() + onBack() + } + + // One action, two ways to ask for it: step back inside the flow, and fall + // out of it only when there is nowhere left to go. + val goBack = { if (!viewModel.onBackWithin()) abandon() } + + // ⚠️ The system gesture has to be caught here, not left to the host. The + // hosts only put the section away — `SettingsScreen` sets `section = parent` + // — while this ViewModel is scoped to the Settings back-stack entry and + // outlives that. So a gesture that fell through left the flow *loaded*: + // reopening "Add account" landed back on the previous server's list picker, + // still holding its username, password and collections. Worse in + // `WaitingForBrowser`, where the two-second poll kept running with no screen + // attached for the rest of the twenty-minute window, and a password minted + // after that point was held unrevoked until Settings itself was left. + // Consuming it means [AddAccountViewModel.onStartOver] runs on every exit. + BackHandler(onBack = goBack) + + OnboardingScaffold( + hero = { StepHero(state) }, + // Grouped rows and fields carry their own inset, so the column adds none. + contentPadding = 0.dp, + topSpacing = OnboardingSpace.md, + progress = { + val position = state.step.position(state.hasSetupStep)?.plus(stepOffset) + val total = totalSteps ?: state.totalSteps + if (position != null) { + OnboardingProgress( + step = position, + total = total, + label = stringResource(R.string.add_account_step_of, position, total), + ) + } + }, + navigationIcon = { + IconButton(onClick = goBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { StepActions(state, viewModel) }, + ) { + val fatal = state.fatal + if (fatal != null) { + Text( + fatal.text(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) + return@OnboardingScaffold + } + + StepTitle(state.step) + + when (val step = state.step) { + is AddAccountStep.ChooseProvider -> ProviderStep(step, viewModel) + is AddAccountStep.PrepareAccess -> SetupStep(step) + is AddAccountStep.EnterAddress -> AddressStep(step, viewModel) + is AddAccountStep.Working -> WorkingStep(step) + is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel) + is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step) + is AddAccountStep.WaitingForBrowser -> BrowserStep(step) + is AddAccountStep.ChooseLists -> ListsStep(step, viewModel) + is AddAccountStep.Summary -> SummaryStep(step) + AddAccountStep.Done -> Unit + } + + // What is known about the chosen service, named before the attempt rather + // than after a 401 the user cannot act on. + // + // ⚠️ Not on the errand step — that step *is* this, and drawing it again + // underneath would print the same instructions twice. On the address step + // it is the fallback for the one route that skips the errand: picking + // "Other server" and then typing an address that turns out to be a + // Fastmail or iCloud one. + val note = when (state.step) { + is AddAccountStep.ChooseProvider, is AddAccountStep.EnterAddress -> state.quirk + else -> null + } + note?.let { + Spacer(Modifier.height(OnboardingSpace.md)) + QuirkGuidance(it) + } + + // Learned during the browser step and shown from there on, whichever + // step follows: the address it blames is one the user has to go and + // correct on the server, and it is just as true once the lists load. + state.originMismatch?.let { mismatch -> + QuirkNote( + text = stringResource( + R.string.add_account_origin_mismatch, + mismatch.actual, + mismatch.expected, + ), + ) + } + } +} + +/** + * Which of the flow's visible steps this is, or null for one that has no place. + * + * [hasSetup] shifts everything after the errand down by one, because the errand + * is a step of its own rather than a screen borrowing the address's number. + */ +private fun AddAccountStep.position(hasSetup: Boolean): Int? { + val errand = if (hasSetup) 1 else 0 + return when (this) { + is AddAccountStep.ChooseProvider -> 1 + is AddAccountStep.PrepareAccess -> 2 + is AddAccountStep.EnterAddress -> 2 + errand + // One slot, because only one of the three ever happens: a server either + // hands the sign-in to a browser — asking first, where the URL needs + // confirming — or asks for a password. + is AddAccountStep.EnterCredentials, + is AddAccountStep.ConfirmBrowser, + is AddAccountStep.WaitingForBrowser, + -> 3 + errand + is AddAccountStep.ChooseLists -> 4 + errand + // The receipt keeps the bar full rather than dropping it: the flow is + // finished, and a chrome that vanishes on the last screen reads as a + // step lost rather than a step done. + is AddAccountStep.Summary -> ADD_ACCOUNT_STEPS + errand + // Working is a moment inside whichever step spawned it, and Done is gone + // before it draws — neither is a place the user can be. + is AddAccountStep.Working, AddAccountStep.Done -> null + } +} + +/** + * The mark for the step, in the family's tonal circle. + * + * Once a service has been chosen it wears *that service's* logo instead — the + * same mark the accounts list will show — so the flow keeps saying what is being + * set up rather than restating that an account is being added. + */ +@Composable +private fun StepHero(state: AddAccountUiState) { + if (state.step is AddAccountStep.Summary) { + ProviderLogo(state.step.provider, size = 72.dp) + return + } + val chosen = (state.step as? AddAccountStep.EnterAddress)?.choice?.provider + ?: (state.step as? AddAccountStep.PrepareAccess)?.choice?.provider + if (chosen != null && state.fatal == null) { + ProviderLogo(chosen, size = 72.dp) + return + } + val icon = when { + state.fatal != null -> Icons.Rounded.CloudOff + state.step is AddAccountStep.EnterCredentials -> Icons.Rounded.Lock + // The warning is the screen, so it is the mark too — the browser icon + // would say "this is going fine", which is the opposite of the point. + state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning + state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser + state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist + else -> Icons.Rounded.CloudSync + } + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(34.dp), + ) + } +} + +/** Title and one line of explanation, centred above whatever the step asks for. */ +@Composable +private fun StepTitle(step: AddAccountStep) { + val (title, body) = when (step) { + is AddAccountStep.ChooseProvider -> + R.string.add_account_provider_title to R.string.add_account_provider_body + is AddAccountStep.PrepareAccess -> + step.quirk.setupTitle to R.string.add_account_setup_body + is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) { + R.string.add_account_email_title to R.string.add_account_email_body + } else { + R.string.add_account_server_title to R.string.add_account_server_body + } + is AddAccountStep.EnterCredentials -> + R.string.add_account_credentials_title to R.string.add_account_credentials_body + is AddAccountStep.ConfirmBrowser -> + R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body + is AddAccountStep.ChooseLists -> + R.string.add_account_lists_title to R.string.add_account_lists_body + is AddAccountStep.Summary -> + R.string.add_account_summary_title to R.string.add_account_summary_body + else -> return + } + Text( + stringResource(title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(OnboardingSpace.lg)) +} + +/** + * The step's call to action, pinned at the bottom the way the onboarding shell + * puts it. + * + * One primary action per step, always in the same place, so the flow reads as + * one screen advancing rather than five different ones. A step that commits + * nothing renders no button rather than a disabled one. + */ +@Composable +private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAccountViewModel) { + if (state.fatal != null) { + PrimaryAction( + label = stringResource(R.string.add_account_start_over), + enabled = true, + onClick = viewModel::onStartOver, + ) + return + } + + when (val step = state.step) { + // ⚠️ No action at all. Tapping a row *is* the choice and advances on the + // spot, so a Continue here would be a second press for the same decision + // — and a disabled one, before anything is picked, that looks like the + // screen is broken. + is AddAccountStep.ChooseProvider -> Unit + + is AddAccountStep.PrepareAccess -> PrimaryAction( + label = stringResource(R.string.add_account_continue), + enabled = true, + onClick = viewModel::onSetupAcknowledged, + ) + + is AddAccountStep.EnterAddress -> PrimaryAction( + label = stringResource(R.string.add_account_continue), + enabled = step.input.isNotBlank(), + onClick = viewModel::onAddressSubmitted, + ) + + is AddAccountStep.EnterCredentials -> PrimaryAction( + label = stringResource(R.string.add_account_sign_in), + enabled = step.username.isNotBlank() && step.password.isNotEmpty(), + onClick = viewModel::onCredentialsSubmitted, + ) + + is AddAccountStep.ChooseLists -> PrimaryAction( + // The count is the feedback: a disabled button with no explanation + // is the commonest way a picker looks broken. + label = if (step.selected.isEmpty()) { + stringResource(R.string.add_account_no_lists_selected) + } else { + stringResource(R.string.add_account_save) + }, + enabled = step.selected.isNotEmpty(), + onClick = viewModel::onSave, + ) + + is AddAccountStep.Summary -> PrimaryAction( + label = stringResource(R.string.add_account_summary_done), + enabled = true, + onClick = viewModel::onSummaryDone, + ) + + // The way forward stays primary: both causes are legitimate behind a + // reverse proxy, and refusing outright would make Login Flow v2 unusable + // for a large share of self-hosted installs. The password route is the + // secondary, exactly as it is one step later. + is AddAccountStep.ConfirmBrowser -> { + PrimaryAction( + label = stringResource(R.string.add_account_browser_open), + enabled = true, + onClick = viewModel::onBrowserConfirmed, + ) + TextButton( + onClick = viewModel::onBrowserCancelled, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.add_account_browser_use_password)) } + } + + is AddAccountStep.WaitingForBrowser -> { + // A failed flow is retried by default. With no browser a retry + // cannot help; while the server throttles or is in maintenance an + // immediate one only adds to it, so a password comes first. + val usePassword = @Composable { primary: Boolean -> + if (primary) { + PrimaryAction( + label = stringResource(R.string.add_account_browser_use_password), + enabled = true, + onClick = viewModel::onBrowserCancelled, + ) + } else { + TextButton(onClick = viewModel::onBrowserCancelled, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.add_account_browser_use_password)) + } + } + } + val retry = @Composable { primary: Boolean -> + if (primary) { + PrimaryAction( + label = stringResource(R.string.add_account_browser_retry), + enabled = true, + onClick = viewModel::onBrowserRetry, + ) + } else { + TextButton(onClick = viewModel::onBrowserRetry, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.add_account_browser_retry)) + } + } + } + when (step.error) { + null -> usePassword(false) + AddAccountMessage.BrowserUnavailable -> usePassword(true) + AddAccountMessage.BrowserRateLimited, AddAccountMessage.BrowserMaintenance -> { + usePassword(true) + retry(false) + } + else -> { + retry(true) + usePassword(false) + } + } + } + + is AddAccountStep.Working, AddAccountStep.Done -> Unit + } +} + diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt new file mode 100644 index 0000000..48d83c8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountText.kt @@ -0,0 +1,78 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.caldav.CalDavDiscovery +import de.jeanlucmakiola.caldav.ServerQuirk + +/** + * A discovery failure, in words rather than a status code. + * + * ⚠️ The server's own message never reaches the screen. The most common failure + * here is a **405** — what an ordinary web server answers to `PROPFIND`, and so + * what someone typing their *website* instead of their CalDAV address gets — and + * "HTTP 405 Method Not Allowed" tells them nothing they can act on. It is also + * untranslatable, and frequently not in a language they read. + */ +internal val CalDavDiscovery.Outcome.Cause.message: Int + get() = when (this) { + CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS -> R.string.add_account_error_not_an_address + CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav + CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar + CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable + CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure + CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars + CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server + } + +/** + * The same rule as [message], for the messages the flow itself produces. + * + * Kept here rather than on [AddAccountMessage] so the type stays a plain Kotlin + * one and the resource ids stay where the resources are. + */ +@Composable +internal fun AddAccountMessage.text(): String = when (this) { + AddAccountMessage.Progress.Discovering -> + stringResource(R.string.add_account_working_discovering) + AddAccountMessage.Progress.SigningIn -> + stringResource(R.string.add_account_working_signing_in) + AddAccountMessage.Progress.ReadingLists -> + stringResource(R.string.add_account_working_reading_lists) + AddAccountMessage.Progress.Saving -> + stringResource(R.string.add_account_working_saving) + AddAccountMessage.GoogleUnsupported -> + stringResource(R.string.add_account_error_google_unsupported) + AddAccountMessage.AlreadyExists -> + stringResource(R.string.add_account_error_already_exists) + AddAccountMessage.ExternalStorage -> + stringResource(R.string.accounts_external_storage) + AddAccountMessage.NoUsableLists -> + stringResource(R.string.add_account_error_no_usable_lists) + AddAccountMessage.NotSaved -> stringResource(R.string.add_account_error_not_saved) + AddAccountMessage.KeystoreRefused -> stringResource(R.string.add_account_error_keystore) + AddAccountMessage.CredentialsRejected -> + stringResource(R.string.add_account_error_credentials_rejected) + AddAccountMessage.BrowserApprovalExpired -> + stringResource(R.string.add_account_browser_error_expired) + AddAccountMessage.BrowserRateLimited -> + stringResource(R.string.add_account_browser_error_rate_limited) + AddAccountMessage.BrowserMaintenance -> + stringResource(R.string.add_account_browser_error_maintenance) + AddAccountMessage.BrowserFailed -> + stringResource(R.string.add_account_browser_error_failed) + AddAccountMessage.BrowserUnavailable -> + stringResource(R.string.add_account_browser_error_unavailable) + is AddAccountMessage.OutsideCredentialScope -> + stringResource(R.string.add_account_error_cross_domain, host) + is AddAccountMessage.Quirk -> when (quirk) { + ServerQuirk.FASTMAIL_APP_PASSWORD -> + stringResource(R.string.add_account_quirk_hint_fastmail) + ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD -> + stringResource(R.string.add_account_quirk_hint_icloud) + // Neither reaches a credentials step: Google is refused before it, and + // the brute-force note is a pre-flight warning. + else -> "" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt new file mode 100644 index 0000000..d102bee --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModel.kt @@ -0,0 +1,1393 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +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.caldav.CalDavDiscovery +import de.jeanlucmakiola.caldav.CalDavProvider +import de.jeanlucmakiola.caldav.NextcloudLoginFlow +import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.caldav.ServiceDiscovery +import de.jeanlucmakiola.caldav.TaskCollection +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import javax.inject.Inject + +/** + * Which service the account lives on. + * + * Picked before anything is typed, because it is the only thing that makes the + * next question answerable: a hosted service is reached by the email address the + * user already knows, while a server they run is reached by a URL only they have. + * Asking for "an address" without knowing which of the two it is was the flow's + * first dead end. + */ +sealed interface ProviderChoice { + + /** The service itself, or null for a server we know nothing about yet. */ + val provider: CalDavProvider? + + /** A service we know by name, and therefore by domain, quirk and sign-in route. */ + data class Service(override val provider: CalDavProvider) : ProviderChoice + + /** Anything else that speaks CalDAV. Identified after discovery, if at all. */ + data object OtherServer : ProviderChoice { + override val provider: CalDavProvider? get() = null + } +} + +/** Where the user is in adding an account. */ +sealed interface AddAccountStep { + + /** Step 1: pick the service. */ + data class ChooseProvider(val choice: ProviderChoice? = null) : AddAccountStep + + /** + * Step 2, first half: what to go and do before any of this will work. + * + * Its own screen rather than a note under the address field, because it is + * an errand in someone else's web app — the user leaves, does four things, + * and comes back — and an instruction you are meant to act on before typing + * does not belong underneath the thing you type into. + */ + data class PrepareAccess( + val choice: ProviderChoice, + val quirk: ServerQuirk, + ) : AddAccountStep + + /** Step 2, second half: type the one address the choice above asks for. */ + data class EnterAddress( + val choice: ProviderChoice, + val input: String = "", + /** A cause, not a message — the screen owns the wording. */ + val error: CalDavDiscovery.Outcome.Cause? = null, + ) : AddAccountStep + + data class Working(val message: AddAccountMessage.Progress) : AddAccountStep + + /** The server wants credentials and is not a Nextcloud we can hand to a browser. */ + data class EnterCredentials( + val username: String = "", + val password: String = "", + val error: AddAccountMessage? = null, + ) : AddAccountStep + + /** + * The flow has a login URL, and something about it has to be read *before* + * the browser opens. + * + * ⚠️ Its own step, not a note on [WaitingForBrowser]. The note and the + * launch used to arrive in the same state update, so the Custom Tab was + * already covering the screen by the time the warning drew — and + * [NextcloudLoginFlow.Flow]'s own doc claims "the UI confirms it with the + * user, quoting the cause". That page is where the **account** password is + * typed, so the confirmation has to come first or it is not a confirmation. + * + * Two things get here. A [hostMismatch] means the server sent the sign-in + * somewhere other than the address that was typed — legitimate behind a + * reverse proxy, which is why it is confirmed rather than refused. An + * [insecure] login URL means the password would travel in the clear: + * `requireSecureOrigin` cannot fire for it, because a typed `http://` base + * makes a cleartext login URL consistent rather than a downgrade. + */ + data class ConfirmBrowser( + val hostMismatch: NextcloudLoginFlow.HostMismatch? = null, + val insecure: Boolean = false, + ) : AddAccountStep + + /** + * The browser has the flow. We poll until the user approves, and offer an + * explicit way out — Custom Tabs return **no result** when dismissed, and + * Nextcloud's flow ends on a "you can close this window" page that never + * comes back to the app. + */ + data class WaitingForBrowser( + val hostMismatch: NextcloudLoginFlow.HostMismatch? = null, + val error: AddAccountMessage? = null, + ) : AddAccountStep + + data class ChooseLists( + val collections: List, + val selected: Set, + ) : AddAccountStep + + /** + * What the flow just built, before it hands back. + * + * The account is already saved by the time this draws — it is a receipt, not + * a confirmation. It exists because the wizard used to vanish on success, + * leaving no answer to "which lists did it take, and under what name?" short + * of going and finding the account again. + */ + data class Summary( + val provider: CalDavProvider?, + val title: String, + val secondary: String?, + val username: String, + val lists: List, + ) : AddAccountStep + + data object Done : AddAccountStep +} + +data class AddAccountUiState( + val step: AddAccountStep = AddAccountStep.ChooseProvider(), + /** + * A warning the user should read before going further — the three providers + * whose real failure is not "wrong password", and a server that sent the + * login flow to a different host than the one typed. + */ + val quirk: ServerQuirk? = null, + /** + * The login flow named an origin we could not have reached, and we used the + * one that answered instead. + * + * Sticky, unlike the per-step note: it is learned during the browser step + * but the setting it blames is what the user has to go and fix, so it has to + * survive into the steps after it. + */ + val originMismatch: NextcloudLoginFlow.HostMismatch? = null, + /** + * Whether this run includes the errand step, which not every service needs. + * + * The flow's length is therefore a property of the *choice*, not a constant: + * a service with an app password to mint is one screen longer than one + * without, and a counter that says "of 4" through a five-screen flow is + * simply wrong. + */ + val hasSetupStep: Boolean = false, + /** Set when the flow cannot continue at all; the UI offers only "start over". */ + val fatal: AddAccountMessage? = null, + /** Non-null once the browser flow has a URL to open. */ + val openInBrowser: HttpUrl? = null, + /** Signing an existing account in again, rather than adding one. */ + val reauthenticating: Boolean = false, +) { + /** How many numbered steps this run has, which the errand can push to five. */ + val totalSteps: Int get() = if (hasSetupStep) ADD_ACCOUNT_STEPS + 1 else ADD_ACCOUNT_STEPS +} + +/** The wizard's own visible steps, before a service adds an errand to them. */ +const val ADD_ACCOUNT_STEPS = 4 + +@HiltViewModel +class AddAccountViewModel @Inject constructor( + private val repository: AccountCreator, + private val gateway: CalDavGateway, + private val pendingFlow: LoginFlowRecord, + /** + * Where the wizard's own state lives, so process death is not the end of it. + * + * ⚠️ Every field below used to be a plain `var`, and the browser step makes + * process death ordinary rather than exotic: the Custom Tab is a separate + * task, so ours is a background process while the user approves, and the + * system kills those. [PendingLoginFlowStore] closed the half of that window + * *before* approval — a poll token can be reclaimed and the password revoked. + * This closes the half after it, where an already-minted one-shot password + * existed only in memory. + * + * ⚠️ What goes in here is deliberately not "everything". A password the user + * **typed** is not saved: it is their account password on the generic CalDAV + * route, retyping it costs one field, and saved state is written out by the + * system. A password the server **minted** is saved, because it is a + * device-scoped app password that cannot be re-obtained — the 200 that + * carried it is spent — and losing it leaves a live credential 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. Saved state dies with + * the task, so unlike a store of our own it cannot outlive the flow. + */ + private val handle: SavedStateHandle, + // ⚠️ Not viewModelScope. Handing the password back has to survive the + // ViewModel that minted it, and androidx closes viewModelScope *before* + // onCleared runs, so a launch there never executes its body. + @ApplicationScope private val appScope: CoroutineScope, +) : ViewModel() { + + private val mutableState = MutableStateFlow(AddAccountUiState()) + val state: StateFlow = mutableState.asStateFlow() + + /** + * The one write path for [state], so that [record] cannot be forgotten at + * one of the twenty-odd places the flow changes step. + * + * ⚠️ Synchronous, not a collector on [state]. The system asks for saved + * state on the main thread, and a recorder that only runs when the + * dispatcher next gets a turn is a recorder that loses whatever happened + * immediately before the kill — which is precisely the moment worth saving. + */ + private var current: AddAccountUiState + get() = mutableState.value + set(value) { + mutableState.value = value + record(value) + } + + private fun updateState(transform: (AddAccountUiState) -> AddAccountUiState) { + current = transform(current) + } + + private var choice: ProviderChoice? + get() = handle.get(KEY_CHOICE)?.let(::choiceOf) + set(value) { + handle[KEY_CHOICE] = value?.let { + when (it) { + is ProviderChoice.Service -> it.provider.name + ProviderChoice.OtherServer -> OTHER_SERVER_TOKEN + } + } + } + + /** + * What is in the address field, as opposed to [typedInput], which is what was + * last *submitted*. Kept so stepping back to the picker and forward again + * returns to a filled field rather than an empty one. + */ + private var addressInput: String + get() = handle[KEY_ADDRESS_INPUT] ?: "" + set(value) { handle[KEY_ADDRESS_INPUT] = value } + + private var typedInput: String + get() = handle[KEY_TYPED_INPUT] ?: "" + set(value) { handle[KEY_TYPED_INPUT] = value } + + private var serverRoot: HttpUrl? + get() = handle.get(KEY_SERVER_ROOT)?.toHttpUrlOrNull() + set(value) { handle[KEY_SERVER_ROOT] = value?.toString() } + + private var username: String + get() = handle[KEY_USERNAME] ?: "" + set(value) { handle[KEY_USERNAME] = value } + + /** The account being signed in again, or null when adding a new one. */ + private var reauthAccountId: Long? + get() = handle[KEY_REAUTH_ACCOUNT] + set(value) { handle[KEY_REAUTH_ACCOUNT] = value } + + /** The username the credentials step starts with on a re-authentication. */ + private var suggestedUsername: String + get() = handle[KEY_SUGGESTED_USERNAME] ?: "" + set(value) { handle[KEY_SUGGESTED_USERNAME] = value } + + /** + * ⚠️ Not saved, unlike everything around it. On the browser route this holds + * a minted app password, which [minted] saves for its own reasons; on the + * generic route it holds the password the user typed, and that is theirs + * rather than ours to write down. A restore that has a minted credential + * refills this from it, and one that does not lands on a field to type into. + */ + private var appPassword: String = "" + + /** + * ⚠️ Deliberately **not** saved, and re-derived instead. It carries a + * principal, several home sets and every collection the server listed, none + * of which is small and all of which the server will answer for again; a + * restore that needs it re-runs discovery with the credential we still hold. + * That also re-reads an ACL or a display name the server changed meanwhile, + * which a serialised snapshot could not. + */ + private var found: CalDavDiscovery.Outcome.Found? = null + private var pollJob: Job? = null + + /** + * A started flow waiting on the user's say-so, held for the length of + * [AddAccountStep.ConfirmBrowser]. + * + * Nothing has been written down for it and nothing has been minted, so + * dropping it is a complete exit: the server keeps a row nobody will ever + * approve, and it expires on its own in twenty minutes. + */ + private var unconfirmedFlow: NextcloudLoginFlow.Flow? = null + + /** The flow the browser was last sent to, for a retry while it is still open. */ + private var browserFlow: NextcloudLoginFlow.Flow? = null + + /** Which hosts asked for credentials, for the cross-domain diagnostic. */ + private var hostsNeedingAuth: List + get() = handle.get>(KEY_HOSTS_NEEDING_AUTH).orEmpty() + set(value) { handle[KEY_HOSTS_NEEDING_AUTH] = ArrayList(value) } + + /** + * A password the browser flow minted and nothing owns yet. + * + * Nextcloud returns it exactly once, so if we walk away without either + * saving or revoking it, it stays valid in the user's device list for ever — + * under the same name as every other attempt, so they cannot tell which one + * their working account uses and dare not prune any. Cleared without + * revoking only when [AccountCreator] takes ownership. + * + * ⚠️ The one secret that *is* saved, and the reason [handle] exists at all — + * see its doc for why this and not the typed password. + */ + private var minted: CalDavGateway.Credentials? + get() { + val password = handle.get(KEY_MINTED_PASSWORD) ?: return null + val origin = handle.get(KEY_MINTED_ORIGIN)?.toHttpUrlOrNull() ?: return null + return CalDavGateway.Credentials( + username = handle[KEY_MINTED_USERNAME] ?: "", + password = password, + origin = origin, + ) + } + set(value) { + handle[KEY_MINTED_USERNAME] = value?.username + handle[KEY_MINTED_PASSWORD] = value?.password + handle[KEY_MINTED_ORIGIN] = value?.origin?.toString() + } + + init { + restore() + } + + /** + * Step 1 → step 2, on the tap itself. + * + * ⚠️ No Continue. A picker whose rows already say what they are does not + * need a second confirmation of the same tap — the back arrow is the undo, + * and it costs one press rather than two. + * + * The exception is a service we cannot sync with at all. Google stays on the + * picker with its reason under it rather than ending the flow: picking the + * wrong service should cost one tap to correct, not a restart. + */ + fun onProviderChosen(picked: ProviderChoice) { + // A different service asks a different question, so the answer to the old + // one is not an answer to this one. + if (picked != choice) addressInput = "" + choice = picked + val quirk = picked.provider?.let(ServerQuirk::forProvider) + val setup = quirk?.hasSetupSteps == true + updateState { + it.copy( + step = when { + quirk?.isFatal == true -> AddAccountStep.ChooseProvider(picked) + setup -> AddAccountStep.PrepareAccess(picked, quirk) + else -> addressStep(addressInput) + }, + quirk = quirk, + // Set on the choice, so the counter is right on the very screen + // the errand appears on rather than one step later. + hasSetupStep = setup, + fatal = null, + ) + } + } + + /** + * Opens the flow on an existing account that needs signing in again: its + * service, its server and its username filled in, and the save aimed at the + * same account rather than a new one. + */ + fun startReauthentication(accountId: Long, principalUrl: String?, username: String?) { + onStartOver() + reauthAccountId = accountId + suggestedUsername = username.orEmpty() + val principal = principalUrl?.toHttpUrlOrNull() + val provider = principal?.let(CalDavProvider::forPrincipal) + ?.takeIf { it in CalDavProvider.selectable } + onProviderChosen(provider?.let(ProviderChoice::Service) ?: ProviderChoice.OtherServer) + addressInput = when { + provider?.hosted == true -> username?.takeIf { '@' in it }.orEmpty() + principal != null -> serverAddressOf(principal) + else -> "" + } + updateState { state -> + val step = state.step + state.copy( + step = if (step is AddAccountStep.EnterAddress) addressStep(addressInput) else step, + reauthenticating = true, + ) + } + } + + /** The errand is read; on to the address. */ + fun onSetupAcknowledged() { + if (current.step !is AddAccountStep.PrepareAccess) return + updateState { it.copy(step = addressStep(addressInput), fatal = null) } + } + + fun onAddressChanged(value: String) { + // The fallback matters: a step reached from a failure carries a choice + // the field would otherwise have no way to read, and without it the + // field goes dead rather than merely unlabelled. + val picked = choice ?: ProviderChoice.OtherServer + choice = picked + addressInput = value + updateState { + it.copy( + step = AddAccountStep.EnterAddress(picked, value), + // The service's own rule outranks the one the typed host implies: + // it was chosen deliberately, and it is known before a key is hit. + quirk = picked.provider?.let(ServerQuirk::forProvider) + ?: ServerQuirk.forInput(value), + ) + } + } + + fun onUsernameChanged(value: String) = updateCredentials { it.copy(username = value) } + + fun onPasswordChanged(value: String) = updateCredentials { it.copy(password = value) } + + /** Step 2 → discovery, unauthenticated. */ + fun onAddressSubmitted() { + val input = (current.step as? AddAccountStep.EnterAddress)?.input?.trim().orEmpty() + if (input.isEmpty()) return + // ⚠️ The retry path, and the one that actually leaks. A post-approval + // failure sends the user back here with an error, and the screen offers + // *Continue*, not start over — so a second attempt would mint a second + // password on top of the first without this. + discardMintedPassword() + // ⚠️ And the credential itself, not just the minted one. backToAddress is + // reachable *after* a successful approval — a post-approval discovery + // failure lands on an ordinary address step with a live Continue button + // — and only onStartOver cleared these. So: approve on server A, + // discovery fails, type server B, B answers anonymously, and onSave sees + // a non-blank password and creates me@B carrying A's credential. + username = "" + appPassword = "" + // Discovery belongs to the address that produced it, and so does the + // host list the diagnostics read. + found = null + hostsNeedingAuth = emptyList() + // As does a flow started for it and never opened. + unconfirmedFlow = null + // The mismatch is sticky on purpose, but it belongs to the address that + // produced it — carrying it into a different server's flow makes a claim + // about that server's settings which was never measured. + updateState { it.copy(originMismatch = null) } + typedInput = input + addressInput = input + + val quirk = ServerQuirk.forInput(input) + if (quirk?.isFatal == true) { + // Google supports neither VTODO nor MKCALENDAR. Refusing with an + // explanation beats a 401 the user cannot act on. + updateState { + it.copy( + step = addressStep(input), + fatal = AddAccountMessage.GoogleUnsupported, + quirk = quirk, + ) + } + return + } + + serverRoot = ServiceDiscovery.serverRootFor(input) + + working(AddAccountMessage.Progress.Discovering) + viewModelScope.launch { + when (val outcome = gateway.discover(typedInput)) { + is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) + + is CalDavDiscovery.Outcome.NeedsAuthentication -> { + hostsNeedingAuth = outcome.hosts + offerSignIn() + } + + // No credentials were sent, so this is not a rejection — it is a + // server that answers RFC 5397 on a 200 + // instead of a 401. Sending the user back to the address field + // would make it permanently unreachable. + CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn() + + is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) + + is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + } + } + } + + /** Step 3a → re-run discovery with the password the user typed. */ + fun onCredentialsSubmitted() { + val step = current.step as? AddAccountStep.EnterCredentials ?: return + if (step.username.isBlank() || step.password.isEmpty()) return + username = step.username.trim() + appPassword = step.password + + working(AddAccountMessage.Progress.SigningIn) + viewModelScope.launch { + // ⚠️ An error, not a silent unauthenticated retry. serverRootFor + // cannot parse a host-with-path like cloud.example.com/nextcloud, and + // passing null here sent no credentials at all — then reported the + // resulting 401 as "credentials rejected" about a password nothing + // had tried. + val root = serverRoot + ?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) + val credentials = CalDavGateway.Credentials(username, appPassword, root) + when (val outcome = gateway.discover(typedInput, credentials)) { + is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) + + // A second rejection is the point at which naming the provider's + // own rule is worth more than repeating "wrong password". + // + // ⚠️ Refreshed here too, not only on the browser path. An + // authenticated PROPFIND reaches further than the anonymous + // probe — principal, then home sets — so the cross-domain home + // set that actually caused this 401 is the one most likely to be + // missing from a list collected before the password was sent, + // and the user gets "credentials rejected" instead of the + // diagnostic that names it. + is CalDavDiscovery.Outcome.NeedsAuthentication -> { + hostsNeedingAuth = outcome.hosts + credentialsRejected() + } + + CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected() + + is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + } + } + } + + private fun credentialsRejected() = updateState { + it.copy( + step = AddAccountStep.EnterCredentials( + username = username, + password = "", + error = quirkHint() ?: crossDomainHint() + ?: AddAccountMessage.CredentialsRejected, + ), + ) + } + + /** Step 3b → the browser flow, if this looks like a Nextcloud. */ + private suspend fun offerSignIn() { + val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) + val flow = gateway.startLoginFlow(root) + + if (flow == null) { + // Not a Nextcloud, or its login flow is unavailable. Ask for a + // username and password instead — that is the generic CalDAV path. + updateState { + it.copy( + step = AddAccountStep.EnterCredentials( + username = suggestedUsername, + error = quirkHint(), + ), + ) + } + return + } + + // ⚠️ Confirmed before the browser, never alongside it. Everything below + // — the record, the URL, the poll — assumes the user has agreed to open + // this page, and for the two cases here they have not been asked yet. + // Nothing has been minted at this point: the flow is started but no + // approval can happen until somebody visits the login URL, so declining + // costs a request and nothing else. + val insecure = !flow.loginUrl.isHttps + if (flow.hostMismatch != null || insecure) { + unconfirmedFlow = flow + updateState { + it.copy( + step = AddAccountStep.ConfirmBrowser( + hostMismatch = flow.hostMismatch, + insecure = insecure, + ), + ) + } + return + } + launchBrowser(flow) + } + + /** The warning is read and the page is wanted anyway. */ + fun onBrowserConfirmed() { + val flow = unconfirmedFlow ?: return + unconfirmedFlow = null + viewModelScope.launch { launchBrowser(flow) } + } + + /** + * Hands [flow] to the browser and starts waiting for it. + * + * ⚠️ The record is written **here**, not where the flow was started, which + * is what [NextcloudLoginFlow.Flow]'s doc asks for: persist before launching + * the browser. Writing it at the confirmation step instead would leave a + * token behind for a page the user declined to open — a reclaim that can + * only ever poll a flow nobody will approve. + * + * ⚠️ Guarded: a DataStore write can throw, and this runs in viewModelScope, + * where an escaping exception takes the app down. A flow we failed to write + * down is a reclaim we will not get, not a reason to lose the sign-in in + * front of the user. + */ + private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) { + browserFlow = flow + runCatching { pendingFlow.remember(flow) } + updateState { + it.copy( + step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch), + openInBrowser = flow.loginUrl, + ) + } + startPolling(flow) + } + + private fun startPolling(flow: NextcloudLoginFlow.Flow) { + pollJob?.cancel() + pollJob = viewModelScope.launch { + // ⚠️ Bounded here, not just by the server's answer. `poll` reports + // Expired past the flow's deadline, but an unbounded `while (true)` + // makes this loop's termination somebody else's responsibility — and + // a gateway that keeps saying "pending" would have it poll forever. + repeat(MAX_POLL_ATTEMPTS) { + delay(POLL_INTERVAL_MILLIS) + // ⚠️ The *call*, not just what follows it. `pollLoginFlow` is a + // blocking execute() inside withContext, and withContext throws + // on return if the job was cancelled meanwhile — so a back + // gesture landing in that window discarded a 200 the server had + // already answered, and with it the only copy of a password it + // had already minted and deleted its flow row for. The persisted + // flow cannot reclaim that one either: the row is gone, so a + // later poll can only report expiry. Bounded by the gateway's + // own per-call budget, and the delay above stays cancellable, so + // the loop still stops promptly. + val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) } + when (result) { + is NextcloudLoginFlow.PollResult.Approved -> { + // ⚠️ Uncancellable. The 200 that carried this password is + // the only one there will ever be — the server deletes the + // flow row before answering — so a cancellation delivered + // between that answer and the assignment below spends the + // credential with nothing left holding it. Recording it is + // what makes handing it back possible. + withContext(NonCancellable) { + // ⚠️ Forgotten *here*, and nowhere else. This is the + // one point where the token stops being worth + // anything: the server deleted the flow row before + // answering, so there is nothing left to collect. + // Every other way out of this step — start over, the + // back arrow, a failure, leaving the screen — + // abandons a flow the user may still go on to + // approve in the browser that is still open, and the + // record is what lets the next launch collect that + // password and hand it back. A poll past the + // deadline reports expiry and clears it. + runCatching { pendingFlow.forget() } + browserFlow = null + username = result.credentials.loginName + appPassword = result.credentials.appPassword + serverRoot = result.credentials.server + // Ours to hand back until an account takes it on. + minted = CalDavGateway.Credentials( + username, + appPassword, + result.credentials.server, + ) + } + // The server contradicted itself about where it lives. + // We recovered, but the setting behind it is the user's + // to fix, so say so rather than silently coercing. + result.hostMismatch?.let { mismatch -> + updateState { it.copy(originMismatch = mismatch) } + } + working(AddAccountMessage.Progress.ReadingLists) + val outcome = gateway.discover( + // The server the credentials were *issued by*, not the + // host the user typed — the two differ in exactly the + // host-mismatch case this flow already models, and + // getting it wrong burns the one-shot app password. + target = result.credentials.server.toString(), + credentials = CalDavGateway.Credentials( + username, + appPassword, + result.credentials.server, + ), + ) + when (outcome) { + is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome) + // ⚠️ Forward what actually happened. Reporting every + // outcome as NO_CALENDARS tells someone whose server + // named an unresolvable host that their account holds + // no task lists, which is both wrong and unactionable. + is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) + // ⚠️ These two carry no Cause, so they used to land + // on "signed in, but no task lists" — said of a + // credential the server had just rejected. A 401 here + // is either a home set outside the domain the + // credential is scoped to, which retrying cannot fix + // and which only mints a second password, or the + // server having a moment. + is CalDavDiscovery.Outcome.NeedsAuthentication -> { + hostsNeedingAuth = outcome.hosts + val stranger = outsideCredentialScope(outcome.hosts) + if (stranger != null) { + fatal(AddAccountMessage.OutsideCredentialScope(stranger)) + } else { + backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) + } + } + + CalDavDiscovery.Outcome.Unauthenticated -> + backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) + } + return@launch + } + + NextcloudLoginFlow.PollResult.Pending -> Unit + + is NextcloudLoginFlow.PollResult.Expired -> { + browserFailed(AddAccountMessage.BrowserApprovalExpired) + return@launch + } + + is NextcloudLoginFlow.PollResult.Failed -> { + // ⚠️ The cause, never result.reason — that is a bare + // status line or a parser's complaint, in whatever + // language the server chose. It is for logs. + browserFailed( + when (result.cause) { + NextcloudLoginFlow.PollResult.Cause.RATE_LIMITED -> + AddAccountMessage.BrowserRateLimited + NextcloudLoginFlow.PollResult.Cause.MAINTENANCE -> + AddAccountMessage.BrowserMaintenance + NextcloudLoginFlow.PollResult.Cause.SERVER_ERROR -> + AddAccountMessage.BrowserFailed + }, + ) + return@launch + } + } + } + browserFailed(AddAccountMessage.BrowserApprovalExpired) + } + } + + /** + * Another go after the browser sign-in failed. + * + * An expired flow is gone on the server, so a new one is started. Any other + * failure leaves the flow approvable: the same page is reopened and polled, + * because starting a second one would replace the record that lets an + * approval in the first tab be collected and handed back. + */ + fun onBrowserRetry() { + val error = (current.step as? AddAccountStep.WaitingForBrowser)?.error ?: return + pollJob?.cancel() + val flow = browserFlow + if (flow != null && error != AddAccountMessage.BrowserApprovalExpired) { + viewModelScope.launch { launchBrowser(flow) } + } else { + working(AddAccountMessage.Progress.SigningIn) + viewModelScope.launch { offerSignIn() } + } + } + + /** The user says they finished in the browser but nothing arrived. */ + fun onBrowserCancelled() { + // The one exit that never reaches onStartOver: if approval already + // landed, that password is about to be replaced by a typed one. + discardMintedPassword() + // Also the way out of the confirmation, where the answer to "open this + // page?" is "no, I'll type a password instead". + unconfirmedFlow = null + browserFlow = null + pollJob?.cancel() + updateState { + it.copy( + step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null), + openInBrowser = null, + ) + } + } + + fun onBrowserLaunched() = updateState { it.copy(openInBrowser = null) } + + /** + * Nothing could open the URL, so there is no approval coming. + * + * ⚠️ Without this, a failed launch still cleared `openInBrowser` and left the + * user watching "waiting for your browser" for the rest of a twenty-minute + * window, on exactly the browserless devices the Custom Tabs fallback exists + * for. Nothing was minted — the flow was never opened — so there is nothing + * to hand back; the poll is stopped because it is polling for an approval + * that cannot happen. + */ + fun onBrowserUnavailable() { + pollJob?.cancel() + browserFailed(AddAccountMessage.BrowserUnavailable) + } + + fun onListToggled(url: HttpUrl) { + val step = current.step as? AddAccountStep.ChooseLists ?: return + val selected = if (url in step.selected) step.selected - url else step.selected + url + updateState { it.copy(step = step.copy(selected = selected)) } + } + + fun onSave() { + val step = current.step as? AddAccountStep.ChooseLists ?: return + val discovered = found ?: return + val chosen = step.collections.filter { it.url in step.selected }.toSet() + + if (username.isBlank() || appPassword.isEmpty()) { + // A server that needed no credentials at all would otherwise be saved + // with an empty username and password, and fail every later sync. + updateState { it.copy(step = AddAccountStep.EnterCredentials()) } + return + } + + working(AddAccountMessage.Progress.Saving) + viewModelScope.launch { + val outcome = runCatching { + repository.create( + displayName = accountName(), + username = username, + appPassword = appPassword, + found = discovered, + selected = chosen, + reauthenticating = reauthAccountId, + ) + }.getOrElse { + // A constraint violation or an IO failure in DataStore must not + // take the app down and leave the spinner up forever. + AccountRepository.Outcome.CredentialFailed( + AccountRepository.Outcome.Cause.NOT_SAVED, + it.message.orEmpty(), + ) + } + when (outcome) { + is AccountRepository.Outcome.Created -> { + // Ownership passes to the account, which revokes on removal — + // but only if this is the password it was saved with. Every + // route from a minted password to a typed one already + // discards on the way, so the else branch should be dead; + // it is here because the failure it guards is silent and + // delayed. The account syncs once and then 401s for ever, + // long after anyone would connect it to adding it. + // + // Cleared before the state update: an exception there must not + // leave a saved account's own credential queued for revoking. + if (minted?.password == appPassword) minted = null else discardMintedPassword() + updateState { it.copy(step = summaryOf(discovered, chosen)) } + } + + AccountRepository.Outcome.AlreadyExists -> + fatal(AddAccountMessage.AlreadyExists) + + AccountRepository.Outcome.ExternalStorage -> + fatal(AddAccountMessage.ExternalStorage) + + is AccountRepository.Outcome.CredentialFailed -> fatal( + when (outcome.cause) { + AccountRepository.Outcome.Cause.KEYSTORE_REFUSED -> + AddAccountMessage.KeystoreRefused + AccountRepository.Outcome.Cause.NOT_SAVED -> AddAccountMessage.NotSaved + }, + ) + } + } + } + + /** The receipt is read; hand back to whoever hosts the flow. */ + fun onSummaryDone() = updateState { it.copy(step = AddAccountStep.Done) } + + /** + * Step back inside the flow, or report that there is nowhere left to go. + * + * Only the two steps that have written nothing can be stepped back from. + * From the browser step onwards, "back" means abandoning a flow the server + * is still holding open, and from the list picker it would mean re-running + * discovery — both are the host's business, which is what `false` asks for. + * + * ⚠️ The credentials go with it. `EnterCredentials` is reachable *from* an + * approved browser flow, so stepping back to the address without this leaves + * a minted password behind and a typed address that could carry it to a + * different server — the same leak `onAddressSubmitted` guards. + */ + fun onBackWithin(): Boolean = when (current.step) { + // Back into the errand when there was one, so the steps can be re-read + // without starting the flow again — that is the screen people return to. + is AddAccountStep.EnterAddress -> { + updateState { it.copy(step = providerOrSetupStep(), fatal = null) } + true + } + + is AddAccountStep.PrepareAccess -> { + updateState { + it.copy(step = AddAccountStep.ChooseProvider(choice), fatal = null) + } + true + } + + is AddAccountStep.EnterCredentials -> { + discardMintedPassword() + username = "" + appPassword = "" + found = null + hostsNeedingAuth = emptyList() + updateState { + it.copy(step = addressStep(typedInput), fatal = null, originMismatch = null) + } + true + } + + // ⚠️ Steps back, unlike the browser step it precedes. The note it + // carries blames an address, and "that address is wrong" is the most + // likely reading of it — so the way out has to reach the field that + // holds it. Nothing has been approved, minted or written down yet, so + // this costs the flow the server is holding and nothing else. + is AddAccountStep.ConfirmBrowser -> { + unconfirmedFlow = null + updateState { it.copy(step = addressStep(typedInput), fatal = null) } + true + } + + else -> false + } + + /** Whatever precedes the address: the errand if the service sets one, else the picker. */ + private fun providerOrSetupStep(): AddAccountStep { + val picked = choice ?: return AddAccountStep.ChooseProvider(null) + val quirk = picked.provider?.let(ServerQuirk::forProvider) + return if (quirk?.hasSetupSteps == true) { + AddAccountStep.PrepareAccess(picked, quirk) + } else { + AddAccountStep.ChooseProvider(picked) + } + } + + /** + * Full reset, including the credentials. + * + * Called on "start over", on leaving the screen, and after a successful add. + * The ViewModel is scoped to the Settings back-stack entry and the flow is an + * `AnimatedVisibility` section inside it, so it outlives the composable: + * without this, re-opening "Add account" would find `step = Done` and bounce + * straight back out, and a second account would be created with the *first* + * account's username and app password. + */ + fun onStartOver() { + discardMintedPassword() + unconfirmedFlow = null + browserFlow = null + choice = null + pollJob?.cancel() + pollJob = null + found = null + addressInput = "" + typedInput = "" + serverRoot = null + username = "" + appPassword = "" + hostsNeedingAuth = emptyList() + reauthAccountId = null + suggestedUsername = "" + current = AddAccountUiState() + } + + override fun onCleared() { + abandonMintedPassword() + pollJob?.cancel() + } + + /** [onCleared] is protected, and the abandonment is worth testing. */ + internal fun abandonMintedPassword() = discardMintedPassword() + + /** + * Hand back a password nothing is going to use. + * + * Fire and forget: the user is already leaving, the call is best-effort by + * the module's own contract, and it carries its own 5s budget. A failure is + * not reported — telling someone we could not clean up a credential they + * never knew existed, while they are escaping a failure, is worse than the + * row it leaves behind. + */ + private fun discardMintedPassword() { + val credentials = minted ?: return + minted = null + // The scope has no exception handler, so anything escaping here takes the + // app down — for a courtesy call whose failures are deliberately silent. + appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials) } } + } + + // -------------------------------------------------------- saved state + + /** + * Writes down where the flow is, on every state change. + * + * A collector rather than a line in each of the fifteen places that update + * the state: one of those would eventually be added without it, and the + * failure is invisible until a process dies at exactly that step. + */ + private fun record(current: AddAccountUiState) { + handle[KEY_HAS_SETUP] = current.hasSetupStep + handle[KEY_STEP] = when (current.step) { + is AddAccountStep.ChooseProvider -> STEP_PROVIDER + is AddAccountStep.PrepareAccess -> STEP_SETUP + is AddAccountStep.EnterAddress -> STEP_ADDRESS + is AddAccountStep.EnterCredentials -> STEP_CREDENTIALS + // ⚠️ Recorded as the address, which is where a restore puts them. + // Neither can be resumed into: the browser is holding a flow this + // process no longer owns, and `PendingLoginFlowStore.reclaim` is + // what deals with that on the next open. Recording them as + // themselves would only mean deciding the same thing twice. + is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS + is AddAccountStep.Working -> STEP_WORKING + is AddAccountStep.ChooseLists -> STEP_LISTS + is AddAccountStep.Summary -> STEP_SUMMARY + AddAccountStep.Done -> STEP_DONE + } + // ⚠️ The username as it is being *typed*, not only as submitted. The + // field is the same thing [username] holds — `onCredentialsSubmitted` + // merely copies it across — and without this a restore from a + // half-filled sign-in came back with an empty name beside a password + // field, which reads as having lost both. + (current.step as? AddAccountStep.EnterCredentials)?.let { handle[KEY_USERNAME] = it.username } + // Null clears the key, so a step's payload never outlives the step. + handle[KEY_SELECTED] = (current.step as? AddAccountStep.ChooseLists) + ?.selected + ?.mapTo(ArrayList()) { it.toString() } + (current.step as? AddAccountStep.Summary).let { summary -> + handle[KEY_SUMMARY_PROVIDER] = summary?.provider?.name + handle[KEY_SUMMARY_TITLE] = summary?.title + handle[KEY_SUMMARY_SECONDARY] = summary?.secondary + handle[KEY_SUMMARY_LISTS] = summary?.lists?.let(::ArrayList) + } + } + + /** + * Picks the flow up where a killed process left it, or does nothing. + * + * Its own writes go back through [current] and so re-record what they just + * read, which is a no-op by construction — the step it restores to is the + * step it read, and a browser step deliberately records as the address it + * restores to. + */ + private fun restore() { + val step = handle.get(KEY_STEP) ?: return + val hasSetup: Boolean = handle[KEY_HAS_SETUP] ?: false + // A password the server minted before the process died is the account's + // password now; everything downstream reads it from here. + minted?.let { + username = it.username + appPassword = it.password + serverRoot = it.origin + } + when (step) { + STEP_SETUP -> { + val picked = choice + val quirk = picked?.provider?.let(ServerQuirk::forProvider) + current = if (picked != null && quirk?.hasSetupSteps == true) { + restored(AddAccountStep.PrepareAccess(picked, quirk), hasSetup) + } else { + restored(AddAccountStep.ChooseProvider(picked), hasSetup) + } + } + + STEP_ADDRESS -> current = restored(addressStep(addressInput), hasSetup) + + // The username comes back, the password does not — it is the one the + // user typed, and a restored password field is a surprise rather + // than a convenience. + STEP_CREDENTIALS -> + current = restored(AddAccountStep.EnterCredentials(username), hasSetup) + + // Both need `found`, which is not saved. One re-read of the server + // rebuilds it, and re-ticks whatever the user had ticked. + STEP_WORKING, STEP_LISTS -> resumeDiscovery(hasSetup) + + STEP_SUMMARY -> + current = restored(restoredSummary() ?: AddAccountStep.Done, hasSetup) + + STEP_DONE -> current = restored(AddAccountStep.Done, hasSetup) + + else -> current = restored(AddAccountStep.ChooseProvider(choice), hasSetup) + } + } + + private fun restored(step: AddAccountStep, hasSetup: Boolean) = AddAccountUiState( + step = step, + hasSetupStep = hasSetup, + reauthenticating = reauthAccountId != null, + // Derived rather than saved: it is a pure function of the choice and the + // address, both of which are. + quirk = choice?.provider?.let(ServerQuirk::forProvider) + ?: ServerQuirk.forInput(addressInput), + ) + + /** + * Re-reads the collections for a flow that died holding a credential. + * + * ⚠️ The credential is the whole test. Without one there is nothing to ask + * the server with, so the flow goes back to the address — which is the step + * that can get one — rather than to a spinner that will never resolve. + */ + private fun resumeDiscovery(hasSetup: Boolean) { + val root = serverRoot + val target = typedInput.ifBlank { root?.toString().orEmpty() } + if (root == null || appPassword.isEmpty() || target.isBlank()) { + current = restored(addressStep(addressInput), hasSetup) + return + } + val selection = handle.get>(KEY_SELECTED) + ?.mapNotNull { it.toHttpUrlOrNull() } + ?.toSet() + current = restored( + AddAccountStep.Working(AddAccountMessage.Progress.ReadingLists), + hasSetup, + ) + viewModelScope.launch { + val credentials = CalDavGateway.Credentials(username, appPassword, root) + when (val outcome = gateway.discover(target, credentials)) { + is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection) + is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause) + is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause) + // The credential we were holding is no longer accepted, which on + // this path is indistinguishable from never having had one. + is CalDavDiscovery.Outcome.NeedsAuthentication -> { + hostsNeedingAuth = outcome.hosts + credentialsRejected() + } + CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected() + } + } + } + + private fun restoredSummary(): AddAccountStep.Summary? { + val title = handle.get(KEY_SUMMARY_TITLE) ?: return null + return AddAccountStep.Summary( + provider = handle.get(KEY_SUMMARY_PROVIDER) + ?.let { name -> CalDavProvider.entries.firstOrNull { it.name == name } }, + title = title, + secondary = handle[KEY_SUMMARY_SECONDARY], + username = username, + lists = handle.get>(KEY_SUMMARY_LISTS).orEmpty(), + ) + } + + private fun choiceOf(token: String): ProviderChoice? = if (token == OTHER_SERVER_TOKEN) { + ProviderChoice.OtherServer + } else { + CalDavProvider.entries.firstOrNull { it.name == token }?.let(ProviderChoice::Service) + } + + // ------------------------------------------------------------- internals + + /** + * @param preselected what the user had ticked before a restore, or null on + * an ordinary run. Intersected with what the server still offers, so a + * collection that has since been unshared cannot come back ticked. + */ + private fun onDiscovered( + outcome: CalDavDiscovery.Outcome.Found, + preselected: Set? = null, + ) { + found = outcome + if (outcome.collections.isEmpty()) { + fatal(AddAccountMessage.NoUsableLists) + return + } + val offered = outcome.collections.map { it.url }.toSet() + updateState { + it.copy( + step = AddAccountStep.ChooseLists( + collections = outcome.collections, + // Everything writable, pre-ticked: the common case is "all of + // them", and a read-only share is more often noise than not. + selected = preselected?.intersect(offered) + ?: outcome.collections.filterNot { c -> c.readOnly } + .map { c -> c.url } + .toSet(), + ), + openInBrowser = null, + ) + } + } + + private fun working(message: AddAccountMessage.Progress) = + updateState { it.copy(step = AddAccountStep.Working(message), fatal = null) } + + /** + * A dead end. The step goes back to the address underneath, so the spinner is + * actually gone rather than merely hidden behind the message — the screen + * happens to render `fatal` first, and relying on that leaves the state + * lying about what it is doing. + */ + private fun fatal(reason: AddAccountMessage) = updateState { + it.copy(step = addressStep(typedInput), fatal = reason) + } + + /** + * ⚠️ Carries the [CalDavDiscovery.Outcome.Cause], never the detail string. + * + * The detail is the server's own words — routinely a bare status line, often + * in a language the user does not read, and always outside `strings.xml`. + * The screen turns the cause into a translated sentence; nothing renders the + * detail. + */ + private fun backToAddress(cause: CalDavDiscovery.Outcome.Cause) = updateState { + it.copy(step = addressStep(typedInput, cause)) + } + + /** + * The address step, carrying the choice that framed the question. + * + * The choice can only be null for a flow that never had a step 1 — nothing + * reaches this without one — and an unnamed server is the honest fallback. + */ + private fun addressStep( + input: String, + error: CalDavDiscovery.Outcome.Cause? = null, + ) = AddAccountStep.EnterAddress( + choice = choice ?: ProviderChoice.OtherServer, + input = input, + error = error, + ) + + /** What was actually set up, named the way the accounts list will name it. */ + private fun summaryOf( + discovered: CalDavDiscovery.Outcome.Found, + chosen: Set, + ): AddAccountStep.Summary { + // The principal, not the typed address: it is what the account stores and + // what identifies self-hosted software, so the receipt and the accounts + // list cannot disagree about what this account is. + val provider = CalDavProvider.forPrincipal(discovered.principal) + val host = discovered.principal.host.removePrefix("www.") + val hosted = provider?.hosted == true + return AddAccountStep.Summary( + provider = provider, + title = if (hosted) provider.label else host, + secondary = if (hosted) host else provider?.label, + username = username, + lists = chosen.map { it.displayName ?: it.url.encodedPath }, + ) + } + + private fun browserFailed(reason: AddAccountMessage) = updateState { + // Keeps whatever the step was carrying. The host-mismatch note is often + // the *explanation* for the failure, so dropping it removes the warning + // exactly when it becomes worth reading. + val step = it.step as? AddAccountStep.WaitingForBrowser + ?: AddAccountStep.WaitingForBrowser() + it.copy(step = step.copy(error = reason), openInBrowser = null) + } + + private fun updateCredentials(transform: (AddAccountStep.EnterCredentials) -> AddAccountStep.EnterCredentials) { + val step = current.step as? AddAccountStep.EnterCredentials ?: return + updateState { it.copy(step = transform(step)) } + } + + /** The provider-specific reason a correct-looking password gets rejected. */ + private fun quirkHint(): AddAccountMessage? = + when ( + val quirk = choice?.provider?.let(ServerQuirk::forProvider) + ?: ServerQuirk.forInput(typedInput) + ) { + ServerQuirk.FASTMAIL_APP_PASSWORD, + ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD, + -> AddAccountMessage.Quirk(quirk) + else -> null + } + + /** + * A home set on a different registrable domain than the account's own is + * legal (RFC 4791 §6.2.1) but unreachable for us: the credential is scoped to + * one domain. Better to name it than to leave "wrong password" standing. + */ + private fun crossDomainHint(): AddAccountMessage? = + outsideCredentialScope(hostsNeedingAuth)?.let { + AddAccountMessage.OutsideCredentialScope(it) + } + + /** + * The first host the credential will never be offered to, if any. + * + * ⚠️ The same boundary `CalDavHttp` scopes the credential by, derived the + * same way. A `substringAfter('.')` split computes "com" for + * `example.com` — so every host ending in `com` reads as in-domain, the + * diagnostic never fires, and `notexample.com` reads as in-domain too. + */ + private fun outsideCredentialScope(hosts: List): String? { + val root = serverRoot ?: return null + val scope = root.topPrivateDomain() ?: root.host + return hosts.firstOrNull { host -> + // OkHttp hands back IPv6 literals unbracketed, which will not parse + // again. Unparseable reads as in-scope: the cost of a diagnostic that + // stays quiet is nothing, and the caller now treats out-of-scope as + // fatal — so failing the other way strands a homelab on [::1]. + val literal = if (':' in host) "[$host]" else host + val candidate = "https://$literal".toHttpUrlOrNull() ?: return@firstOrNull false + !(candidate.topPrivateDomain() ?: candidate.host).equals(scope, ignoreCase = true) + } + } + + private fun accountName(): String = + username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" } + ?: typedInput + + internal companion object { + const val POLL_INTERVAL_MILLIS = 2_000L + + /** The server-side lifetime is 1200s; this covers it and then stops. */ + const val MAX_POLL_ATTEMPTS = 600 + + /** + * ⚠️ Tokens, not `Enum.name` or an ordinal. These strings are written + * into a bundle by one build and read back by whichever build the system + * hands the state to after an update, so they have to be stable against + * a step being renamed, added or reordered — and an unknown one has to + * be survivable, which is what `restore`'s `else` is for. + */ + const val STEP_PROVIDER = "provider" + const val STEP_SETUP = "setup" + const val STEP_ADDRESS = "address" + const val STEP_CREDENTIALS = "credentials" + const val STEP_WORKING = "working" + const val STEP_LISTS = "lists" + const val STEP_SUMMARY = "summary" + const val STEP_DONE = "done" + + /** Cannot collide with a [CalDavProvider] name, which is what it stands beside. */ + const val OTHER_SERVER_TOKEN = "other-server" + + const val KEY_STEP = "add_account.step" + const val KEY_CHOICE = "add_account.choice" + const val KEY_HAS_SETUP = "add_account.has_setup" + const val KEY_ADDRESS_INPUT = "add_account.address_input" + const val KEY_TYPED_INPUT = "add_account.typed_input" + const val KEY_SERVER_ROOT = "add_account.server_root" + const val KEY_USERNAME = "add_account.username" + const val KEY_HOSTS_NEEDING_AUTH = "add_account.hosts_needing_auth" + const val KEY_SELECTED = "add_account.selected" + const val KEY_MINTED_USERNAME = "add_account.minted_username" + const val KEY_MINTED_PASSWORD = "add_account.minted_password" + const val KEY_MINTED_ORIGIN = "add_account.minted_origin" + const val KEY_SUMMARY_PROVIDER = "add_account.summary_provider" + const val KEY_SUMMARY_TITLE = "add_account.summary_title" + const val KEY_SUMMARY_SECONDARY = "add_account.summary_secondary" + const val KEY_SUMMARY_LISTS = "add_account.summary_lists" + const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account" + const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username" + + /** + * What to type for a server, from the principal an account stored. + * + * The root, not the principal: Nextcloud's login flow hangs off the root, + * and on a subpath install that is everything before `/remote.php/`. + */ + internal fun serverAddressOf(principal: HttpUrl): String { + val url = principal.toString() + val dav = url.indexOf("/remote.php/") + return if (dav >= 0) url.substring(0, dav) else principal.resolve("/").toString().trimEnd('/') + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt new file mode 100644 index 0000000..2e28328 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddressStep.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R + +/** + * Step 2: the one address the chosen service actually needs. + * + * A hosted service is reached by the email address the user already knows, so it + * asks for that and nothing else; a server they run is reached by a URL only + * they have. The old single field had to ask for either, which is why its hint + * had to show both. + */ +@Composable +internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) { + val hosted = step.choice.provider?.hosted == true + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + FieldRow( + label = if (hosted) { + stringResource(R.string.add_account_email_label) + } else { + stringResource(R.string.add_account_server_label) + }, + value = step.input, + onValueChange = viewModel::onAddressChanged, + placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" } + ?: stringResource(R.string.add_account_server_hint), + error = step.error?.let { stringResource(it.message) }, + // An email address is still typed with the URI keyboard: it is the + // one that carries "@" and "." without a shift, and the field takes + // a URL too whenever a hosted service is reached by one. + keyboardType = KeyboardType.Uri, + onImeAction = viewModel::onAddressSubmitted, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt new file mode 100644 index 0000000..4b366df --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ListsStep.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf + +/** Step 4: which of the account's collections to keep in sync. */ +@Composable +internal fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) { + // The heading is the shell's; this step only lists. + step.collections.forEachIndexed { index, collection -> + val checked = collection.url in step.selected + GroupedRow( + title = collection.displayName ?: collection.url.encodedPath, + summary = when { + collection.readOnly -> stringResource(R.string.add_account_lists_read_only) + collection.isShared -> stringResource(R.string.add_account_lists_shared) + else -> null + }, + position = positionOf(index, step.collections.size), + selected = checked, + // The family marks a chosen row with a check, not a Material + // checkbox — same affordance every picker in the app uses. + trailing = { if (checked) SelectedCheck() }, + onClick = { viewModel.onListToggled(collection.url) }, + ) + } + Spacer(Modifier.height(16.dp)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt new file mode 100644 index 0000000..0f2a300 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/ProviderStep.kt @@ -0,0 +1,73 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo +import de.jeanlucmakiola.caldav.CalDavProvider +import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf + +/** + * Step 1: which service the tasks live on. + * + * The services we know by name, each by its own mark, and then the escape hatch + * for everything else. Choosing here is what lets step 2 ask one clear question + * instead of "an email address, or a server address, whichever you have" — and + * it is what puts a provider's app-password rule in front of the user *before* + * the 401 rather than after it. + */ +@Composable +internal fun ProviderStep(step: AddAccountStep.ChooseProvider, viewModel: AddAccountViewModel) { + val services = CalDavProvider.selectable + services.forEachIndexed { index, provider -> + val choice = ProviderChoice.Service(provider) + val chosen = step.choice == choice + // ⚠️ On the row, not behind a tap. Google is listed because people come + // looking for it — an absent row reads as the app being unfinished + // rather than as Google's own limitation — but a row that only reveals + // why it cannot be used *after* being selected is the worst of both: it + // looks available, then quietly refuses. + val unusable = ServerQuirk.forProvider(provider)?.isFatal == true + GroupedRow( + title = provider.label, + summary = when { + unusable -> stringResource(R.string.add_account_provider_unsupported) + else -> provider.primaryDomain + ?: stringResource(R.string.add_account_provider_self_hosted) + }, + position = positionOf(index, services.size), + selected = chosen && !unusable, + dimmed = unusable, + leading = { ProviderLogo(provider) }, + trailing = { if (chosen && !unusable) SelectedCheck() }, + // Still tappable: the summary is the short reason, and the note the + // tap brings up is the long one. + onClick = { viewModel.onProviderChosen(choice) }, + ) + } + + GroupedSectionHeader(stringResource(R.string.add_account_provider_other_header)) + val other = ProviderChoice.OtherServer + val otherChosen = step.choice == other + GroupedRow( + title = stringResource(R.string.add_account_provider_other), + summary = stringResource(R.string.add_account_provider_other_summary), + position = Position.Alone, + selected = otherChosen, + // Null is the family's "a server, unnamed" mark — exactly what this row + // is choosing. + leading = { ProviderLogo(provider = null) }, + trailing = { if (otherChosen) SelectedCheck() }, + onClick = { viewModel.onProviderChosen(other) }, + ) + Spacer(Modifier.height(16.dp)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt new file mode 100644 index 0000000..f0f3971 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SetupStep.kt @@ -0,0 +1,56 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.floret.components.InstructionSteps + +/** + * Step 2, first half: the errand the chosen service requires, before anything is + * typed. + * + * A screen of its own rather than a note under the address field. What it asks + * for happens somewhere else entirely — open a browser, sign in to the provider, + * mint an app password, come back — so it has to be read *before* the fields it + * makes answerable, and it needs the room to be four numbered actions rather + * than a paragraph. Underneath an input, it was neither. + * + * The list carries no header of its own: the step's headline already names the + * requirement, and repeating it above the rows says it twice. + */ +@Composable +internal fun SetupStep(step: AddAccountStep.PrepareAccess) { + InstructionSteps( + steps = stringArrayResource(step.quirk.steps).asList(), + footnote = stringResource(step.quirk.footnote), + ) + Spacer(Modifier.height(16.dp)) +} + +/** The headline for the errand — what the service requires, in one line. */ +internal val ServerQuirk.setupTitle: Int + get() = when (this) { + ServerQuirk.FASTMAIL_APP_PASSWORD -> R.string.add_account_setup_fastmail_title + else -> R.string.add_account_setup_icloud_title + } + +private val ServerQuirk.steps: Int + get() = when (this) { + ServerQuirk.FASTMAIL_APP_PASSWORD -> R.array.add_account_setup_fastmail_steps + // Only two quirks reach this screen — ServerQuirk.hasSetupSteps is what + // decides — so the branch that cannot happen takes the other one rather + // than inventing a third set of instructions to be wrong with. + else -> R.array.add_account_setup_icloud_steps + } + +private val ServerQuirk.footnote: Int + get() = when (this) { + ServerQuirk.FASTMAIL_APP_PASSWORD -> R.string.add_account_setup_fastmail_footnote + else -> R.string.add_account_setup_icloud_footnote + } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt new file mode 100644 index 0000000..1160710 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SignInStep.kt @@ -0,0 +1,167 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.Position + +/** + * Step 3a: a username and a password. + * + * The generic CalDAV route, taken whenever the server has no browser sign-in to + * hand off to. + */ +@Composable +internal fun CredentialsStep( + step: AddAccountStep.EnterCredentials, + viewModel: AddAccountViewModel, +) { + Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + // Two fields in one connected group, which is what the family does with + // a pair that is filled in together. + Column { + FieldRow( + label = stringResource(R.string.add_account_username_label), + value = step.username, + onValueChange = viewModel::onUsernameChanged, + position = Position.Top, + ) + FieldRow( + label = stringResource(R.string.add_account_password_label), + value = step.password, + onValueChange = viewModel::onPasswordChanged, + position = Position.Bottom, + error = step.error?.text(), + hint = stringResource(R.string.add_account_password_hint), + keyboardType = KeyboardType.Password, + onImeAction = viewModel::onCredentialsSubmitted, + ) + } + } +} + +/** + * Step 3b, first half: what is wrong with the page we are about to open. + * + * ⚠️ A screen of its own, drawn *before* the Custom Tab rather than behind it. + * The login URL is where the account password is typed, so a warning about it + * has to be readable at the moment it still means something — and the primary + * action stays the way forward, because both causes are legitimate on real + * deployments and refusing outright would make Login Flow v2 unusable behind an + * ordinary reverse proxy. + */ +@Composable +internal fun ColumnScope.ConfirmBrowserStep(step: AddAccountStep.ConfirmBrowser) { + // Title and body come from StepTitle like every other fixed step's do; what + // is left here is the cause, which is the whole reason the step exists. + step.hostMismatch?.let { mismatch -> + QuirkNote( + text = stringResource( + R.string.add_account_browser_host_mismatch, + mismatch.actual, + mismatch.expected, + ), + ) + } + + // Both can be true at once — a proxy that rewrites the host and drops TLS + // is one misconfiguration, not two — and each is worth its own sentence. + if (step.insecure) { + Spacer(Modifier.height(16.dp)) + QuirkNote(text = stringResource(R.string.add_account_browser_insecure)) + } +} + +/** + * Step 3b: the server is signing the user in, in a browser we do not own. + * + * The same slot as [CredentialsStep] because only one of the two ever happens — + * but its own title, mark and body, since "approve this in your browser" and + * "type your password" have nothing in common but their place in the flow. + */ +@Composable +internal fun ColumnScope.BrowserStep(step: AddAccountStep.WaitingForBrowser) { + // ⚠️ The title changes too. Swapping only the body left every failure in + // this phase — a burnt credential, a 429, a maintenance page — sitting + // under the heading "Waiting for your browser", which reads as "still + // working" when nothing is working and nothing else will happen. + Message( + icon = { + if (step.error == null) { + CircularProgressIndicator(Modifier.size(24.dp)) + } else { + Icon(Icons.Rounded.CloudOff, contentDescription = null) + } + }, + title = if (step.error == null) { + stringResource(R.string.add_account_browser_title) + } else { + stringResource(R.string.add_account_browser_failed_title) + }, + text = step.error?.text() ?: stringResource(R.string.add_account_browser_body), + ) + + // ⚠️ A sibling of the message, not a child of its padded column: QuirkNote + // carries the grouped-list inset itself, and nesting it inside one that + // already pads by the same amount insets it twice. + step.hostMismatch?.let { mismatch -> + Spacer(Modifier.height(16.dp)) + QuirkNote( + text = stringResource( + R.string.add_account_browser_host_mismatch, + mismatch.actual, + mismatch.expected, + ), + ) + } +} + +@Composable +internal fun WorkingStep(step: AddAccountStep.Working) { + Column( + Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text(step.message.text(), style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun Message( + icon: @Composable () -> Unit, + text: String, + title: String? = null, +) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + icon() + title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt new file mode 100644 index 0000000..746e149 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/add/SummaryStep.kt @@ -0,0 +1,81 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf + +/** + * The receipt: what the flow just set up, under the name the accounts list will + * use for it. + * + * Not a confirmation — the account is already saved, and there is nothing here + * to undo. It exists because the wizard used to vanish on success, leaving "did + * it take the right lists, and under which name?" answerable only by going and + * finding the account again. + */ +@Composable +internal fun SummaryStep(step: AddAccountStep.Summary) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ProviderLogo(step.provider, size = 48.dp) + Spacer(Modifier.width(16.dp)) + Column { + Text(step.title, style = MaterialTheme.typography.titleMedium) + // The account's own two supporting facts, in the order the accounts + // list gives them: what it is, then who you are on it. + listOfNotNull(step.secondary, step.username.takeIf { it.isNotBlank() }) + .forEach { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.height(24.dp)) + + GroupedSectionHeader( + pluralStringResource( + R.plurals.add_account_summary_lists, + step.lists.size, + step.lists.size, + ), + ) + step.lists.forEachIndexed { index, name -> + GroupedRow( + title = name, + position = positionOf(index, step.lists.size), + leading = { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + Spacer(Modifier.height(16.dp)) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt deleted file mode 100644 index 493f41f..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/DateTimeField.kt +++ /dev/null @@ -1,167 +0,0 @@ -package de.jeanlucmakiola.agendula.ui.common - -import de.jeanlucmakiola.floret.time.formatDateTime -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.Event -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TimePicker -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import de.jeanlucmakiola.agendula.R -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneId -import java.time.ZoneOffset -import kotlin.time.Instant - -private val zone: ZoneId get() = ZoneId.systemDefault() - -internal fun Instant.toLocalDate(): LocalDate = - java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate() - -internal fun Instant.toLocalTime(): LocalTime = - java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() - -internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = - Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) - -/** - * A labelled date(-time) field for the edit form: a tonal row showing the - * current value (or nothing), tappable to pick a date and — unless [allDay] — - * a time. A clear affordance appears once a value is set. Emits `null` when - * cleared. Styled to match the app's rounded tonal family. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DateTimeField( - label: String, - value: Instant?, - allDay: Boolean, - onChange: (Instant?) -> Unit, - modifier: Modifier = Modifier, -) { - var showDatePicker by remember { mutableStateOf(false) } - var showTimePicker by remember { mutableStateOf(false) } - var pendingDate by remember { mutableStateOf(null) } - - Surface( - onClick = { showDatePicker = true }, - shape = RoundedCornerShape(22.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set), - style = MaterialTheme.typography.bodyLarge, - ) - } - if (value != null) { - IconButton(onClick = { onChange(null) }) { - Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear)) - } - } - } - } - - if (showDatePicker) { - val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())) - .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() - val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) - DatePickerDialog( - onDismissRequest = { showDatePicker = false }, - confirmButton = { - TextButton( - onClick = { - showDatePicker = false - val millis = dateState.selectedDateMillis ?: return@TextButton - val date = java.time.Instant.ofEpochMilli(millis) - .atZone(ZoneOffset.UTC).toLocalDate() - if (allDay) { - onChange(localToInstant(date, LocalTime.MIDNIGHT)) - } else { - pendingDate = date - showTimePicker = true - } - }, - ) { Text(stringResource(android.R.string.ok)) } - }, - dismissButton = { - TextButton(onClick = { showDatePicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) { DatePicker(state = dateState) } - } - - if (showTimePicker) { - val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()) - val timeState = rememberTimePickerState( - initialHour = base.toLocalTime().hour, - initialMinute = base.toLocalTime().minute, - ) - Dialog(onDismissRequest = { showTimePicker = false }) { - Surface( - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - TimePicker(state = timeState) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { showTimePicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - TextButton(onClick = { - showTimePicker = false - val date = pendingDate ?: return@TextButton - onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute))) - }) { Text(stringResource(android.R.string.ok)) } - } - } - } - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ExactAlarms.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ExactAlarms.kt new file mode 100644 index 0000000..ee7aa12 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ExactAlarms.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.agendula.ui.common + +import android.app.AlarmManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.core.net.toUri + +/** Whether reminders can fire at their exact time; always true below API 31, where no grant exists. */ +fun Context.canScheduleExactAlarms(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + getSystemService(AlarmManager::class.java).canScheduleExactAlarms() + +/** The system page where the user grants or revokes exact alarms for this app. */ +@RequiresApi(Build.VERSION_CODES.S) +fun Context.exactAlarmSettingsIntent(): Intent = + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()) + +fun Context.openExactAlarmSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) runCatching { startActivity(exactAlarmSettingsIntent()) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt new file mode 100644 index 0000000..b376caf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/FormFieldVisuals.kt @@ -0,0 +1,37 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.rounded.AccountTree +import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.ui.graphics.vector.ImageVector +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.TaskFormField + +/** The label an optional edit-form section goes by, in the form and in Settings. */ +fun TaskFormField.labelRes(): Int = when (this) { + TaskFormField.Description -> R.string.field_description + TaskFormField.Recurrence -> R.string.edit_recurrence_label + TaskFormField.Priority -> R.string.edit_priority_label + TaskFormField.Progress -> R.string.edit_progress_label + TaskFormField.Parent -> R.string.edit_parent_label + TaskFormField.Reminder -> R.string.edit_reminder_label + TaskFormField.Location -> R.string.edit_location_label + TaskFormField.Url -> R.string.edit_url_label +} + +fun TaskFormField.icon(): ImageVector = when (this) { + TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes + TaskFormField.Recurrence -> Icons.Rounded.Repeat + TaskFormField.Priority -> Icons.Rounded.Flag + TaskFormField.Progress -> Icons.Rounded.Percent + TaskFormField.Parent -> Icons.Rounded.AccountTree + TaskFormField.Reminder -> Icons.Rounded.Notifications + TaskFormField.Location -> Icons.Rounded.LocationOn + TaskFormField.Url -> Icons.Rounded.Link +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/Linkify.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/Linkify.kt new file mode 100644 index 0000000..92de102 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/Linkify.kt @@ -0,0 +1,36 @@ +package de.jeanlucmakiola.agendula.ui.common + +import android.util.Patterns +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration + +/** [text] with every web address, e-mail address and phone number made tappable. */ +@Composable +fun linkified(text: String): AnnotatedString { + val color = MaterialTheme.colorScheme.primary + return remember(text, color) { + val styles = TextLinkStyles(SpanStyle(color = color, textDecoration = TextDecoration.Underline)) + val links = buildList { + Patterns.WEB_URL.matcher(text).let { m -> + while (m.find()) add(Triple(m.start(), m.end(), m.group().let { if ("://" in it) it else "https://$it" })) + } + Patterns.EMAIL_ADDRESS.matcher(text).let { m -> + while (m.find()) add(Triple(m.start(), m.end(), "mailto:${m.group()}")) + } + }.sortedBy { it.first } + .fold(emptyList>()) { kept, link -> + if (kept.lastOrNull()?.let { link.first < it.second } == true) kept else kept + link + } + buildAnnotatedString { + append(text) + links.forEach { (start, end, url) -> addLink(LinkAnnotation.Url(url, styles), start, end) } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt new file mode 100644 index 0000000..0ccf9fd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ListColors.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +/** + * The colours offered when creating or editing a task list. + * + * Raw ARGB, the way a CalDAV server sends one — every surface that draws a list + * colour runs it through + * [de.jeanlucmakiola.floret.components.pastelize] first, so these are hues + * rather than final fills, chosen to stay distinguishable after that pass. A + * list can still carry any colour a server gives it; this is only the set the + * app hands out. + */ +val ListPalette: List = listOf( + 0xFF7A5C6B.toInt(), // mauve — Agendula's own seed + 0xFFD7484A.toInt(), // red + 0xFFE8743B.toInt(), // orange + 0xFFE0A32E.toInt(), // amber + 0xFF7CA83E.toInt(), // olive + 0xFF35A06A.toInt(), // green + 0xFF19938C.toInt(), // teal + 0xFF2A9BC4.toInt(), // cyan + 0xFF3C74C8.toInt(), // blue + 0xFF6A5CC0.toInt(), // indigo + 0xFF9455B8.toInt(), // purple + 0xFFC94F8E.toInt(), // pink +) + +/** What a new list gets before the user picks anything. */ +val DefaultListColor: Int = ListPalette.first() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt new file mode 100644 index 0000000..b29f473 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/OnResume.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** + * Runs [block] on every `ON_RESUME`. + * + * For state the app cannot observe because it is granted, revoked or installed + * outside it — a runtime permission, an exact-alarm allowance, a provider app — + * which otherwise stays stale until the process restarts. + */ +@Composable +fun OnResume(block: () -> Unit) { + val current by rememberUpdatedState(block) + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) current() + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt new file mode 100644 index 0000000..be77de3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/PickerTime.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.agendula.ui.common + +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import kotlin.time.Instant + +/** + * Zone helpers shared by the date/time pickers. All-day conversions live in + * [de.jeanlucmakiola.agendula.domain.AllDayTime] — these cover the timed case, + * where the device zone is the right frame of reference. + */ + +private val zone: ZoneId get() = ZoneId.systemDefault() + +internal fun Instant.toLocalTime(): LocalTime = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() + +internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = + Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/RecurrenceText.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/RecurrenceText.kt new file mode 100644 index 0000000..1b1d501 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/RecurrenceText.kt @@ -0,0 +1,171 @@ +package de.jeanlucmakiola.agendula.ui.common + +import android.icu.text.ListFormatter +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.recurrence.SimpleRecurrence +import de.jeanlucmakiola.agendula.domain.recurrence.occurrencesSpanYears +import de.jeanlucmakiola.agendula.domain.recurrence.upcomingOccurrences +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.toJavaLocalDate +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale + +/** + * Humanise an RFC 5545 RRULE into a localized phrase, e.g. + * "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times". + * Rules we don't render in full (ordinal monthly/yearly BYDAY, …) fall back to + * a generic label. Weekday names are italicised. + */ +@Composable +fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { + val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> + val eq = token.indexOf('=') + if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1) + }.toMap() + + val freq = parts["FREQ"]?.uppercase() + val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 + val base = when (freq) { + "DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily) + else pluralStringResource(R.plurals.recurrence_every_n_days, interval, interval) + "WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly) + else pluralStringResource(R.plurals.recurrence_every_n_weeks, interval, interval) + "MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly) + else pluralStringResource(R.plurals.recurrence_every_n_months, interval, interval) + "YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly) + else pluralStringResource(R.plurals.recurrence_every_n_years, interval, interval) + else -> return AnnotatedString(stringResource(R.string.recurrence_generic)) + } + + // Weekly + BYDAY → " on "; other BYDAY forms keep just the base. + val byDay = parts["BYDAY"] + var dayNames: List? = null + var joinedDays: String? = null + val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) { + val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) } + if (days.isNotEmpty()) { + val joined = ListFormatter.getInstance(locale).format(days) + dayNames = days + joinedDays = joined + stringResource(R.string.recurrence_on_days, base, joined) + } else { + base + } + } else { + base + } + + // UNTIL takes precedence over COUNT. + val until = parts["UNTIL"]?.let { formatUntilDate(it, locale) } + val count = parts["COUNT"]?.toIntOrNull() + val full = when { + until != null -> stringResource(R.string.recurrence_with_until, main, until) + count != null -> pluralStringResource(R.plurals.recurrence_with_count, count, main, count) + else -> main + } + + return buildAnnotatedString { + append(full) + val names = dayNames + val joined = joinedDays + if (names != null && joined != null) { + // Only the names are italicised, not the separators or conjunction. + val regionStart = full.indexOf(joined) + if (regionStart >= 0) { + val regionEnd = regionStart + joined.length + var cursor = regionStart + for (name in names) { + val at = full.indexOf(name, cursor) + if (at in regionStart until regionEnd) { + addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length) + cursor = at + name.length + } + } + } + } + } +} + +/** + * The rule's first few dates as one line — "Next: 30 Jul, 6 Aug and 13 Aug" — + * for the recurrence picker: [recurrenceText] says what the rule is, this says + * what it does. A rule that yields nothing says so. + */ +@Composable +fun nextOccurrencesText( + rule: SimpleRecurrence, + start: kotlinx.datetime.LocalDate, + locale: Locale, +): String { + val dates = rule.upcomingOccurrences(start, limit = NEXT_OCCURRENCE_COUNT) + if (dates.isEmpty()) return stringResource(R.string.recurrence_next_none) + val pattern = if (occurrencesSpanYears(dates, start)) "dMMMy" else "dMMM" + val formatter = localizedDateFormatter(locale, pattern) + val formatted = dates.map { formatter.format(it.toJavaLocalDate()) } + return stringResource( + R.string.recurrence_next, + ListFormatter.getInstance(locale).format(formatted), + ) +} + +private const val NEXT_OCCURRENCE_COUNT = 3 + +/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */ +private fun rruleDayName(token: String, locale: Locale): String? { + val dow = when (token.takeLast(2).uppercase()) { + "MO" -> DayOfWeek.MONDAY + "TU" -> DayOfWeek.TUESDAY + "WE" -> DayOfWeek.WEDNESDAY + "TH" -> DayOfWeek.THURSDAY + "FR" -> DayOfWeek.FRIDAY + "SA" -> DayOfWeek.SATURDAY + "SU" -> DayOfWeek.SUNDAY + else -> return null + } + return dow.getDisplayName(JavaTextStyle.SHORT, locale) +} + +private fun formatUntilDate(raw: String, locale: Locale): String? { + val date = untilLocalDate(raw) ?: return null + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date) +} + +private val UNTIL_UTC_FORMAT: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + +/** + * The calendar day an RRULE UNTIL value denotes, in [zone]. The UTC form is + * converted back first — `toRRule` writes the end of the chosen local day in + * UTC, which for zones behind UTC already falls on the next UTC date. Date-only + * and floating forms pass through unchanged. + */ +internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? { + val value = raw.trim() + return runCatching { + LocalDateTime.parse(value, UNTIL_UTC_FORMAT) + .atOffset(ZoneOffset.UTC) + .atZoneSameInstant(zone) + .toLocalDate() + }.recoverCatching { + val digits = value.takeWhile { it.isDigit() } + LocalDate.of( + digits.substring(0, 4).toInt(), + digits.substring(4, 6).toInt(), + digits.substring(6, 8).toInt(), + ) + }.getOrNull() +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/RecurringScopeDialog.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/RecurringScopeDialog.kt new file mode 100644 index 0000000..1112dc7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/RecurringScopeDialog.kt @@ -0,0 +1,70 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope +import de.jeanlucmakiola.floret.components.OptionCard + +/** + * How far a write to a recurring task reaches: this occurrence, it and + * everything after, or the whole series. One of the two carve-outs from the + * full-screen picker rule. + * + * [allowOccurrence], [allowFollowing] and [allowSeries] drop options that do + * not apply; [reason] then says why. + */ +@Composable +fun RecurringScopeDialog( + title: String, + onSelect: (RecurringScope) -> Unit, + onDismiss: () -> Unit, + allowOccurrence: Boolean = true, + allowFollowing: Boolean = true, + allowSeries: Boolean = true, + reason: String? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (reason != null) { + Text( + text = reason, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (allowOccurrence) { + OptionCard( + label = stringResource(R.string.recurring_scope_occurrence), + onClick = { onSelect(RecurringScope.ThisOccurrence) }, + ) + } + if (allowFollowing) { + OptionCard( + label = stringResource(R.string.recurring_scope_following), + onClick = { onSelect(RecurringScope.ThisAndFollowing) }, + ) + } + if (allowSeries) { + OptionCard( + label = stringResource(R.string.recurring_scope_series), + onClick = { onSelect(RecurringScope.AllOccurrences) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt index 26bb89b..9fabff3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt @@ -10,6 +10,9 @@ import de.jeanlucmakiola.floret.reminders.ReminderUnit /** Common reminder lead times offered as quick picks in the reminder pickers. */ val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440) +/** Day-scale lead times for all-day tasks: on the day, 1 and 2 days, 1 week before. */ +val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080) + @StringRes fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) { ReminderUnit.Minutes -> R.string.reminder_unit_minutes @@ -21,11 +24,12 @@ fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) { /** * Humanise a reminder lead time (minutes before due) into one line: "At time of * task" (0), "10 minutes before", "1 hour before", … Shared by the reminder - * pickers and the settings summaries so the wording never drifts. Ported from - * Calendula. + * pickers and the settings summaries so the wording never drifts. For an + * [allDay] task, 0 reads "On the day". Ported from Calendula. */ @Composable -fun reminderLeadTimeLabel(minutes: Int): String = when { +fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when { + minutes <= 0 && allDay -> stringResource(R.string.reminder_on_the_day) minutes <= 0 -> stringResource(R.string.reminder_at_due) minutes % 10_080 == 0 -> pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt index fe68cac..cb1f4ea 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt @@ -5,6 +5,11 @@ import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes @@ -41,8 +46,8 @@ import de.jeanlucmakiola.agendula.R * Reminder lead-time picker, full-screen: the grouped list of preset lead times * plus a "Custom" row that expands an inline amount field + unit selector. * [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds - * a "None" row. Returns the choice as a [ReminderOverride]. Ported from - * Calendula's ReminderDefaultPicker. + * a "None" row; [allDay] switches to day-scale presets and wording. Returns the + * choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker. */ @Composable fun ReminderLeadPicker( @@ -52,7 +57,8 @@ fun ReminderLeadPicker( allowNone: Boolean, onSelect: (ReminderOverride) -> Unit, onDismiss: () -> Unit, - presets: List = REMINDER_PRESETS, + allDay: Boolean = false, + presets: List = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, ) { // Agendula is single-reminder: an override carries a one-element list, so // take the single value for this single-select picker. @@ -75,7 +81,7 @@ fun ReminderLeadPicker( options.forEachIndexed { index, option -> val isSelected = option == selected GroupedRow( - title = reminderOverrideLabel(option), + title = reminderOverrideLabel(option, allDay), position = positionOf(index, rowCount), selected = isSelected, onClick = { @@ -88,7 +94,7 @@ fun ReminderLeadPicker( // two read as one grouped container. GroupedRow( title = if (customSelected) { - stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!)) + stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!, allDay)) } else { stringResource(R.string.reminder_custom) }, @@ -115,6 +121,108 @@ fun ReminderLeadPicker( } } +/** + * Default-reminder picker, full-screen and **multi-select**: each preset lead + * time (plus any custom one chosen) toggles on its own, so a default can carry + * several reminders — 30 minutes before *and* at due time. "No reminder" and, for + * a per-list picker ([allowInherit]), "Use default" sit above as exclusive rows. + * Clearing the last time falls back to "Use default" on a per-list picker and to + * "No reminder" on the global one. Changes apply live; the user leaves via back. + * [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second + * line to each lead-time row. Ported from Calendula's ReminderDefaultPicker. + */ +@Composable +fun ReminderDefaultPicker( + title: String, + selected: ReminderOverride, + allowInherit: Boolean, + onSelect: (ReminderOverride) -> Unit, + onDismiss: () -> Unit, + allDay: Boolean = false, + presets: List = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS, + leadTimeSummary: String? = null, +) { + // Local state is authoritative once edited, so quick toggles compose on each + // other instead of racing the round-trip through the settings flow. + var current by remember { mutableStateOf(selected) } + var userEdited by remember { mutableStateOf(false) } + LaunchedEffect(selected) { if (!userEdited) current = selected } + val inherits = current is ReminderOverride.Inherit + val isNone = current is ReminderOverride.None + val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty() + // Custom values seen this session keep their row once unchecked. + val seenCustom = remember { mutableSetOf() } + seenCustom += selectedMinutes.filter { it !in presets } + val rows = presets + seenCustom.sorted() + + var customExpanded by rememberSaveable { mutableStateOf(false) } + var amountText by rememberSaveable { mutableStateOf("") } + var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } + + fun apply(override: ReminderOverride) { + userEdited = true + current = override + onSelect(override) + } + fun emit(minutes: List) = apply(reminderOverrideForMinutes(minutes, allowInherit)) + fun toggle(minute: Int) = + emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute) + + FullScreenPicker(title = title, onDismiss = onDismiss) { + if (allowInherit) { + GroupedRow( + title = stringResource(R.string.reminder_use_default), + position = Position.Top, + selected = inherits, + trailing = if (inherits) ({ SelectedCheck() }) else null, + onClick = { apply(ReminderOverride.Inherit) }, + ) + } + GroupedRow( + title = stringResource(R.string.reminder_none), + position = if (allowInherit) Position.Bottom else Position.Alone, + selected = isNone, + trailing = if (isNone) ({ SelectedCheck() }) else null, + onClick = { apply(ReminderOverride.None) }, + ) + Spacer(Modifier.height(24.dp)) + val rowCount = rows.size + 1 // + the custom row + rows.forEachIndexed { index, minute -> + val checked = minute in selectedMinutes + GroupedRow( + title = reminderLeadTimeLabel(minute, allDay), + summary = leadTimeSummary, + position = positionOf(index, rowCount), + selected = checked, + trailing = if (checked) ({ SelectedCheck() }) else null, + onClick = { toggle(minute) }, + ) + } + GroupedRow( + title = stringResource(R.string.reminder_custom), + position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount), + onClick = { customExpanded = !customExpanded }, + ) + AnimatedVisibility( + visible = customExpanded, + enter = expandEnter(), + exit = collapseExit(), + ) { + CustomReminderEditor( + amountText = amountText, + onAmountChange = { amountText = it }, + unit = unit, + onUnitChange = { unit = it }, + onConfirm = { minutes -> + emit(selectedMinutes + minutes) + amountText = "" + customExpanded = false + }, + ) + } + } +} + @Composable private fun CustomReminderEditor( amountText: String, @@ -167,8 +275,8 @@ private fun CustomReminderEditor( } @Composable -private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) { +private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) { ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) ReminderOverride.None -> stringResource(R.string.reminder_none) - is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first()) + is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes.first(), allDay) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt index f7ddf99..779e37a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ShapedActionButton.kt @@ -1,6 +1,8 @@ package de.jeanlucmakiola.agendula.ui.common +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box @@ -10,19 +12,33 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.toPath import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph import androidx.graphics.shapes.RoundedPolygon +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch /** * A top-bar action whose icon sits in a tonal container clipped to one of the @@ -35,6 +51,11 @@ import androidx.graphics.shapes.RoundedPolygon * container is what dips and spins, while the glyph inside stays upright (a * spinning magnifier or list icon would just read as wrong). [spinIcon] opts a * glyph into turning too, for icons that read well mid-spin like the gear. + * + * With [morphTo] set the press is a different gesture altogether: no turn and no + * dip — the container morphs into [morphTo] and pops slightly larger on a bouncy + * spring, for an action that should not feel like its neighbours. A tap holds + * the popped state for a beat before [onClick] fires, so the gesture is seen. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -49,9 +70,14 @@ fun ShapedActionButton( size: Dp = 40.dp, iconSize: Dp = 22.dp, spinIcon: Boolean = false, + morphTo: RoundedPolygon? = null, ) { val interaction = remember { MutableInteractionSource() } val pressed by interaction.collectIsPressedAsState() + if (morphTo != null) { + MorphPopButton(shape, morphTo, icon, contentDescription, onClick, modifier, containerColor, contentColor, size, iconSize, interaction, pressed) + return + } // The shape (the scalloped cookie) turns and dips on press. val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation") val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale") @@ -86,6 +112,69 @@ fun ShapedActionButton( } } +@Composable +private fun MorphPopButton( + shape: RoundedPolygon, + morphTo: RoundedPolygon, + icon: ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + size: Dp, + iconSize: Dp, + interaction: MutableInteractionSource, + pressed: Boolean, +) { + val morph = remember(shape, morphTo) { Morph(shape, morphTo) } + val scope = rememberCoroutineScope() + var firing by remember { mutableStateOf(false) } + val popped = pressed || firing + val bouncy = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium) + val progress by animateFloatAsState(if (popped) 1f else 0f, bouncy, label = "morphProgress") + val scale by animateFloatAsState(if (popped) 1.12f else 1f, bouncy, label = "popScale") + val iconScale by animateFloatAsState(if (popped) 1.2f else 1f, bouncy, label = "iconScale") + + Surface( + onClick = { + if (!firing) { + firing = true + scope.launch { + delay(MorphClickDelayMs) + onClick() + firing = false + } + } + }, + modifier = modifier.size(size).scale(scale), + shape = remember(morph, progress) { MorphShape(morph, progress) }, + color = containerColor, + contentColor = contentColor, + interactionSource = interaction, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(iconSize).scale(iconScale), + ) + } + } +} + +private const val MorphClickDelayMs = 160L + +/** [morph] at [progress], scaled from the shapes' unit square to the component. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +private class MorphShape(private val morph: Morph, private val progress: Float) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val path = morph.toPath(progress.coerceIn(0f, 1f), Path()) + path.transform(Matrix().apply { scale(size.width, size.height) }) + return Outline.Generic(path) + } +} + /** * Named shapes for the app's top-bar actions, so each action is recognisably * "its own" and new actions just pick the next unused shape. Expose the @@ -98,4 +187,10 @@ object ActionShapes { /** Search — a 6-sided cookie, the same family as [Settings] but distinct. */ val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided + + /** New list — a sunny burst beside the Lists header. */ + val AddList: RoundedPolygon get() = MaterialShapes.Sunny + + /** What [AddList] morphs into on press. */ + val AddListPressed: RoundedPolygon get() = MaterialShapes.Circle } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TimeFormat.kt new file mode 100644 index 0000000..3dbee55 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TimeFormat.kt @@ -0,0 +1,25 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.runtime.staticCompositionLocalOf +import java.time.DayOfWeek +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.temporal.WeekFields +import java.util.Locale + +/** + * The resolved clock convention for the whole UI, provided at the app root from + * the time-format setting and the device's own 24-hour switch. + */ +val LocalUse24HourFormat = staticCompositionLocalOf { true } + +/** The resolved first day of the week, provided at the app root from the week-start setting. */ +val LocalFirstDayOfWeek = staticCompositionLocalOf { WeekFields.of(Locale.getDefault()).firstDayOfWeek } + +/** A minute-of-day as a clock time: "14:00" or "2:00 PM". */ +fun formatMinuteOfDay(minuteOfDay: Int, is24Hour: Boolean, locale: Locale = Locale.getDefault()): String = + LocalTime.of(minuteOfDay / 60 % 24, minuteOfDay % 60) + .format(DateTimeFormatter.ofPattern(if (is24Hour) "HH:mm" else "h:mm a", locale)) + +/** The locale's first day of the week, for a week start left on automatic. */ +fun localeFirstDayOfWeek(locale: Locale = Locale.getDefault()): DayOfWeek = WeekFields.of(locale).firstDayOfWeek diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TimePickerAlert.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TimePickerAlert.kt new file mode 100644 index 0000000..5e4f865 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/TimePickerAlert.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import java.time.LocalTime + +/** M3 time picker in an alert dialog, seeded with [initial], in the app's 12/24-hour convention. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimePickerAlert( + initial: LocalTime, + onConfirm: (LocalTime) -> Unit, + onDismiss: () -> Unit, +) { + val state = rememberTimePickerState( + initialHour = initial.hour, + initialMinute = initial.minute, + is24Hour = LocalUse24HourFormat.current, + ) + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { onConfirm(LocalTime.of(state.hour, state.minute)) }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + text = { TimePicker(state = state) }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt index 060f4f9..0bf1661 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailScreen.kt @@ -37,7 +37,33 @@ import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Schedule +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Checkbox +import androidx.compose.material3.TextButton +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.pluralStringResource +import androidx.core.net.toUri +import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope +import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog +import de.jeanlucmakiola.agendula.ui.common.linkified +import de.jeanlucmakiola.agendula.ui.common.recurrenceText +import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.locale.currentLocale import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -65,9 +91,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.readableDescription import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.floret.components.GroupedSurface @@ -86,14 +113,16 @@ import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel @OptIn(ExperimentalMaterial3Api::class) @Composable fun TaskDetailScreen( - onEdit: () -> Unit, + onEdit: (Task) -> Unit, onDeleted: () -> Unit, onBack: () -> Unit, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, + onDuplicated: (Long) -> Unit, modifier: Modifier = Modifier, viewModel: TaskDetailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + var confirmDelete by rememberSaveable { mutableStateOf(false) } Scaffold( modifier = modifier, @@ -110,20 +139,48 @@ fun TaskDetailScreen( }, actions = { val content = state as? TaskDetailUiState.Content - IconButton(onClick = onEdit, enabled = content != null) { + // A read-only share: an edit would never reach the server. + val writable = content != null && !content.detail.task.isReadOnly + IconButton(onClick = { content?.let { onEdit(it.detail.task) } }, enabled = writable) { Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit)) } IconButton( - onClick = { - content?.let { - viewModel.delete(it.detail.task) - onDeleted() - } - }, - enabled = content != null, + onClick = { confirmDelete = true }, + enabled = writable, ) { Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete)) } + // On an occurrence of a series, cancelling reaches only that occurrence. + val task = content?.detail?.task + if (task != null) { + var menuOpen by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Rounded.MoreVert, contentDescription = stringResource(R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + val cancelled = task.status == TaskStatus.CANCELLED + DropdownMenuItem( + text = { + Text(stringResource(if (cancelled) R.string.detail_restore else R.string.detail_cancel_task)) + }, + onClick = { + menuOpen = false + viewModel.setCancelled(task, !cancelled) + }, + ) + if (writable) { + DropdownMenuItem( + text = { Text(stringResource(R.string.detail_duplicate)) }, + onClick = { + menuOpen = false + viewModel.duplicate(task, onDuplicated) + }, + ) + } + } + } + } }, ) }, @@ -135,6 +192,7 @@ fun TaskDetailScreen( is TaskDetailUiState.Content -> DetailBody( detail = s.detail, inner = inner, + reminders = s.reminders, onToggle = { viewModel.toggleComplete(s.detail.task) }, onToggleSubtask = { viewModel.toggleComplete(it) }, onAddSubtask = { viewModel.addSubtask(it) }, @@ -142,16 +200,55 @@ fun TaskDetailScreen( ) } } + + val content = state as? TaskDetailUiState.Content + if (confirmDelete && content != null) { + val task = content.detail.task + val delete = { scope: RecurringScope -> + confirmDelete = false + viewModel.delete(task, scope) + onDeleted() + } + if (task.isOccurrence) { + RecurringScopeDialog( + title = stringResource(R.string.detail_delete_recurring_title), + onSelect = delete, + onDismiss = { confirmDelete = false }, + ) + } else { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(stringResource(R.string.detail_delete_title)) }, + text = { + Text( + if (content.detail.subtasks.isEmpty()) stringResource(R.string.detail_delete_body) + else pluralStringResource( + R.plurals.detail_delete_body_subtasks, + content.detail.subtasks.size, + content.detail.subtasks.size, + ), + ) + }, + confirmButton = { + TextButton(onClick = { delete(RecurringScope.AllOccurrences) }) { Text(stringResource(R.string.delete)) } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) + } + } } @Composable private fun DetailBody( detail: TaskDetail, inner: androidx.compose.foundation.layout.PaddingValues, + reminders: List, onToggle: () -> Unit, onToggleSubtask: (Task) -> Unit, onAddSubtask: (String) -> Unit, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, ) { val task = detail.task val dark = isSystemInDarkTheme() @@ -171,7 +268,11 @@ private fun DetailBody( ) { // Title + complete toggle, with a short list-coloured accent beneath. Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) + Checkbox( + checked = task.isCompleted, + onCheckedChange = { onToggle() }, + enabled = !task.isReadOnly, + ) Text( text = task.title.ifBlank { stringResource(R.string.task_untitled) }, style = MaterialTheme.typography.headlineMedium, @@ -199,7 +300,7 @@ private fun DetailBody( DetailCard( icon = Icons.Rounded.AccountTree, iconContentDescription = stringResource(R.string.detail_parent), - onClick = { onOpenTask(parent.taskId) }, + onClick = { onOpenTask(parent) }, ) { Text( text = stringResource(R.string.detail_part_of), @@ -231,6 +332,43 @@ private fun DetailBody( } } + task.recurrenceRule?.let { rule -> + add { + val locale = currentLocale() + DetailCard(icon = Icons.Rounded.Repeat, iconContentDescription = stringResource(R.string.edit_recurrence_label)) { + Text(recurrenceText(rule, locale), style = MaterialTheme.typography.titleMedium) + } + } + } + + if (reminders.isNotEmpty() && (task.due != null || task.start != null)) { + add { + DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) { + reminders.forEach { reminder -> + Text( + if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore)) + else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + } + + if (task.status == TaskStatus.CANCELLED || task.status == TaskStatus.IN_PROCESS) { + add { + DetailCard(icon = Icons.Rounded.Info, iconContentDescription = null) { + Text( + stringResource( + if (task.status == TaskStatus.CANCELLED) R.string.detail_status_cancelled + else R.string.detail_status_in_process, + ), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + // List — icon tinted in the list colour conveys identity (no extra dot). add { DetailCard( @@ -283,11 +421,48 @@ private fun DetailBody( } } - // Description. - task.description?.takeIf { it.isNotBlank() }?.let { description -> + task.location?.takeIf { it.isNotBlank() }?.let { location -> add { - DetailCard(icon = Icons.AutoMirrored.Rounded.Notes, iconContentDescription = null) { - Text(description, style = MaterialTheme.typography.bodyMedium) + val context = LocalContext.current + DetailCard( + icon = Icons.Rounded.LocationOn, + iconContentDescription = stringResource(R.string.edit_location_label), + onClick = { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, "geo:0,0?q=${Uri.encode(location)}".toUri()), + ) + } + }, + ) { + Text(location, style = MaterialTheme.typography.titleMedium) + } + } + } + + task.url?.takeIf { it.isNotBlank() }?.let { url -> + add { + val uriHandler = LocalUriHandler.current + DetailCard( + icon = Icons.Rounded.Link, + iconContentDescription = stringResource(R.string.edit_url_label), + onClick = { runCatching { uriHandler.openUri(if ("://" in url) url else "https://$url") } }, + ) { + Text(url, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + } + } + + // Description — selectable, with its links tappable. + task.description?.readableDescription()?.takeIf { it.isNotBlank() }?.let { description -> + add { + DetailCard( + icon = Icons.AutoMirrored.Rounded.Notes, + iconContentDescription = stringResource(R.string.field_description), + ) { + SelectionContainer { + Text(linkified(description), style = MaterialTheme.typography.bodyMedium) + } } } } @@ -303,6 +478,7 @@ private fun DetailBody( onToggleSubtask = onToggleSubtask, onAddSubtask = onAddSubtask, onOpenSubtask = onOpenTask, + readOnly = task.isReadOnly, ) } } @@ -325,14 +501,16 @@ private fun SubtasksGroup( onToggle: () -> Unit, onToggleSubtask: (Task) -> Unit, onAddSubtask: (String) -> Unit, - onOpenSubtask: (Long) -> Unit, + onOpenSubtask: (Task) -> Unit, + readOnly: Boolean = false, ) { val done = subtasks.count { it.isCompleted } // Completed subtasks sink to the bottom; stable, so order is otherwise kept. val ordered = remember(subtasks) { subtasks.sortedBy { it.isCompleted } } val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtasksChevron") // Segments when expanded: header + the always-present add row + each subtask. - val count = if (expanded) subtasks.size + 2 else 1 + val addRow = if (readOnly) 0 else 1 + val count = if (expanded) subtasks.size + 1 + addRow else 1 // First segment: the toggle header. GroupedSurface(position = positionOf(0, count), onClick = onToggle) { @@ -355,7 +533,7 @@ private fun SubtasksGroup( // No count until there's something to count. if (subtasks.isNotEmpty()) { Text( - text = "$done / ${subtasks.size}", + text = stringResource(R.string.subtask_progress, done, subtasks.size), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -372,22 +550,28 @@ private fun SubtasksGroup( if (expanded) { // Always-present add row at the top of the list, in an accent tone. - GroupedSurface( - position = positionOf(1, count), - color = MaterialTheme.colorScheme.primaryContainer, - ) { - AddSubtaskField(onAdd = onAddSubtask) + if (!readOnly) { + GroupedSurface( + position = positionOf(1, count), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + AddSubtaskField(onAdd = onAddSubtask) + } } ordered.forEachIndexed { index, sub -> GroupedSurface( - position = positionOf(index + 2, count), - onClick = { onOpenSubtask(sub.taskId) }, + position = positionOf(index + 1 + addRow, count), + onClick = { onOpenSubtask(sub) }, ) { Row( modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Checkbox(checked = sub.isCompleted, onCheckedChange = { onToggleSubtask(sub) }) + Checkbox( + checked = sub.isCompleted, + onCheckedChange = { onToggleSubtask(sub) }, + enabled = !sub.isReadOnly, + ) Spacer(Modifier.width(4.dp)) Text( text = sub.title.ifBlank { stringResource(R.string.task_untitled) }, @@ -497,6 +681,7 @@ private fun DetailCard( */ @Composable private fun taskWhenLines(task: Task): Pair? { + val use24 = LocalUse24HourFormat.current val start = task.start val due = task.due return when { @@ -506,12 +691,12 @@ private fun taskWhenLines(task: Task): Pair? { val primary = if (sameDay) due.formatDate(allDay) else "${start.formatDate(allDay)} – ${due.formatDate(allDay)}" - val secondary = if (allDay) null else "${start.formatTime()} – ${due.formatTime()}" + val secondary = if (allDay) null else "${start.formatTime(use24)} – ${due.formatTime(use24)}" primary to secondary } - due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime() + due != null -> due.formatDate(task.isAllDay) to if (task.isAllDay) null else due.formatTime(use24) start != null -> - start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime() + start.formatDate(task.isAllDay) to if (task.isAllDay) null else start.formatTime(use24) else -> null } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt index 2e48a3a..acf3a0e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/detail/TaskDetailViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskForm @@ -11,49 +12,74 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope import javax.inject.Inject +import kotlin.time.Instant sealed interface TaskDetailUiState { data object Loading : TaskDetailUiState data object NotFound : TaskDetailUiState - data class Content(val detail: TaskDetail) : TaskDetailUiState + data class Content( + val detail: TaskDetail, + /** The task's own reminders; empty when the list's or app default applies. */ + val reminders: List = emptyList(), + ) : TaskDetailUiState } @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class TaskDetailViewModel @Inject constructor( private val repository: TasksRepository, + @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { - private val taskId = MutableStateFlow(null) + private val target = MutableStateFlow?>(null) val state: StateFlow = - taskId.filterNotNull() - .flatMapLatest { id -> - repository.taskDetail(id) + target.filterNotNull() + .flatMapLatest { (id, occurrence) -> + repository.taskDetail(id, occurrence) .map { detail -> - if (detail == null) TaskDetailUiState.NotFound else TaskDetailUiState.Content(detail) + if (detail == null) { + TaskDetailUiState.NotFound + } else { + TaskDetailUiState.Content( + detail = detail, + reminders = repository.remindersFor(detail.task.taskId), + ) + } } .onStart { emit(TaskDetailUiState.Loading) } - .catch { emit(TaskDetailUiState.NotFound) } + .recoveringFromProviderFailure { TaskDetailUiState.NotFound } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskDetailUiState.Loading) - fun bind(id: Long) { taskId.value = id } + fun bind(id: Long, occurrenceStart: Instant? = null) { target.value = id to occurrenceStart } fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, !task.isCompleted) } } - fun delete(task: Task) = viewModelScope.launch { - runCatching { repository.deleteTask(task.taskId) } + fun setCancelled(task: Task, cancelled: Boolean) = viewModelScope.launch { + runCatching { repository.setCancelled(task.taskId, task.occurrenceStart, cancelled) } + } + + /** In the application scope: the screen pops as soon as this is called. */ + fun delete(task: Task, scope: RecurringScope = RecurringScope.AllOccurrences) = appScope.launch { + runCatching { repository.deleteTask(task.taskId, task.occurrenceStart, scope) } + } + + /** Copy the task and hand the copy's id to [onCopied], on the main thread. */ + fun duplicate(task: Task, onCopied: (Long) -> Unit) = viewModelScope.launch { + runCatching { repository.duplicateTask(task.taskId, task.occurrenceStart) }.onSuccess(onCopied) } /** Add a subtask (title only) under the currently-shown task. */ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/RecurrencePicker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/RecurrencePicker.kt new file mode 100644 index 0000000..2f43cd1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/RecurrencePicker.kt @@ -0,0 +1,485 @@ +package de.jeanlucmakiola.agendula.ui.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceEnd +import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceFreq +import de.jeanlucmakiola.agendula.domain.recurrence.SimpleRecurrence +import de.jeanlucmakiola.agendula.domain.recurrence.parseSimpleRecurrence +import de.jeanlucmakiola.agendula.domain.recurrence.toRRule +import de.jeanlucmakiola.agendula.ui.common.nextOccurrencesText +import de.jeanlucmakiola.agendula.ui.common.recurrenceText +import de.jeanlucmakiola.floret.components.DialogAmountField +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.locale.currentLocale +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.toJavaDayOfWeek +import kotlinx.datetime.toJavaLocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale + +private enum class RecurrenceEndMode { Never, Until, Count } + +/** + * Full-screen recurrence picker in two steps: the plain frequencies as a + * tappable list (one tap applies and closes), and a "Custom" step with an + * interval, weekday toggles (weekly only) and an end condition, confirmed with + * OK. A rule the simple shape can't express stays untouched unless the user + * picks something here. + * + * [current] is the bare RRULE value (or null for none); [startDate] anchors the + * "Next: …" previews — for a task, its start date or else its due date. + * [onSelect] gets the new RRULE value, or null for "Does not repeat". + */ +@Composable +fun RecurrencePicker( + current: String?, + startDate: LocalDate, + firstDayOfWeek: DayOfWeek, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, +) { + val startDay = startDate.dayOfWeek + val parsed = remember(current) { current?.let { parseSimpleRecurrence(it) } } + val isPlainPreset = parsed != null && parsed.interval == 1 && + parsed.end == RecurrenceEnd.Never && parsed.byDays.isEmpty() + var customMode by rememberSaveable { mutableStateOf(false) } + var intervalText by rememberSaveable { mutableStateOf((parsed?.interval ?: 1).toString()) } + var freq by rememberSaveable { mutableStateOf(parsed?.freq ?: RecurrenceFreq.Weekly) } + // The start weekday stands in until the user picks days herself. + var daysMask by rememberSaveable { + mutableStateOf((parsed?.byDays?.takeIf { it.isNotEmpty() } ?: setOf(startDay)).toMask()) + } + var endMode by rememberSaveable { + mutableStateOf( + when (parsed?.end) { + is RecurrenceEnd.Until -> RecurrenceEndMode.Until + is RecurrenceEnd.Count -> RecurrenceEndMode.Count + else -> RecurrenceEndMode.Never + }, + ) + } + var untilIso by rememberSaveable { + mutableStateOf((parsed?.end as? RecurrenceEnd.Until)?.date?.toString()) + } + var countText by rememberSaveable { + mutableStateOf(((parsed?.end as? RecurrenceEnd.Count)?.times ?: 10).toString()) + } + var showUntilPicker by rememberSaveable { mutableStateOf(false) } + + val locale = currentLocale() + val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + val untilSummary = remember(untilDate, locale) { + untilDate?.let { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(locale).format(it.toJavaLocalDate()) + } + } + // A blank amount reads as its placeholder; only a real out-of-range value is invalid. + val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 } + val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 } + val customEnd: RecurrenceEnd? = when (endMode) { + RecurrenceEndMode.Never -> RecurrenceEnd.Never + RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) } + RecurrenceEndMode.Count -> count?.let { RecurrenceEnd.Count(it) } + } + val customRule: SimpleRecurrence? = if (interval != null && customEnd != null) { + SimpleRecurrence( + freq = freq, + interval = interval, + end = customEnd, + byDays = if (freq == RecurrenceFreq.Weekly) daysMask.toDaySet() else emptySet(), + ) + } else { + null + } + val customResult: String? = customRule?.toRRule() + + FullScreenPicker( + title = stringResource(R.string.recurrence_title), + onDismiss = onDismiss, + actions = { + if (customMode) { + TextButton( + enabled = customResult != null, + onClick = { customResult?.let(onSelect) }, + ) { Text(stringResource(android.R.string.ok)) } + } + }, + ) { + if (!customMode) { + PresetList( + current = current, + parsed = parsed, + isPlainPreset = isPlainPreset, + startDate = startDate, + locale = locale, + onSelect = onSelect, + onCustom = { customMode = true }, + ) + } else { + Column(modifier = Modifier.fillMaxWidth()) { + // Live read-out of exactly what OK would save; minLines keeps the + // controls below from shifting as the phrase grows. + Text( + text = customResult?.let { recurrenceText(it, locale) } + ?: AnnotatedString(stringResource(R.string.recurrence_incomplete)), + style = MaterialTheme.typography.titleMedium, + color = if (customResult != null) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.error + }, + minLines = 2, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Text( + text = customRule?.let { nextOccurrencesText(it, startDate, locale) }.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + + GroupedSurface( + position = Position.Alone, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.recurrence_every), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(12.dp)) + DialogAmountField( + value = intervalText, + onValueChange = { intervalText = it }, + placeholder = "1", + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + RecurrenceFreq.entries.forEachIndexed { index, entry -> + SegmentedButton( + selected = freq == entry, + onClick = { freq = entry }, + shape = SegmentedButtonDefaults.itemShape( + index, RecurrenceFreq.entries.size, + ), + // No check slot: four segments share one row and + // its width would ellipsize the longer labels. + icon = {}, + label = { + Text( + text = stringResource(recurrenceUnitLabel(entry)), + maxLines = 1, + ) + }, + ) + } + } + } + } + + AnimatedVisibility( + visible = freq == RecurrenceFreq.Weekly, + enter = expandEnter(), + exit = collapseExit(), + ) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 16.dp), + ) { + WeekdayToggleRow( + selected = daysMask.toDaySet(), + onToggle = { day -> daysMask = daysMask xor day.toMaskBit() }, + locale = locale, + firstDay = firstDayOfWeek, + modifier = Modifier.padding(16.dp), + ) + } + } + + Column(modifier = Modifier.padding(top = 16.dp)) { + Text( + text = stringResource(R.string.recurrence_ends), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp), + ) + CheckRow( + title = stringResource(R.string.recurrence_end_never), + position = Position.Top, + selected = endMode == RecurrenceEndMode.Never, + onClick = { endMode = RecurrenceEndMode.Never }, + ) + CheckRow( + title = stringResource(R.string.recurrence_end_until), + summary = untilSummary, + position = Position.Middle, + selected = endMode == RecurrenceEndMode.Until, + onClick = { + endMode = RecurrenceEndMode.Until + showUntilPicker = true + }, + ) + CheckRow( + title = stringResource(R.string.recurrence_end_count), + position = if (endMode == RecurrenceEndMode.Count) Position.Middle else Position.Bottom, + selected = endMode == RecurrenceEndMode.Count, + onClick = { endMode = RecurrenceEndMode.Count }, + ) + AnimatedVisibility( + visible = endMode == RecurrenceEndMode.Count, + enter = expandEnter(), + exit = collapseExit(), + ) { + GroupedSurface( + position = Position.Bottom, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp), + ) { + DialogAmountField( + value = countText, + onValueChange = { countText = it }, + placeholder = "10", + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.recurrence_times), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + } + } + } + } + + if (showUntilPicker) { + UntilDatePicker( + initial = untilDate ?: startDate, + onConfirm = { + untilIso = it.toString() + showUntilPicker = false + }, + onDismiss = { + showUntilPicker = false + // A dateless "on a date" can't be saved; fall back to "never". + if (untilIso == null) endMode = RecurrenceEndMode.Never + }, + ) + } +} + +@Composable +private fun PresetList( + current: String?, + parsed: SimpleRecurrence?, + isPlainPreset: Boolean, + startDate: LocalDate, + locale: Locale, + onSelect: (String?) -> Unit, + onCustom: () -> Unit, +) { + val rowCount = RecurrenceFreq.entries.size + 2 + CheckRow( + title = stringResource(R.string.recurrence_none), + position = positionOf(0, rowCount), + selected = current == null, + onClick = { onSelect(null) }, + ) + RecurrenceFreq.entries.forEachIndexed { index, entry -> + CheckRow( + title = stringResource(recurrencePresetLabel(entry)), + summary = nextOccurrencesText(SimpleRecurrence(entry), startDate, locale), + position = positionOf(index + 1, rowCount), + selected = isPlainPreset && parsed?.freq == entry, + onClick = { onSelect(SimpleRecurrence(entry).toRRule()) }, + ) + } + CheckRow( + title = stringResource(R.string.recurrence_custom), + position = positionOf(rowCount - 1, rowCount), + selected = current != null && !isPlainPreset, + onClick = onCustom, + ) +} + +@Composable +private fun CheckRow( + title: String, + position: Position, + selected: Boolean, + onClick: () -> Unit, + summary: String? = null, +) { + GroupedRow( + title = title, + summary = summary, + position = position, + selected = selected, + trailing = if (selected) { + { SelectedCheck() } + } else { + null + }, + onClick = onClick, + ) +} + +/** M3 date picker for the UNTIL day; the picker speaks UTC-midnight millis. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UntilDatePicker( + initial: LocalDate, + onConfirm: (LocalDate) -> Unit, + onDismiss: () -> Unit, +) { + val state = rememberDatePickerState( + initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, + ) + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + state.selectedDateMillis?.let { millis -> + onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt())) + } ?: onDismiss() + }, + ) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) { + DatePicker(state = state) + } +} + +private const val MILLIS_PER_DAY: Long = 86_400_000L + +/** One tappable circle per weekday in locale week order, multi-select (BYDAY picks). */ +@Composable +private fun WeekdayToggleRow( + selected: Set, + onToggle: (DayOfWeek) -> Unit, + locale: Locale, + firstDay: DayOfWeek, + modifier: Modifier = Modifier, +) { + val days = remember(firstDay) { + (0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) } + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier.fillMaxWidth(), + ) { + days.forEach { day -> + val isSelected = day in selected + Surface( + onClick = { onToggle(day) }, + shape = CircleShape, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + contentColor = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.size(36.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + text = day.toJavaDayOfWeek().getDisplayName(JavaTextStyle.NARROW, locale), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } +} + +/** Weekday sets travel through rememberSaveable as an ISO-day bitmask. */ +private fun DayOfWeek.toMaskBit(): Int = 1 shl (isoDayNumber - 1) + +private fun Set.toMask(): Int = fold(0) { acc, day -> acc or day.toMaskBit() } + +private fun Int.toDaySet(): Set = + DayOfWeek.entries.filter { this and it.toMaskBit() != 0 }.toSet() + +private fun recurrencePresetLabel(freq: RecurrenceFreq): Int = when (freq) { + RecurrenceFreq.Daily -> R.string.recurrence_daily + RecurrenceFreq.Weekly -> R.string.recurrence_weekly + RecurrenceFreq.Monthly -> R.string.recurrence_monthly + RecurrenceFreq.Yearly -> R.string.recurrence_yearly +} + +private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { + RecurrenceFreq.Daily -> R.string.recurrence_unit_days + RecurrenceFreq.Weekly -> R.string.recurrence_unit_weeks + RecurrenceFreq.Monthly -> R.string.recurrence_unit_months + RecurrenceFreq.Yearly -> R.string.recurrence_unit_years +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt index 03e4e4e..f8feec8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditScreen.kt @@ -1,5 +1,30 @@ package de.jeanlucmakiola.agendula.ui.edit +import de.jeanlucmakiola.agendula.ui.common.LocalFirstDayOfWeek +import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat +import androidx.activity.compose.BackHandler +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog +import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker +import de.jeanlucmakiola.agendula.ui.common.icon +import de.jeanlucmakiola.agendula.ui.common.labelRes +import de.jeanlucmakiola.agendula.ui.common.recurrenceText +import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import kotlinx.datetime.toKotlinDayOfWeek +import kotlinx.datetime.toKotlinLocalDate import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -15,10 +40,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -44,8 +65,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -59,14 +78,14 @@ import androidx.compose.material3.TimePicker import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -78,7 +97,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.floret.time.DayWindow @@ -92,20 +111,19 @@ import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.OptionalFormSection import de.jeanlucmakiola.floret.components.InlineTextField -import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.agendula.ui.common.priorityFill import de.jeanlucmakiola.floret.time.formatDate import de.jeanlucmakiola.floret.time.formatTime import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.agendula.ui.common.toLocalDate +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.calendarDate import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import java.time.LocalTime import java.time.ZoneId import java.time.ZoneOffset -import kotlinx.coroutines.launch import kotlin.time.Clock import kotlin.time.Instant @@ -127,22 +145,27 @@ fun TaskEditScreen( viewModel: TaskEditViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + var showDiscard by rememberSaveable { mutableStateOf(false) } + var showScope by rememberSaveable { mutableStateOf(false) } LaunchedEffect(state.saved) { if (state.saved) onSaved() } + val requestBack = { if (state.isDirty && !state.saved) showDiscard = true else onBack() } + BackHandler(enabled = state.isDirty && !state.saved) { showDiscard = true } + Scaffold( modifier = modifier, topBar = { TopAppBar( title = {}, navigationIcon = { - IconButton(onClick = onBack) { + IconButton(onClick = requestBack) { Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) } }, actions = { Button( - onClick = viewModel::save, + onClick = { if (viewModel.needsScope()) showScope = true else viewModel.save() }, enabled = !state.loading, modifier = Modifier.padding(end = 12.dp), ) { Text(stringResource(R.string.save)) } @@ -160,6 +183,34 @@ fun TaskEditScreen( modifier = Modifier.padding(inner), ) } + + if (showDiscard) { + AlertDialog( + onDismissRequest = { showDiscard = false }, + title = { Text(stringResource(R.string.edit_discard_title)) }, + text = { Text(stringResource(R.string.edit_discard_body)) }, + confirmButton = { + TextButton(onClick = { showDiscard = false; onBack() }) { + Text(stringResource(R.string.edit_discard_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showDiscard = false }) { Text(stringResource(R.string.edit_keep_editing)) } + }, + ) + } + + if (showScope) { + RecurringScopeDialog( + title = stringResource(R.string.edit_scope_title), + allowOccurrence = !state.recurrenceChanged, + onSelect = { scope -> + showScope = false + viewModel.save(scope = scope) + }, + onDismiss = { showScope = false }, + ) + } } private enum class PickerTarget { Start, Due } @@ -178,11 +229,21 @@ private fun EditContent( val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary val gap = 12.dp - var pickerTarget by remember { mutableStateOf(null) } + var pickerTarget by rememberSaveable { mutableStateOf(null) } var showListPicker by rememberSaveable { mutableStateOf(false) } var showParentPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } + var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } + + // A fresh, still-empty form takes the cursor once; an edit or a prefilled + // title never grabs focus. + val titleFocus = remember { FocusRequester() } + LaunchedEffect(state.loading) { + if (!state.loading && state.autofocusTitle && state.isNew && state.title.isBlank()) { + runCatching { titleFocus.requestFocus() } + } + } Column( modifier = modifier @@ -198,6 +259,7 @@ private fun EditContent( onValueChange = viewModel::onTitleChange, placeholder = stringResource(R.string.edit_title_hint), textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.focusRequester(titleFocus), ) Spacer(Modifier.height(10.dp)) Box( @@ -258,7 +320,7 @@ private fun EditContent( icon = Icons.Rounded.Checklist, iconContentDescription = stringResource(R.string.edit_list_label), iconTint = accent, - onClick = { showListPicker = true }.takeIf { state.lists.isNotEmpty() }, + onClick = { showListPicker = true }.takeIf { state.writableLists.isNotEmpty() }, ) { Text( text = selectedList?.name ?: stringResource(R.string.error_no_list), @@ -293,6 +355,40 @@ private fun EditContent( } } + OptionalFormSection(visible = TaskFormField.Recurrence in state.visibleFields) { + Spacer(Modifier.height(gap)) + val locale = currentLocale() + EditCard( + icon = Icons.Rounded.Repeat, + iconContentDescription = null, + onClick = { showRecurrencePicker = true }, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.rrule?.let { recurrenceText(it, locale) } + ?: AnnotatedString(stringResource(R.string.edit_recurrence_none)), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.edit_recurrence_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (TaskFormError.RECURRENCE_WITHOUT_DATE in state.errors) { + Spacer(Modifier.height(2.dp)) + FieldError(R.string.error_recurrence_without_date) + } + } + } + OptionalFormSection(visible = TaskFormField.Priority in state.visibleFields) { Spacer(Modifier.height(gap)) // Not an EditCard: the four buttons need the card's full width, so @@ -424,33 +520,55 @@ private fun EditContent( OptionalFormSection(visible = TaskFormField.Reminder in state.visibleFields) { Spacer(Modifier.height(gap)) - EditCard( - icon = Icons.Rounded.Notifications, - iconContentDescription = null, + // One card per reminder, connected, the last row adding another. + val rows = state.reminders.size + 1 + state.reminders.forEachIndexed { index, minutes -> + ReminderItemCard( + position = positionOf(index, rows), + title = reminderLeadTimeLabel(minutes, state.isAllDay), + onRemove = { viewModel.removeReminder(minutes) }, + ) + } + AddReminderCard( + position = positionOf(state.reminders.size, rows), + label = stringResource( + if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add, + ), onClick = { showReminderPicker = true }, + ) + if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { + Spacer(Modifier.height(4.dp)) + FieldError(R.string.error_reminder_without_due) + } + } + + OptionalFormSection(visible = TaskFormField.Location in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Rounded.LocationOn, + iconContentDescription = stringResource(R.string.edit_location_label), ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(reminderOptionFor(state.reminderMinutesBeforeDue).labelRes), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.edit_reminder_label), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.Rounded.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { - Spacer(Modifier.height(2.dp)) - FieldError(R.string.error_reminder_without_due) - } + InlineField( + value = state.location, + onValueChange = viewModel::onLocationChange, + placeholder = stringResource(R.string.edit_location_label), + ) + } + } + + OptionalFormSection(visible = TaskFormField.Url in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Rounded.Link, + iconContentDescription = stringResource(R.string.edit_url_label), + ) { + InlineField( + value = state.url, + onValueChange = viewModel::onUrlChange, + placeholder = stringResource(R.string.edit_url_hint), + keyboardType = KeyboardType.Uri, + capitalization = KeyboardCapitalization.None, + ) } } @@ -487,7 +605,7 @@ private fun EditContent( if (showListPicker) { ListPickerDialog( - lists = state.lists, + lists = state.writableLists, selectedId = state.listId, onSelect = { viewModel.onListChange(it); showListPicker = false }, onDismiss = { showListPicker = false }, @@ -520,13 +638,31 @@ private fun EditContent( } if (showReminderPicker) { - ReminderPickerDialog( - selected = state.reminderMinutesBeforeDue, - onSelect = { viewModel.onReminderChange(it); showReminderPicker = false }, + ReminderLeadPicker( + title = stringResource(R.string.edit_reminder_add), + selected = ReminderOverride.Inherit, + allowInherit = false, + allowNone = false, + allDay = state.isAllDay, + onSelect = { choice -> + (choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder) + showReminderPicker = false + }, onDismiss = { showReminderPicker = false }, ) } + if (showRecurrencePicker) { + val anchor = (state.start ?: state.due)?.calendarDate(state.isAllDay) ?: java.time.LocalDate.now() + RecurrencePicker( + current = state.rrule, + startDate = anchor.toKotlinLocalDate(), + firstDayOfWeek = LocalFirstDayOfWeek.current.toKotlinDayOfWeek(), + onSelect = { viewModel.onRecurrenceChange(it); showRecurrencePicker = false }, + onDismiss = { showRecurrencePicker = false }, + ) + } + if (showFieldPicker) { FieldPickerDialog( hiddenFields = state.hiddenFields, @@ -607,17 +743,23 @@ private fun InlineField( value: String, onValueChange: (String) -> Unit, placeholder: String, + modifier: Modifier = Modifier, textStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleMedium, singleLine: Boolean = true, minLines: Int = 1, + keyboardType: KeyboardType = KeyboardType.Text, + capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, ) { InlineTextField( value = value, onValueChange = onValueChange, placeholder = placeholder, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).then(modifier), textStyle = textStyle, singleLine = singleLine, minLines = minLines, + keyboardType = keyboardType, + capitalization = capitalization, ) } @@ -660,7 +802,7 @@ private fun ScheduleRow( ) if (!allDay) { Text( - text = value.formatTime(), + text = value.formatTime(LocalUse24HourFormat.current), style = MaterialTheme.typography.titleMedium, color = valueColor, modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), @@ -689,12 +831,15 @@ private fun DateTimePickerFlow( onResult: (Instant) -> Unit, onDismiss: () -> Unit, ) { - var pendingDate by remember { mutableStateOf(null) } - var showTime by remember { mutableStateOf(false) } + var pendingDate by rememberSaveable { mutableStateOf(null) } + var showTime by rememberSaveable { mutableStateOf(false) } if (!showTime) { + // M3's DatePicker speaks UTC millis. An all-day value is already UTC-based, + // a timed one is read in the device zone — calendarDate picks the right frame + // so the dialog opens on the day the rest of the UI shows. val initialMillis = (initial ?: nowInstant()) - .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + .calendarDate(allDay).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) DatePickerDialog( onDismissRequest = onDismiss, @@ -703,7 +848,7 @@ private fun DateTimePickerFlow( val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() if (allDay) { - onResult(localToInstant(date, LocalTime.MIDNIGHT)) + onResult(allDayInstantOf(date)) } else { pendingDate = date showTime = true @@ -719,6 +864,7 @@ private fun DateTimePickerFlow( val timeState = rememberTimePickerState( initialHour = base.toLocalTime().hour, initialMinute = base.toLocalTime().minute, + is24Hour = LocalUse24HourFormat.current, ) AlertDialog( onDismissRequest = onDismiss, @@ -736,6 +882,7 @@ private fun DateTimePickerFlow( } } +/** The target list, full-screen and grouped under each list's account. */ @Composable private fun ListPickerDialog( lists: List, @@ -744,37 +891,31 @@ private fun ListPickerDialog( onDismiss: () -> Unit, ) { val dark = isSystemInDarkTheme() - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.edit_list_label)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - lists.forEach { list -> - OptionCard( - label = list.name, - onClick = { onSelect(list.id) }, - icon = Icons.Rounded.Circle, - iconTint = pastelize(list.color, dark), - supportingText = list.accountName.takeIf { it.isNotBlank() }, - selected = list.id == selectedId, - ) - } + val groups = remember(lists) { lists.groupBy { it.accountName } } + FullScreenPicker(title = stringResource(R.string.edit_list_label), onDismiss = onDismiss) { + groups.forEach { (account, inAccount) -> + if (groups.size > 1 && account.isNotBlank()) GroupedSectionHeader(account) + inAccount.forEachIndexed { index, list -> + val selected = list.id == selectedId + GroupedRow( + title = list.name, + position = positionOf(index, inAccount.size), + selected = selected, + leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) }, + trailing = if (selected) ({ SelectedCheck() }) else null, + onClick = { onSelect(list.id) }, + ) } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } - }, - ) + } + } } /** - * Pick the task to file this one under, or "None" to keep it top-level. A - * full-width modal sheet (not a cramped dialog): a search field filters by title, - * and candidates group under the same due-date sections as the task list - * (Overdue / Today / Upcoming / No date) so the right one is easy to find in a - * long list. Only active tasks are offered (the VM filters closed ones out). + * Pick the task to file this one under, or "None" to keep it top-level. A search + * field filters by title, and candidates group under the same due-date sections as + * the task list (Overdue / Today / Upcoming / No date). Only active tasks are + * offered (the VM filters closed ones out). */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun ParentPickerSheet( candidates: List, @@ -782,15 +923,6 @@ private fun ParentPickerSheet( onSelect: (Long?) -> Unit, onDismiss: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - // Animate the sheet out before reporting the pick, so it doesn't snap shut. - fun choose(id: Long?) { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) onSelect(id) - } - } - var query by rememberSaveable { mutableStateOf("") } val filtered = remember(candidates, query) { val q = query.trim() @@ -799,93 +931,63 @@ private fun ParentPickerSheet( val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } val sections = remember(filtered) { TaskSections.of(filtered, todayStart, todayEnd) } - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(bottom = 32.dp), + FullScreenPicker(title = stringResource(R.string.edit_parent_label), onDismiss = onDismiss) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = GroupedListInset), ) { - item(key = "title") { - Text( - text = stringResource(R.string.edit_parent_label), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp), - ) - } - // The search field pins to the top of the sheet while the list scrolls. - stickyHeader(key = "search") { - Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(16.dp)) + Box(modifier = Modifier.weight(1f)) { + InlineTextField( value = query, onValueChange = { query = it }, - singleLine = true, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = { query = "" }) { - Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear)) - } - } - }, - placeholder = { Text(stringResource(R.string.edit_parent_search)) }, - shape = RoundedCornerShape(28.dp), - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 12.dp), + placeholder = stringResource(R.string.edit_parent_search), ) } - } - // "None" — promote to top-level. Pinned above the grouped candidates. - item(key = "none") { - GroupedRow( - title = stringResource(R.string.edit_parent_none), - position = de.jeanlucmakiola.floret.components.Position.Alone, - selected = selectedId == null, - minHeight = 56.dp, - onClick = { choose(null) }, - modifier = Modifier.padding(bottom = 4.dp), - ) - } - - if (sections.isEmpty()) { - item(key = "empty") { - Text( - text = stringResource(R.string.edit_parent_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), - ) - } - } - - sections.forEach { section -> - stickyHeader(key = "hdr-${section.section}") { - SheetSectionHeader(parentSectionLabel(section.section)) - } - itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task -> - GroupedRow( - title = task.title.ifBlank { stringResource(R.string.task_untitled) }, - position = positionOf(index, section.tasks.size), - summary = task.due?.formatDate(task.isAllDay), - selected = task.taskId == selectedId, - minHeight = 56.dp, - onClick = { choose(task.taskId) }, - ) + if (query.isNotEmpty()) { + IconButton(onClick = { query = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear)) + } } } } - } -} - -/** Section header inside the parent sheet — opaque so rows scroll cleanly under it. */ -@Composable -private fun SheetSectionHeader(text: String) { - Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) { - Text( - text = text, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 6.dp), + Spacer(Modifier.height(12.dp)) + GroupedRow( + title = stringResource(R.string.edit_parent_none), + position = Position.Alone, + selected = selectedId == null, + trailing = if (selectedId == null) ({ SelectedCheck() }) else null, + minHeight = 56.dp, + onClick = { onSelect(null) }, ) + if (sections.isEmpty()) { + Text( + text = stringResource(R.string.edit_parent_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp), + ) + } + sections.forEach { section -> + GroupedSectionHeader(parentSectionLabel(section.section)) + section.tasks.forEachIndexed { index, task -> + val selected = task.taskId == selectedId + GroupedRow( + title = task.title.ifBlank { stringResource(R.string.task_untitled) }, + position = positionOf(index, section.tasks.size), + summary = task.due?.formatDate(task.isAllDay), + selected = selected, + trailing = if (selected) ({ SelectedCheck() }) else null, + minHeight = 56.dp, + onClick = { onSelect(task.taskId) }, + ) + } + } } } @@ -900,30 +1002,42 @@ private fun parentSectionLabel(section: TaskSection): String = stringResource( }, ) +/** One reminder in the connected run, with a remove button. */ @Composable -private fun ReminderPickerDialog( - selected: Int?, - onSelect: (Int?) -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.edit_reminder_label)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - reminderOptions.forEach { option -> - OptionCard( - label = stringResource(option.labelRes), - onClick = { onSelect(option.minutes) }, - selected = option.minutes == selected, - ) - } +private fun ReminderItemCard(position: Position, title: String, onRemove: () -> Unit) { + GroupedSurface(position = position, gapBelow = position != Position.Bottom && position != Position.Alone) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { + Icon(Icons.Rounded.Notifications, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(16.dp)) + Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { + Icon( + Icons.Rounded.Close, + contentDescription = stringResource(R.string.edit_reminder_remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } - }, - ) + } + } +} + +/** The run's last row: add a reminder. */ +@Composable +private fun AddReminderCard(position: Position, label: String, onClick: () -> Unit) { + GroupedSurface(position = position, onClick = onClick, gapBelow = false) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon(Icons.Rounded.Add, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text(label, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + } + } } /** Picks one hidden optional section to add to the form. */ @@ -933,57 +1047,18 @@ private fun FieldPickerDialog( onSelect: (TaskFormField) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.edit_more_fields)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - hiddenFields.forEach { field -> - OptionCard( - label = stringResource(fieldLabel(field)), - onClick = { onSelect(field) }, - icon = fieldIcon(field), - ) - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } - }, - ) + FullScreenPicker(title = stringResource(R.string.edit_more_fields), onDismiss = onDismiss) { + hiddenFields.forEachIndexed { index, field -> + GroupedRow( + title = stringResource(field.labelRes()), + position = positionOf(index, hiddenFields.size), + leading = { Icon(field.icon(), contentDescription = null) }, + onClick = { onSelect(field) }, + ) + } + } } -private fun fieldLabel(field: TaskFormField): Int = when (field) { - TaskFormField.Description -> R.string.field_description - TaskFormField.Priority -> R.string.edit_priority_label - TaskFormField.Progress -> R.string.edit_progress_label - TaskFormField.Parent -> R.string.edit_parent_label - TaskFormField.Reminder -> R.string.edit_reminder_label -} - -private fun fieldIcon(field: TaskFormField): ImageVector = when (field) { - TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes - TaskFormField.Priority -> Icons.Rounded.Flag - TaskFormField.Progress -> Icons.Rounded.Percent - TaskFormField.Parent -> Icons.Rounded.AccountTree - TaskFormField.Reminder -> Icons.Rounded.Notifications -} - -private data class ReminderOption(val labelRes: Int, val minutes: Int?) - -private val reminderOptions = listOf( - ReminderOption(R.string.reminder_none, null), - ReminderOption(R.string.reminder_at_due, 0), - ReminderOption(R.string.reminder_5_min, 5), - ReminderOption(R.string.reminder_10_min, 10), - ReminderOption(R.string.reminder_30_min, 30), - ReminderOption(R.string.reminder_1_hour, 60), - ReminderOption(R.string.reminder_1_day, 1_440), -) - -private fun reminderOptionFor(minutes: Int?): ReminderOption = - reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first() - @Composable private fun FieldError(messageRes: Int) { Text( diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt index d25d0e5..2c75dec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/edit/TaskEditViewModel.kt @@ -15,6 +15,11 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.populatedFields +import de.jeanlucmakiola.agendula.domain.htmlToPlainText +import de.jeanlucmakiola.agendula.domain.looksLikeHtml +import de.jeanlucmakiola.agendula.domain.rebasedForAllDay +import de.jeanlucmakiola.agendula.domain.readableDescription +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,6 +38,8 @@ data class TaskEditUiState( val isNew: Boolean = true, val title: String = "", val description: String = "", + /** The stored description when it was HTML, written back as-is unless its text is edited. */ + val descriptionHtml: String? = null, val listId: Long? = null, val start: Instant? = null, val due: Instant? = null, @@ -40,7 +47,16 @@ data class TaskEditUiState( val priority: Priority = Priority.NONE, val parentId: Long? = null, val percentComplete: Int? = null, - val reminderMinutesBeforeDue: Int? = null, + val reminders: List = emptyList(), + val location: String = "", + val url: String = "", + val rrule: String? = null, + /** Editing one occurrence of a series: saving asks how far the edit reaches. */ + val isOccurrence: Boolean = false, + /** The series' rule as loaded; a changed rule rules out "only this task". */ + val originalRule: String? = null, + /** Something differs from what was loaded — back asks before discarding it. */ + val isDirty: Boolean = false, val lists: List = emptyList(), /** Top-level tasks in the current list this task can be filed under (reparent). */ val parentCandidates: List = emptyList(), @@ -53,9 +69,16 @@ data class TaskEditUiState( /** The task changed underneath us; the screen asks to overwrite or cancel. */ val saveConflict: Boolean = false, val saved: Boolean = false, + /** A fresh task whose title should take focus once the form shows. */ + val autofocusTitle: Boolean = false, ) { /** The currently-selected parent task, if this task is filed under one. */ val parent: Task? get() = parentCandidates.firstOrNull { it.taskId == parentId } + + /** Lists a task can be saved into: read-only shares drop out, except the one it is already in. */ + val writableLists: List get() = lists.filter { it.acceptsWrites || it.id == listId } + + val recurrenceChanged: Boolean get() = rrule != originalRule } @HiltViewModel @@ -69,6 +92,19 @@ class TaskEditViewModel @Inject constructor( val state: StateFlow = _state.asStateFlow() private var editingTaskId: Long? = null + private var editingOccurrence: Instant? = null + + /** The form as loaded, to tell a real edit from an untouched one. */ + private var baseline: TaskForm? = null + + /** + * Whether the form has already been populated. The host `LaunchedEffect` + * re-fires whenever the composition restarts — an Activity recreation + * (rotation, theme/font/display-size change, split-screen, unfolding) — while + * this ViewModel survives on the nav back stack. Without this guard the + * rebind would overwrite in-progress edits with the untouched provider row. + */ + private var bound = false /** `last_modified` captured when the form loaded — the conflict-check baseline. */ private var baselineLastModified: Instant? = null @@ -77,37 +113,47 @@ class TaskEditViewModel @Inject constructor( private var defaultFields: Set = emptySet() /** Start a fresh task, optionally pre-selecting a list / parent. */ - fun bindNew(presetListId: Long? = null, parentId: Long? = null) { + fun bindNew(presetListId: Long? = null, parentId: Long? = null, initialTitle: String? = null) { + if (bound) return + bound = true editingTaskId = null baselineLastModified = null viewModelScope.launch { val settings = settingsPrefs.settings.first() defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } + // A read-only or vanished default would give the task a list it + // cannot be saved to. + val writable = lists.filter { it.acceptsWrites } val defaultList = presetListId - ?: settings.defaultListId - ?: lists.firstOrNull { !it.isLocal }?.id - ?: lists.firstOrNull()?.id + ?: settings.defaultListId?.takeIf { id -> writable.any { it.id == id } } + ?: writable.firstOrNull { !it.isLocal }?.id + ?: writable.firstOrNull()?.id _state.value = withFields( TaskEditUiState( loading = false, isNew = true, + autofocusTitle = settings.autofocusTitle, listId = defaultList, parentId = parentId, lists = lists, parentCandidates = loadParents(defaultList, selfId = null), + title = initialTitle.orEmpty(), ), - ) + ).also { baseline = it.toForm().copy(title = "") } } } /** Load an existing task for editing. */ - fun bindEdit(taskId: Long) { + fun bindEdit(taskId: Long, occurrenceStart: Instant? = null) { + if (bound && editingTaskId == taskId) return + bound = true editingTaskId = taskId + editingOccurrence = occurrenceStart viewModelScope.launch { defaultFields = settingsPrefs.settings.first().defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } - val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull() + val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull() if (task == null) { _state.value = _state.value.copy(loading = false, lists = lists) return@launch @@ -118,7 +164,8 @@ class TaskEditViewModel @Inject constructor( loading = false, isNew = false, title = task.title, - description = task.description.orEmpty(), + description = task.description?.readableDescription().orEmpty(), + descriptionHtml = task.description?.takeIf { it.looksLikeHtml() }, listId = task.listId, start = task.start, due = task.due, @@ -126,10 +173,17 @@ class TaskEditViewModel @Inject constructor( priority = task.priority, parentId = task.parentId, percentComplete = task.percentComplete, + reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore }, + location = task.location.orEmpty(), + url = task.url.orEmpty(), + rrule = task.recurrenceRule, + originalRule = task.recurrenceRule, + isOccurrence = task.isOccurrence, lists = lists, parentCandidates = loadParents(task.listId, selfId = taskId), ), - ) + ).also { baseline = it.toForm() } + editingOccurrence = task.occurrenceStart } } @@ -178,17 +232,45 @@ class TaskEditViewModel @Inject constructor( fun onStartChange(value: Instant?) = update { it.copy(start = value) } fun onDueChange(value: Instant?) = update { it.copy(due = value) } - fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) } + /** + * All-day and timed values use different conventions (UTC midnight vs. a real + * instant in the device zone), so the switch has to move the timestamps too — + * flipping the flag alone makes an all-day task read back as "02:00", which + * looks to the user like the time reset itself. + */ + fun onAllDayChange(value: Boolean) = update { + it.copy( + isAllDay = value, + start = it.start?.rebasedForAllDay(value), + due = it.due?.rebasedForAllDay(value), + ) + } fun onPriorityChange(value: Priority) = update { it.copy(priority = value) } fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) } fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) } - fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) } + fun addReminder(minutesBeforeDue: Int) = update { + it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet()) + } + fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) } + fun onLocationChange(value: String) = update { it.copy(location = value) } + fun onUrlChange(value: String) = update { it.copy(url = value) } + fun onRecurrenceChange(rrule: String?) = update { it.copy(rrule = rrule, errors = emptySet()) } /** Dismiss the conflict prompt without saving (the user keeps editing). */ fun dismissConflict() = update { it.copy(saveConflict = false) } - /** [force] = overwrite even if the task changed since it loaded (conflict prompt). */ - fun save(force: Boolean = false) { + /** Whether saving needs the "this / following / all" choice first. */ + fun needsScope(): Boolean { + val current = _state.value + return !current.isNew && current.isOccurrence && current.toForm().validate().isEmpty() + } + + /** + * [force] = overwrite even if the task changed since it loaded (conflict prompt). + * [scope] only matters when editing one occurrence of a series. + */ + fun save(force: Boolean = false, scope: RecurringScope = lastScope) { + lastScope = scope val current = _state.value val form = current.toForm() val errors = form.validate() @@ -200,11 +282,26 @@ class TaskEditViewModel @Inject constructor( runCatching { val id = editingTaskId if (id == null) { - repository.createTask(form) + // ⚠️ Remembered. A second Save on this screen would otherwise + // take the create path again and write a second task — + // whatever sent the user back to it. + editingTaskId = repository.createTask(form) } else { - repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified) + repository.updateTask( + taskId = id, + form = form, + expectedLastModified = if (force) null else baselineLastModified, + occurrenceStart = editingOccurrence, + scope = scope, + ) } - reminderScheduler.sync() + // ⚠️ Outside the write's own result. The task is saved by this + // point, and reporting a scheduling failure as a *save* failure + // sends the user back to tap Save again — on a screen whose + // editingTaskId is still null, which creates a second task. The + // reminder is re-synced on the next data change and on launch; + // the duplicate is forever. + runCatching { reminderScheduler.sync() } }.onSuccess { _state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false) }.onFailure { error -> @@ -217,8 +314,11 @@ class TaskEditViewModel @Inject constructor( } } + private var lastScope: RecurringScope = RecurringScope.ThisOccurrence + private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) { - _state.value = block(_state.value) + val next = block(_state.value) + _state.value = next.copy(isDirty = baseline != null && next.toForm() != baseline) } } @@ -226,12 +326,15 @@ class TaskEditViewModel @Inject constructor( private fun TaskEditUiState.toForm(): TaskForm = TaskForm( title = title, listId = listId ?: 0L, - description = description.ifBlank { null }, + description = descriptionHtml?.takeIf { it.htmlToPlainText() == description } ?: description.ifBlank { null }, start = start, due = due, isAllDay = isAllDay, priority = priority, parentId = parentId, percentComplete = percentComplete, - reminderMinutesBeforeDue = reminderMinutesBeforeDue, + reminders = reminders, + location = location.ifBlank { null }, + url = url.ifBlank { null }, + rrule = rrule, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt new file mode 100644 index 0000000..8b342e4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt @@ -0,0 +1,345 @@ +package de.jeanlucmakiola.agendula.ui.export + +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderZip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ButtonGroupScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.documentfile.provider.DocumentFile +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf + +private const val ZIP_MIME = "application/zip" +private const val ZIP_NAME = "agendula-tasks.zip" +private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents" + +/** + * Export the task lists as iCalendar. Which lists go out is a per-list tick; the + * destination is a folder or a single zip, both picked through SAF so the app + * needs no storage permission. + */ +@Composable +fun ExportScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ExportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val dark = isSystemInDarkTheme() + + val folderLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + ) { uri -> uri?.let(viewModel::exportToFolder) } + val zipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(ZIP_MIME), + ) { uri -> uri?.let(viewModel::exportToZip) } + + val canExport = !state.running && state.selectedCount > 0 + + CollapsingScaffold( + title = stringResource(R.string.settings_export), + onBack = onBack, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.export_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), + ) + Spacer(Modifier.height(16.dp)) + + if (state.lists.isEmpty()) { + GroupedRow( + title = stringResource(R.string.export_no_lists), + position = Position.Alone, + dimmed = true, + ) + } else { + state.lists.forEachIndexed { index, list -> + val selected = state.isSelected(list.id) + GroupedRow( + title = list.name, + // The account only says something when it isn't the device itself. + summary = list.accountName.takeIf { !list.isLocal }, + position = positionOf(index, state.lists.size), + selected = selected, + leading = { + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + }, + trailing = if (selected) ({ SelectedCheck() }) else null, + onClick = { viewModel.toggle(list.id) }, + ) + } + } + + Spacer(Modifier.height(24.dp)) + DestinationButtons( + enabled = canExport, + onFolder = { folderLauncher.launch(null) }, + onZip = { zipLauncher.launch(ZIP_NAME) }, + ) + + Spacer(Modifier.height(16.dp)) + ExportStatus(state = state) + Spacer(Modifier.height(24.dp)) + } +} + +/** + * The two destinations as one expressive button group: the pressed tile grows + * and squeezes its neighbour, and its corners morph square — the M3 Expressive + * pairing, and the same press language as the app's shaped top-bar actions. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DestinationButtons(enabled: Boolean, onFolder: () -> Unit, onZip: () -> Unit) { + ButtonGroup( + // Two weighted tiles always fit the row, so the group never overflows. + overflowIndicator = {}, + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + ) { + customItem( + buttonGroupContent = { + DestinationButton( + icon = Icons.Rounded.Folder, + label = stringResource(R.string.export_to_folder), + onClick = onFolder, + enabled = enabled, + filled = true, + ) + }, + menuContent = {}, + ) + customItem( + buttonGroupContent = { + DestinationButton( + icon = Icons.Rounded.FolderZip, + label = stringResource(R.string.export_to_zip), + onClick = onZip, + enabled = enabled, + filled = false, + ) + }, + menuContent = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ButtonGroupScope.DestinationButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + enabled: Boolean, + filled: Boolean, +) { + val interaction = remember { MutableInteractionSource() } + val modifier = Modifier + .weight(1f) + .animateWidth(interaction) + .heightIn(min = 88.dp) + val content: @Composable () -> Unit = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + ) + } + } + if (filled) { + Button( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + enabled = enabled, + interactionSource = interaction, + contentPadding = TILE_PADDING, + modifier = modifier, + ) { content() } + } else { + FilledTonalButton( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + enabled = enabled, + interactionSource = interaction, + contentPadding = TILE_PADDING, + modifier = modifier, + ) { content() } + } +} + +private val TILE_PADDING = PaddingValues(horizontal = 12.dp, vertical = 16.dp) + +/** The running spinner, then whatever the last export ended as — it stays put. */ +@Composable +private fun ExportStatus(state: ExportUiState) { + val outcome = state.outcome + when { + state.running -> Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(18.dp)) + Text( + text = stringResource(R.string.export_running), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + outcome is ExportOutcome.Success -> Receipt( + title = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount), + destination = outcome.destination, + container = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + outcome is ExportOutcome.Failure -> Receipt( + title = stringResource(failureMessage(outcome.reason)), + destination = outcome.destination, + container = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + icon = Icons.Rounded.ErrorOutline, + ) + } +} + +/** + * What the last export ended as, over *where* it was aimed: the folder or zip + * the user picked, named, with an Open button when something on the device can + * answer for it. + */ +@Composable +private fun Receipt( + title: String, + destination: ExportDestination, + container: Color, + contentColor: Color, + icon: ImageVector? = null, +) { + val context = LocalContext.current + val name = remember(destination) { destinationName(context, destination) } + val open = remember(destination) { openIntent(destination)?.takeIf { context.canHandle(it) } } + + GroupedRow( + title = title, + summary = name, + position = Position.Alone, + container = container, + contentColor = contentColor, + leading = { + Icon( + imageVector = icon + ?: if (destination.isFolder) Icons.Rounded.Folder else Icons.Rounded.FolderZip, + contentDescription = null, + ) + }, + trailing = if (open == null) { + null + } else { + { + TextButton( + onClick = { runCatching { context.startActivity(open) } }, + colors = ButtonDefaults.textButtonColors(contentColor = contentColor), + ) { Text(stringResource(R.string.export_open)) } + } + }, + ) +} + +/** + * The destination as the user would name it. The device's own provider spells the + * path into the tree's document id (`primary:Documents/Tasks`); every other + * provider's id is opaque, so those fall back to the display name. + */ +private fun destinationName(context: Context, destination: ExportDestination): String? = runCatching { + if (!destination.isFolder) return@runCatching DocumentFile.fromSingleUri(context, destination.uri)?.name + if (destination.uri.authority == EXTERNAL_STORAGE) { + DocumentsContract.getTreeDocumentId(destination.uri).substringAfter(':', "").trim('/') + .takeIf { it.isNotBlank() } + ?: DocumentFile.fromTreeUri(context, destination.uri)?.name + } else { + DocumentFile.fromTreeUri(context, destination.uri)?.name + } +}.getOrNull()?.takeIf { it.isNotBlank() } + +/** Null when the tree `Uri` will not yield a document to point at. */ +private fun openIntent(destination: ExportDestination): Intent? = runCatching { + val uri = if (destination.isFolder) { + DocumentsContract.buildDocumentUriUsingTree( + destination.uri, + DocumentsContract.getTreeDocumentId(destination.uri), + ) + } else { + destination.uri + } + val mime = if (destination.isFolder) DocumentsContract.Document.MIME_TYPE_DIR else ZIP_MIME + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) +}.getOrNull() + +private fun Context.canHandle(intent: Intent): Boolean = + packageManager.queryIntentActivities(intent, 0).isNotEmpty() + +private fun failureMessage(reason: ExportFailure): Int = when (reason) { + ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder + ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only + ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create + ExportFailure.LOST_ACCESS -> R.string.export_failed_access + ExportFailure.WRITE_FAILED -> R.string.export_failed +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt new file mode 100644 index 0000000..df45e8d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportViewModel.kt @@ -0,0 +1,146 @@ +package de.jeanlucmakiola.agendula.ui.export + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.export.ExportFailedException +import de.jeanlucmakiola.agendula.data.export.ExportFailure +import de.jeanlucmakiola.agendula.data.export.ExportResult +import de.jeanlucmakiola.agendula.data.export.ExportWriter +import de.jeanlucmakiola.agendula.data.export.TaskExporter +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.export.ExportDocument +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** + * Where an export was aimed: the SAF `Uri` the user picked and whether it was a + * folder or a single zip. Kept on the outcome so the receipt can name the place + * and offer to open it — the `Uri` is the only handle we have on it. + */ +data class ExportDestination(val uri: Uri, val isFolder: Boolean) + +/** How the last export ended, kept on screen rather than flashed past. */ +sealed interface ExportOutcome { + val destination: ExportDestination + + data class Success( + val fileCount: Int, + override val destination: ExportDestination, + ) : ExportOutcome + + data class Failure( + val reason: ExportFailure, + override val destination: ExportDestination, + ) : ExportOutcome +} + +data class ExportUiState( + val lists: List = emptyList(), + /** Lists the user has ticked *off*; everything else is included. */ + val excluded: Set = emptySet(), + val running: Boolean = false, + val outcome: ExportOutcome? = null, +) { + fun isSelected(listId: Long): Boolean = listId !in excluded + + val selectedCount: Int get() = lists.count { isSelected(it.id) } +} + +/** + * Drives the export screen. Holds the selection as an exclusion set so a list + * that appears while the screen is open is exported too — the natural reading of + * "everything, minus what I unticked". + */ +@HiltViewModel +class ExportViewModel @Inject constructor( + repository: TasksRepository, + resolver: ProviderResolver, + private val exporter: TaskExporter, + private val writer: ExportWriter, +) : ViewModel() { + + private val excluded = MutableStateFlow(emptySet()) + private val running = MutableStateFlow(false) + private val outcome = MutableStateFlow(null) + + private var exportJob: Job? = null + + // List ids are per-store, and Settings can switch stores with this ViewModel + // still alive — so the selection, the receipt and a write already addressing + // the old store's lists all go with it. + private val modeHandle = resolver.onModeChanged { + exportJob?.cancel() + excluded.value = emptySet() + outcome.value = null + } + + override fun onCleared() { + modeHandle.close() + } + + val state: StateFlow = + combine( + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + excluded, + running, + outcome, + ) { lists, excluded, running, outcome -> + ExportUiState(lists, excluded, running, outcome) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState()) + + fun toggle(listId: Long) = excluded.update { current -> + if (listId in current) current - listId else current + listId + } + + /** Writes one `.ics` per list into a folder the user picked through SAF. */ + fun exportToFolder(tree: Uri) = export(ExportDestination(tree, isFolder = true)) { documents -> + writer.writeToTree(tree, documents) + } + + /** Writes every list into a single zip the user named through SAF. */ + fun exportToZip(target: Uri) = export(ExportDestination(target, isFolder = false)) { documents -> + writer.writeZip(target, documents) + } + + private fun export( + destination: ExportDestination, + write: suspend (List) -> ExportResult, + ) { + if (running.value) return + running.value = true + outcome.value = null + exportJob = viewModelScope.launch { + // Null means "every list" to the exporter, and is what an untouched + // screen should send: the flow may not have emitted a list yet. + val selection = excluded.value.takeIf { it.isNotEmpty() } + ?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped } + try { + val result = write(exporter.export(selection)) + outcome.value = ExportOutcome.Success(result.fileCount, destination) + } catch (cancelled: CancellationException) { + // Leaving the screen mid-write is not a failed export. + throw cancelled + } catch (error: Exception) { + outcome.value = ExportOutcome.Failure( + (error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED, + destination, + ) + } finally { + running.value = false + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportIntent.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportIntent.kt new file mode 100644 index 0000000..9f124cf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportIntent.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.ui.imports + +import android.content.Intent +import android.net.Uri +import androidx.core.content.IntentCompat + +private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics") + +/** + * The `.ics` an incoming intent hands us — `ACTION_VIEW` from a file manager or + * browser, or `ACTION_SEND` from a share sheet — or null when it is not one. + * A generic type is accepted only when the file name says `.ics`. + */ +fun importIntentUri(intent: Intent): Uri? { + val uri = when (intent.action) { + Intent.ACTION_VIEW -> intent.data + Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?: intent.data + else -> null + } ?: return null + // A file:// uri needs a storage permission the app does not hold. + if (uri.scheme != "content") return null + val type = intent.type?.lowercase()?.substringBefore(';')?.trim() + val namedIcs = uri.lastPathSegment?.endsWith(".ics", ignoreCase = true) == true + return uri.takeIf { type in CALENDAR_TYPES || namedIcs } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt new file mode 100644 index 0000000..0818e2f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportScreen.kt @@ -0,0 +1,404 @@ +package de.jeanlucmakiola.agendula.ui.imports + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.FileOpen +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ButtonGroupScope +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize + +private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "application/zip", "*/*") + +/** + * Import tasks from an `.ics` file, or a zip of them as the export writes. The + * file is picked through SAF (or handed in as [initialUri] from an opened + * file), the target is an existing writable list or a new device-only one, and + * the outcome stays on screen as a receipt. + * + * Only the own store can take an import; [onOpenStorage], when given, offers + * the way there. + */ +@Composable +fun ImportScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, + initialUri: Uri? = null, + onOpenStorage: (() -> Unit)? = null, + viewModel: ImportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var showTargets by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(initialUri, state.ownStore) { + if (initialUri != null && state.ownStore) viewModel.open(initialUri) + } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> uri?.let(viewModel::open) } + val pick = { + viewModel.reset() + launcher.launch(PICK_TYPES) + } + + val defaultName = stringResource(R.string.import_new_list_default_name) + val ready = state.step as? ImportStep.Ready + val newListOption = ImportChoice.NewList( + ready?.parsed?.suggestedListName?.takeIf { it.isNotBlank() } ?: defaultName, + ) + val selected = state.choice ?: newListOption + + CollapsingScaffold( + title = stringResource(R.string.import_title), + onBack = onBack, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.import_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), + ) + Spacer(Modifier.height(16.dp)) + + if (!state.ownStore) { + OwnStoreOnly(onOpenStorage) + return@CollapsingScaffold + } + + when (val step = state.step) { + ImportStep.Idle -> PickButton(onClick = pick) + ImportStep.Reading -> Progress(stringResource(R.string.import_reading)) + ImportStep.Importing -> Progress(stringResource(R.string.import_running)) + is ImportStep.Ready -> { + ReadyContent( + step = step, + target = selected, + onChangeTarget = { showTargets = true }, + ) + Spacer(Modifier.height(24.dp)) + ActionTiles( + primaryIcon = Icons.Rounded.Download, + primaryLabel = stringResource(R.string.import_button), + onPrimary = { viewModel.import(DefaultListColor, defaultName) }, + secondaryIcon = Icons.Rounded.FileOpen, + secondaryLabel = stringResource(R.string.import_pick_other), + onSecondary = pick, + ) + } + is ImportStep.Done -> { + DoneReceipt(step) + Spacer(Modifier.height(24.dp)) + PickButton(onClick = pick, label = stringResource(R.string.import_pick_another)) + } + is ImportStep.Failed -> { + FailedReceipt(step) + Spacer(Modifier.height(24.dp)) + PickButton(onClick = pick, label = stringResource(R.string.import_pick_other)) + } + } + Spacer(Modifier.height(24.dp)) + } + + if (showTargets && ready != null) { + TargetPicker( + options = listOf(newListOption) + state.lists.map { ImportChoice.Existing(it) }, + selected = selected, + onSelect = viewModel::choose, + onDismiss = { showTargets = false }, + ) + } +} + +@Composable +private fun OwnStoreOnly(onOpenStorage: (() -> Unit)?) { + GroupedRow( + title = stringResource(R.string.import_own_store_only), + position = Position.Alone, + leading = { Icon(Icons.Rounded.Info, contentDescription = null) }, + trailing = onOpenStorage?.let { open -> + { TextButton(onClick = open) { Text(stringResource(R.string.import_open_storage)) } } + }, + ) +} + +@Composable +private fun ReadyContent(step: ImportStep.Ready, target: ImportChoice, onChangeTarget: () -> Unit) { + val parsed = step.parsed + val found = pluralStringResource(R.plurals.import_found, parsed.tasks.size, parsed.tasks.size) + val events = if (parsed.skippedComponents > 0) { + pluralStringResource( + R.plurals.import_events_left_out, + parsed.skippedComponents, + parsed.skippedComponents, + ) + } else { + null + } + GroupedRow( + title = parsed.fileName ?: stringResource(R.string.import_picked_file), + summary = listOfNotNull(found, events).joinToString(" · "), + position = Position.Top, + leading = { Icon(Icons.Rounded.Description, contentDescription = null) }, + ) + GroupedRow( + title = stringResource(R.string.import_target_label), + summary = targetLabel(target), + position = Position.Bottom, + leading = { TargetIcon(target) }, + onClick = onChangeTarget, + ) +} + +@Composable +private fun TargetPicker( + options: List, + selected: ImportChoice, + onSelect: (ImportChoice) -> Unit, + onDismiss: () -> Unit, +) { + OptionPicker( + title = stringResource(R.string.import_target_label), + options = options, + selected = selected, + label = { targetLabel(it) }, + summary = { option -> + when (option) { + is ImportChoice.NewList -> stringResource(R.string.import_new_list_hint) + is ImportChoice.Existing -> option.list.accountName.takeIf { !option.list.isLocal } + } + }, + leading = { TargetIcon(it) }, + onSelect = onSelect, + onDismiss = onDismiss, + ) +} + +@Composable +private fun targetLabel(choice: ImportChoice): String = when (choice) { + is ImportChoice.NewList -> stringResource(R.string.import_new_list, choice.name) + is ImportChoice.Existing -> choice.list.name +} + +@Composable +private fun TargetIcon(choice: ImportChoice) { + when (choice) { + is ImportChoice.NewList -> Icon(Icons.Rounded.Add, contentDescription = null) + is ImportChoice.Existing -> Icon( + Icons.Rounded.Circle, + contentDescription = null, + tint = pastelize(choice.list.color, isSystemInDarkTheme()), + ) + } +} + +@Composable +private fun DoneReceipt(step: ImportStep.Done) { + val notes = listOfNotNull( + step.skippedExisting.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.import_done_skipped_existing, it, it) + }, + step.skippedComponents.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.import_events_left_out, it, it) + }, + ) + Receipt( + title = pluralStringResource(R.plurals.import_done, step.imported, step.imported, step.listName), + summary = notes.joinToString(" · ").takeIf { it.isNotEmpty() }, + icon = Icons.Rounded.CheckCircle, + container = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) +} + +@Composable +private fun FailedReceipt(step: ImportStep.Failed) { + Receipt( + title = stringResource( + when (step.problem) { + ImportStep.Problem.UNREADABLE -> R.string.import_failed_unreadable + ImportStep.Problem.NO_TASKS -> R.string.import_failed_no_tasks + ImportStep.Problem.WRITE_FAILED -> R.string.import_failed_write + }, + ), + summary = step.skippedComponents.takeIf { it > 0 }?.let { + pluralStringResource(R.plurals.import_events_left_out, it, it) + }, + icon = Icons.Rounded.ErrorOutline, + container = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) +} + +@Composable +private fun Receipt( + title: String, + summary: String?, + icon: ImageVector, + container: Color, + contentColor: Color, +) { + GroupedRow( + title = title, + summary = summary, + position = Position.Alone, + container = container, + contentColor = contentColor, + leading = { Icon(icon, contentDescription = null) }, + ) +} + +@Composable +private fun Progress(label: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(18.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** One large expressive tile, the export's destination buttons' single sibling. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PickButton(onClick: () -> Unit, label: String = stringResource(R.string.import_pick)) { + Button( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + contentPadding = TILE_PADDING, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = GroupedListInset) + .heightIn(min = 88.dp), + ) { TileContent(Icons.Rounded.FileOpen, label) } +} + +/** Two tiles as one expressive button group, the same pairing as the export's. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ActionTiles( + primaryIcon: ImageVector, + primaryLabel: String, + onPrimary: () -> Unit, + secondaryIcon: ImageVector, + secondaryLabel: String, + onSecondary: () -> Unit, +) { + ButtonGroup( + overflowIndicator = {}, + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + ) { + customItem( + buttonGroupContent = { Tile(primaryIcon, primaryLabel, onPrimary, filled = true) }, + menuContent = {}, + ) + customItem( + buttonGroupContent = { Tile(secondaryIcon, secondaryLabel, onSecondary, filled = false) }, + menuContent = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ButtonGroupScope.Tile( + icon: ImageVector, + label: String, + onClick: () -> Unit, + filled: Boolean, +) { + val interaction = remember { MutableInteractionSource() } + val modifier = Modifier + .weight(1f) + .animateWidth(interaction) + .heightIn(min = 88.dp) + if (filled) { + Button( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + interactionSource = interaction, + contentPadding = TILE_PADDING, + modifier = modifier, + ) { TileContent(icon, label) } + } else { + FilledTonalButton( + onClick = onClick, + shapes = ButtonDefaults.shapes(), + interactionSource = interaction, + contentPadding = TILE_PADDING, + modifier = modifier, + ) { TileContent(icon, label) } + } +} + +@Composable +private fun TileContent(icon: ImageVector, label: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(24.dp)) + Text(text = label, style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center) + } +} + +private val TILE_PADDING = PaddingValues(horizontal = 12.dp, vertical = 16.dp) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt new file mode 100644 index 0000000..5939e6f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt @@ -0,0 +1,178 @@ +package de.jeanlucmakiola.agendula.ui.imports + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.sync.SyncTrigger +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.data.tasks.transfer.IcsImport +import de.jeanlucmakiola.agendula.data.tasks.transfer.ImportTarget +import de.jeanlucmakiola.agendula.data.tasks.transfer.ParsedIcs +import de.jeanlucmakiola.agendula.domain.TaskList +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** Where the picked tasks go, as the picker offers it. */ +sealed interface ImportChoice { + data class Existing(val list: TaskList) : ImportChoice + data class NewList(val name: String) : ImportChoice +} + +/** The import screen's one step at a time. */ +sealed interface ImportStep { + data object Idle : ImportStep + data object Reading : ImportStep + + data class Ready(val parsed: ParsedIcs) : ImportStep + + data object Importing : ImportStep + + data class Done( + val listName: String, + val imported: Int, + val skippedExisting: Int, + val skippedComponents: Int, + ) : ImportStep + + enum class Problem { UNREADABLE, NO_TASKS, WRITE_FAILED } + + data class Failed(val problem: Problem, val skippedComponents: Int = 0) : ImportStep +} + +data class ImportUiState( + /** Import writes Room rows, so it needs the own store. */ + val ownStore: Boolean = true, + /** Writable lists, the candidates for a target. */ + val lists: List = emptyList(), + val step: ImportStep = ImportStep.Idle, + /** Null until picked: then the new list the file suggests. */ + val choice: ImportChoice? = null, +) { + /** The effective target: the user's pick, else a new list named after the file. */ + val target: ImportChoice? + get() { + val ready = step as? ImportStep.Ready ?: return null + return choice ?: ImportChoice.NewList(ready.parsed.suggestedListName.orEmpty()) + } +} + +/** + * Drives the `.ics` import: read a picked (or opened) file, let the user choose + * where it goes, write it, and keep a receipt on screen. + */ +@HiltViewModel +class ImportViewModel @Inject constructor( + repository: TasksRepository, + private val resolver: ProviderResolver, + private val importer: IcsImport, + private val syncTrigger: SyncTrigger, +) : ViewModel() { + + private val ownStore = MutableStateFlow(resolver.mode() == StorageMode.OWN) + private val step = MutableStateFlow(ImportStep.Idle) + private val choice = MutableStateFlow(null) + private var job: Job? = null + private var lastUri: Uri? = null + + private val modeHandle = resolver.onModeChanged { + job?.cancel() + ownStore.value = resolver.mode() == StorageMode.OWN + step.value = ImportStep.Idle + choice.value = null + } + + override fun onCleared() { + modeHandle.close() + } + + val state: StateFlow = + combine( + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + ownStore, + step, + choice, + ) { lists, own, step, choice -> + ImportUiState( + ownStore = own, + lists = lists.filterNot { it.isReadOnly }, + step = step, + choice = choice, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ImportUiState(ownStore = ownStore.value)) + + /** Reads [uri]; the same uri twice (recomposition, rotation) is read once. */ + fun open(uri: Uri) { + if (uri == lastUri || !ownStore.value) return + lastUri = uri + job?.cancel() + choice.value = null + step.value = ImportStep.Reading + job = viewModelScope.launch { + val parsed = importer.read(uri) + step.value = when { + parsed == null -> ImportStep.Failed(ImportStep.Problem.UNREADABLE) + parsed.tasks.isEmpty() && parsed.unreadableDocuments > 0 && parsed.skippedComponents == 0 -> + ImportStep.Failed(ImportStep.Problem.UNREADABLE) + parsed.tasks.isEmpty() -> + ImportStep.Failed(ImportStep.Problem.NO_TASKS, parsed.skippedComponents) + else -> ImportStep.Ready(parsed) + } + } + } + + fun choose(target: ImportChoice) { + choice.value = target + } + + /** Back to the start, so another file can be picked. */ + fun reset() { + job?.cancel() + lastUri = null + choice.value = null + step.value = ImportStep.Idle + } + + fun import(newListColor: Int, fallbackName: String) { + val current = state.value + val ready = current.step as? ImportStep.Ready ?: return + val chosen = current.target ?: return + val target = when (chosen) { + is ImportChoice.Existing -> ImportTarget.Existing(chosen.list.id) + is ImportChoice.NewList -> ImportTarget.NewList(chosen.name.ifBlank { fallbackName }, newListColor) + } + val listName = when (chosen) { + is ImportChoice.Existing -> chosen.list.name + is ImportChoice.NewList -> chosen.name.ifBlank { fallbackName } + } + step.value = ImportStep.Importing + job = viewModelScope.launch { + step.value = try { + val result = importer.import(ready.parsed, target) + (chosen as? ImportChoice.Existing)?.list + ?.takeIf { it.accountId != null && result.imported > 0 } + ?.let { runCatching { syncTrigger.enqueue(it.accountName) } } + ImportStep.Done( + listName = listName, + imported = result.imported, + skippedExisting = result.skippedExisting + ready.parsed.duplicatesInFile, + skippedComponents = ready.parsed.skippedComponents, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + ImportStep.Failed(ImportStep.Problem.WRITE_FAILED) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt new file mode 100644 index 0000000..84c86ab --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/LicencesScreen.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.agendula.ui.licences + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.positionOf + +/** + * Third-party attribution. + * + * Reachable from Settings rather than buried, because MPL-2.0 §3.2(a) is about + * what the *recipient of the binary* is told — a notice only a developer reading + * the repository would find does not discharge it. Each row opens the project's + * source, which is the specific thing §3.2(a) requires us to point at. + */ +@Composable +internal fun LicencesScreen(onBack: () -> Unit) { + val uriHandler = LocalUriHandler.current + + CollapsingScaffold( + title = stringResource(R.string.licences_title), + onBack = onBack, + ) { + Text( + stringResource(R.string.licences_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) + Spacer(Modifier.height(16.dp)) + + OpenSourceLicences.ALL.forEachIndexed { index, attribution -> + GroupedRow( + title = attribution.name, + summary = "${attribution.copyright} · ${attribution.licence.spdxId}", + position = positionOf(index, OpenSourceLicences.ALL.size), + onClick = { uriHandler.openUri(attribution.sourceUrl) }, + ) + } + + Spacer(Modifier.height(24.dp)) + Text( + stringResource(R.string.licences_footer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset), + ) + Spacer(Modifier.height(24.dp)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt new file mode 100644 index 0000000..b699e81 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/licences/OpenSourceLicences.kt @@ -0,0 +1,125 @@ +package de.jeanlucmakiola.agendula.ui.licences + +/** + * The third-party code Agendula ships, and what each licence obliges us to say. + * + * ⚠️ This screen is a **licence obligation, not an about-page nicety**. Until it + * exists the app is in plain violation: + * + * - **MPL-2.0 §3.2(a)** requires that recipients of the *executable* be told how + * to obtain the source of the covered files, and **§3.4** that the file headers + * be retained. That is dav4jvm, which we vendor — vendoring makes the + * obligation ours rather than a transitive dependency's. + * - **BSD-3-Clause** requires the copyright notice and disclaimer to be + * reproduced "in the documentation and/or other materials provided with the + * distribution", which for an app is exactly this. + * - **Apache-2.0 §4(d)** propagates any `NOTICE` file, and §4(a) requires the + * licence to travel with the work. + * + * Hand-maintained on purpose. Generators read POM metadata, and POM metadata is + * routinely wrong — the licence name is often not an SPDX id and the licence URL + * frequently 404s, which produces confidently empty output. A short honest list + * beats a long generated one that omits the entry that mattered. + * + * **When adding a dependency, add it here.** There is no automated check that + * can tell you that you forgot. + */ +data class Attribution( + val name: String, + val copyright: String, + val licence: Licence, + /** Where the source can actually be obtained — MPL-2.0 §3.2(a)'s requirement. */ + val sourceUrl: String, +) + +enum class Licence(val spdxId: String, val url: String) { + APACHE_2("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0"), + BSD_3("BSD-3-Clause", "https://opensource.org/license/bsd-3-clause"), + MPL_2("MPL-2.0", "https://mozilla.org/MPL/2.0/"), + MIT("MIT", "https://opensource.org/license/mit"), +} + +object OpenSourceLicences { + + /** + * ⚠️ dav4jvm is **vendored**, not depended on — see `dav/PROVENANCE.md`. That + * makes MPL-2.0 §3.2(a) directly ours: we distribute modified covered files + * inside our binary, so we must say where their source is. The upstream URL + * satisfies it only together with `PROVENANCE.md`, which lists every change + * we made; both are named below. + */ + val ALL: List = listOf( + Attribution( + name = "dav4jvm (vendored, modified — see dav/PROVENANCE.md)", + copyright = "© bitfire web engineering (Ricki Hirner, Bernhard Stockmann)", + licence = Licence.MPL_2, + sourceUrl = "https://github.com/bitfireAT/dav4jvm", + ), + Attribution( + name = "dnsjava", + copyright = "© Brian Wellington and the dnsjava contributors", + licence = Licence.BSD_3, + sourceUrl = "https://github.com/dnsjava/dnsjava", + ), + Attribution( + name = "UnifiedPush Android connector", + copyright = "© UnifiedPush contributors", + licence = Licence.APACHE_2, + sourceUrl = "https://codeberg.org/UnifiedPush/android-connector", + ), + Attribution( + name = "Tink", + copyright = "© Google LLC", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/tink-crypto/tink-java", + ), + Attribution( + name = "OkHttp", + copyright = "© Square, Inc.", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/square/okhttp", + ), + Attribution( + name = "Okio", + copyright = "© Square, Inc.", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/square/okio", + ), + Attribution( + name = "lib-recur", + copyright = "© dmfs GmbH", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/dmfs/lib-recur", + ), + Attribution( + name = "Kotlin, kotlinx.coroutines, kotlinx.datetime, kotlinx.serialization", + copyright = "© JetBrains s.r.o. and Kotlin Programming Language contributors", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/JetBrains/kotlin", + ), + Attribution( + name = "AndroidX, Jetpack Compose, Room, WorkManager, DataStore, Glance", + copyright = "© The Android Open Source Project", + licence = Licence.APACHE_2, + sourceUrl = "https://cs.android.com/androidx/platform/frameworks/support", + ), + Attribution( + name = "Dagger and Hilt", + copyright = "© Google LLC and The Dagger Authors", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/google/dagger", + ), + Attribution( + name = "Material Design icons", + copyright = "© Google LLC", + licence = Licence.APACHE_2, + sourceUrl = "https://github.com/google/material-design-icons", + ), + Attribution( + name = "floret-kit", + copyright = "© Jean-Luc Makiola", + licence = Licence.MIT, + sourceUrl = "https://codeberg.org/jlmakiola/floret-kit", + ), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt new file mode 100644 index 0000000..d06c11f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListEditorSheet.kt @@ -0,0 +1,437 @@ +package de.jeanlucmakiola.agendula.ui.lists + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.agendula.ui.common.ListColorChip +import de.jeanlucmakiola.agendula.ui.common.ListPalette +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSurface +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.pastelize + +private const val SWATCHES_PER_ROW = 6 + +/** + * Where a list lives. + * + * [accountId] null is the device, which is always offered and always first: it + * needs no server, no permission and no network, so it is the choice that cannot + * fail. The rest are CalDAV accounts whose home set answered OPTIONS with a + * creation method — an account that has neither is absent rather than disabled, + * because "you cannot make a list here" is not a decision the user can act on. + */ +data class ListDestination(val accountId: Long?, val label: String) + +/** + * Create or edit a task list: a name field over the palette of list colours, on + * the family's full-screen sheet with the commit in its title bar. + * + * [initial] null is the create case, and the only one that offers a choice of + * [destinations] — moving a list between a server and the device is a copy and a + * delete rather than an edit, and is not this sheet's job. + * + * ⚠️ [onDelete] no longer means "device-only list". An account's collection can + * be deleted now, on the server, which is what the caller wires it to; it stays + * null for a list the app must not remove — a read-only share, whose removal + * belongs to whoever owns it. + */ +@Composable +fun ListEditorSheet( + initial: TaskList?, + onSave: (name: String, color: Int, accountId: Long?) -> Unit, + onDismiss: () -> Unit, + onDelete: (() -> Unit)? = null, + destinations: List = emptyList(), + hiddenFromSmartLists: Boolean? = null, + onHiddenFromSmartListsChange: (Boolean) -> Unit = {}, +) { + var name by rememberSaveable(initial?.id) { mutableStateOf(initial?.name.orEmpty()) } + var color by rememberSaveable(initial?.id) { mutableIntStateOf(initial?.color ?: DefaultListColor) } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + // Saved by id rather than by object: the destination has to survive a + // rotation, and a Long? is the only part of it that is worth keeping — the + // labels are rebuilt from the accounts either way. + var destinationId by rememberSaveable(initial?.id) { mutableStateOf(initial?.accountId) } + var choosingDestination by rememberSaveable { mutableStateOf(false) } + + // One choice is not a choice. A device with no account that can make + // collections gets the sheet it has always had. + val offersChoice = initial == null && destinations.size > 1 + val destination = destinations.firstOrNull { it.accountId == destinationId } + ?: destinations.firstOrNull() + + val readOnly = initial?.isReadOnly == true + val valid = name.isNotBlank() && !readOnly + val commit = { + if (valid) { + onSave(name.trim(), color, destinationId) + onDismiss() + } + } + + FullScreenPicker( + title = stringResource(if (initial == null) R.string.list_new_title else R.string.list_edit_title), + onDismiss = onDismiss, + actions = { + Button( + onClick = commit, + enabled = valid, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } + }, + ) { + ListNameField( + name = name, + color = color, + // A new list opens with the keyboard up: naming it is the whole task. + autoFocus = initial == null, + onNameChange = { name = it }, + onImeAction = commit, + ) + + // ⚠️ Said before the work, not after it. A list made on a server and a + // list made on the device are different things — one syncs to every + // other client, one never leaves the phone — and finding out which you + // got by watching it fail to appear elsewhere is the wrong way round. + if (offersChoice && destination != null) { + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.list_where)) + GroupedRow( + title = destination.label, + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + leading = { Icon(destinationIcon(destination), contentDescription = null) }, + trailing = { Icon(Icons.Rounded.ChevronRight, contentDescription = null) }, + onClick = { choosingDestination = true }, + ) + } + + if (readOnly) { + Spacer(Modifier.height(20.dp)) + ReadOnlyNote() + } + + Spacer(Modifier.height(20.dp)) + SectionLabel(stringResource(R.string.list_color)) + ListColorGrid(selected = color, onSelect = { color = it }) + + // A view preference, not a list edit: it applies at once, read-only share included. + if (hiddenFromSmartLists != null) { + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.list_in_smart_lists), + summary = stringResource(R.string.list_in_smart_lists_hint), + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + trailing = { + Switch( + checked = !hiddenFromSmartLists, + onCheckedChange = { onHiddenFromSmartListsChange(!it) }, + ) + }, + onClick = { onHiddenFromSmartListsChange(!hiddenFromSmartLists) }, + ) + } + + if (onDelete != null) { + Spacer(Modifier.height(24.dp)) + DeleteRow(onClick = { confirmDelete = true }) + } + Spacer(Modifier.height(24.dp)) + } + + if (choosingDestination && destination != null) { + OptionPicker( + title = stringResource(R.string.list_where), + options = destinations, + selected = destination, + label = { it.label }, + leading = { Icon(destinationIcon(it), contentDescription = null) }, + // No dismissal here: OptionPicker calls onSelect and then onDismiss + // on the same tap, and closing it twice reads as though it did not. + onSelect = { destinationId = it.accountId }, + onDismiss = { choosingDestination = false }, + ) + } + + if (confirmDelete && onDelete != null) { + DeleteListDialog( + listName = initial?.name.orEmpty(), + // A synced list is deleted on the server too, and that reaches every + // other client the account has. Saying so is the difference between + // a confirmation and a trap. + synced = initial?.accountId != null, + onConfirm = { + confirmDelete = false + onDelete() + onDismiss() + }, + onDismiss = { confirmDelete = false }, + ) + } +} + +/** The device and a server are different places, so they get different marks. */ +private fun destinationIcon(destination: ListDestination) = + if (destination.accountId == null) Icons.Rounded.PhoneAndroid else Icons.Rounded.CloudSync + +/** A share we may read and not write; the edit has nowhere to land. */ +@Composable +private fun ReadOnlyNote() { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(Icons.Rounded.Lock, contentDescription = null) + Text( + text = stringResource(R.string.list_read_only), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** The name, with the chosen colour beside it so the two read as one thing. */ +@Composable +internal fun ListNameField( + name: String, + color: Int, + autoFocus: Boolean, + onNameChange: (String) -> Unit, + onImeAction: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(autoFocus) { if (autoFocus) focusRequester.requestFocus() } + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + ListColorChip(color) + InlineTextField( + value = name, + onValueChange = onNameChange, + placeholder = stringResource(R.string.list_name_hint), + imeAction = ImeAction.Done, + onImeAction = onImeAction, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + ) + } + } +} + +/** + * The palette as two rows of round swatches; the chosen one carries a check. + * Shared with the onboarding step that makes the user's first list. + */ +@Composable +internal fun ListColorGrid(selected: Int, onSelect: (Int) -> Unit) { + val dark = isSystemInDarkTheme() + GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ListPalette.chunked(SWATCHES_PER_ROW).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEach { swatch -> + Swatch( + color = swatch, + dark = dark, + selected = swatch == selected, + onClick = { onSelect(swatch) }, + modifier = Modifier.weight(1f), + ) + } + // Keeps a short final row's swatches at the same size as a full + // one's rather than stretching them across the width. + repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun Swatch( + color: Int, + dark: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val fill = pastelize(color, dark) + val label = stringResource(colorLabel(color)) + Box( + modifier = modifier + .aspectRatio(1f) + .clip(CircleShape) + .background(fill) + // selectable, not clickable: the swatch carries its chosen state in + // semantics, so the check below is decoration rather than the only cue. + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + tint = if (fill.luminance() > 0.5f) Color.Black else Color.White, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun DeleteRow(onClick: () -> Unit) { + GroupedSurface( + position = Position.Alone, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClick, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.list_delete), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} + +@Composable +private fun DeleteListDialog( + listName: String, + synced: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.list_delete_confirm_title)) }, + text = { + Text( + stringResource( + if (synced) { + R.string.list_delete_confirm_message_synced + } else { + R.string.list_delete_confirm_message + }, + listName, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp), + ) +} + +/** Names the palette entries for screen readers; anything else is just "colour". */ +private fun colorLabel(color: Int): Int = when (ListPalette.indexOf(color)) { + 0 -> R.string.list_color_mauve + 1 -> R.string.list_color_red + 2 -> R.string.list_color_orange + 3 -> R.string.list_color_amber + 4 -> R.string.list_color_olive + 5 -> R.string.list_color_green + 6 -> R.string.list_color_teal + 7 -> R.string.list_color_cyan + 8 -> R.string.list_color_blue + 9 -> R.string.list_color_indigo + 10 -> R.string.list_color_purple + 11 -> R.string.list_color_pink + else -> R.string.list_color +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index 90bdff0..92088cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -1,5 +1,9 @@ package de.jeanlucmakiola.agendula.ui.lists +import de.jeanlucmakiola.floret.components.ReorderableColumn +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.SwapVert import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState @@ -46,6 +50,7 @@ import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -66,12 +71,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.domain.Priority @@ -80,7 +86,12 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.SnackChip +import de.jeanlucmakiola.floret.components.SnackChipHeight +import de.jeanlucmakiola.floret.components.SnackChipMargin +import kotlinx.coroutines.delay import de.jeanlucmakiola.agendula.ui.common.ListColorChip +import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel @@ -99,7 +110,7 @@ import java.time.ZoneId @Composable fun ListsScreen( onOpenFilter: (TaskFilter) -> Unit, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, onNewTask: () -> Unit, onOpenSettings: () -> Unit, modifier: Modifier = Modifier, @@ -108,10 +119,28 @@ fun ListsScreen( val state by viewModel.state.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } + var newList by rememberSaveable { mutableStateOf(false) } + val destinations by viewModel.destinations.collectAsStateWithLifecycle() + // ⚠️ Asked when the sheet opens, not when the screen is built. The answer is + // an OPTIONS against each account's home set, and an account added since — + // or a server upgraded since — has to be able to appear without a restart. + val deviceLabel = stringResource(R.string.list_where_device) + LaunchedEffect(newList) { if (newList) viewModel.refreshDestinations(deviceLabel) } val closeSearch = { query = "" searchActive = false } + // With no lists there is nowhere to put a task, so the primary action becomes + // making one — otherwise a fresh install's FAB opens a form that cannot save. + val noLists = (state as? ListsUiState.Content)?.groups?.isEmpty() == true + // The sheet closes on save, so a refused write has to report itself here. + val writeFailure by viewModel.writeFailure.collectAsStateWithLifecycle() + LaunchedEffect(writeFailure) { + if (writeFailure != null) { + delay(4_000) + viewModel.clearWriteFailure() + } + } // System back closes search before leaving the screen. BackHandler(enabled = searchActive, onBack = closeSearch) @@ -130,9 +159,11 @@ fun ListsScreen( // The FAB would otherwise float over the search results. if (!searchActive) { ExtendedFloatingActionButton( - onClick = onNewTask, + onClick = if (noLists) ({ newList = true }) else onNewTask, icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.new_task)) }, + text = { + Text(stringResource(if (noLists) R.string.list_add else R.string.new_task)) + }, ) } }, @@ -147,6 +178,8 @@ fun ListsScreen( state = s, onOpenFilter = onOpenFilter, onOpenTask = onOpenTask, + onNewList = { newList = true }, + onReorder = viewModel::reorderLists, topPadding = 0.dp, bottomPadding = inner.calculateBottomPadding() + 96.dp, ) @@ -166,18 +199,65 @@ fun ListsScreen( } } } + // Anchored beside the FAB, at its height — the same receipt placement + // the task list uses. + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .padding( + start = SnackChipMargin, + bottom = inner.calculateBottomPadding() + SnackChipMargin, + ) + .height(SnackChipHeight), + contentAlignment = Alignment.CenterStart, + ) { + SnackChip( + visible = writeFailure != null, + message = stringResource( + listWriteFailureMessage(writeFailure ?: ListWriteFailure.SAVE), + ), + ) + } } } + + if (newList) { + ListEditorSheet( + initial = null, + onSave = viewModel::createList, + onDismiss = { newList = false }, + destinations = destinations, + ) + } +} + +/** + * Wording for a refused list write. + * + * Shared with the task list, which offers the same edit from the other side — + * one refusal should not read differently depending on where it was asked for. + */ +internal fun listWriteFailureMessage(failure: ListWriteFailure): Int = when (failure) { + ListWriteFailure.SAVE -> R.string.list_save_failed + ListWriteFailure.DELETE -> R.string.list_delete_failed + ListWriteFailure.SERVER_REFUSED -> R.string.list_save_failed_server + ListWriteFailure.OFFLINE -> R.string.list_save_failed_offline + ListWriteFailure.READ_ONLY -> R.string.list_save_failed_read_only + ListWriteFailure.UNSUPPORTED -> R.string.list_save_failed_unsupported } @Composable private fun ListsContent( state: ListsUiState.Content, onOpenFilter: (TaskFilter) -> Unit, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, + onNewList: () -> Unit, + onReorder: (List) -> Unit, topPadding: androidx.compose.ui.unit.Dp, bottomPadding: androidx.compose.ui.unit.Dp, ) { + // Lists get drag handles only while reordering, so the home screen stays calm. + var reordering by rememberSaveable { mutableStateOf(false) } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -215,11 +295,61 @@ private fun ListsContent( } if (state.groups.isEmpty()) { - item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) } + item { EmptyLists(onNewList = onNewList) } } else { - item { SectionHeader(stringResource(R.string.lists_header)) } + item { + SectionHeader( + text = stringResource(R.string.lists_header), + action = { + IconButton(onClick = { reordering = !reordering }) { + Icon( + if (reordering) Icons.Rounded.Check else Icons.Rounded.SwapVert, + contentDescription = stringResource( + if (reordering) R.string.lists_reorder_done else R.string.lists_reorder, + ), + ) + } + ShapedActionButton( + shape = ActionShapes.AddList, + morphTo = ActionShapes.AddListPressed, + icon = Icons.Rounded.Add, + contentDescription = stringResource(R.string.list_add), + onClick = onNewList, + ) + }, + ) + } state.groups.forEach { group -> item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) } + if (reordering) { + item(key = "reorder-${group.accountName}") { + ReorderableColumn( + items = group.lists, + keyOf = { it.list.id }, + onReorder = { ordered -> onReorder(ordered.map { it.list.id }) }, + rowHeight = 72.dp, + ) { overview, position, dragHandle, isDragging -> + GroupedRow( + title = overview.list.name, + position = position, + minHeight = 72.dp, + gapBelow = false, + container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null, + leading = { ListColorChip(overview.list.color) }, + trailing = { + Box(modifier = dragHandle.size(48.dp), contentAlignment = Alignment.Center) { + Icon( + Icons.Rounded.DragHandle, + contentDescription = stringResource(R.string.lists_reorder_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } + } + return@forEach + } itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview -> GroupedRow( title = overview.list.name, @@ -243,6 +373,28 @@ private fun ListsContent( } } +/** No lists at all — a fresh install, where nothing else on this screen works yet. */ +@Composable +private fun EmptyLists(onNewList: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.lists_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + FilledTonalButton(onClick = onNewList) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.lists_empty_action)) + } + } +} + /** * The home top bar. There is no title — the launcher icon already says which app * this is. Settings is pinned at the right; the search action sits just left of it @@ -398,14 +550,17 @@ private fun SearchPill( private fun SearchResults( query: String, allTasks: List, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, modifier: Modifier = Modifier, ) { if (query.isBlank()) return val results = remember(query, allTasks) { val q = query.trim() allTasks - .filter { it.title.contains(q, ignoreCase = true) } + .filter { t -> listOfNotNull(t.title, t.description, t.location).any { it.contains(q, ignoreCase = true) } } + // A series matches once, on its current occurrence. + .sortedBy { kotlin.math.abs(it.distanceFromCurrent ?: 0) } + .distinctBy { it.seriesId ?: it.taskId } .sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() })) } Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) { @@ -419,8 +574,8 @@ private fun SearchResults( } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(results, key = { it.id }) { task -> - UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) + items(results, key = { it.occurrenceKey }) { task -> + UpcomingRow(task = task, onClick = { onOpenTask(task) }) } } } @@ -466,12 +621,12 @@ private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) { val headline = when { total == 0 -> stringResource(R.string.home_today_empty) left == 0 -> stringResource(R.string.home_today_all_done) - else -> stringResource(R.string.home_today_progress, done, total) + else -> pluralStringResource(R.plurals.home_today_progress_count, total, done, total) } Text(headline, style = MaterialTheme.typography.headlineSmall) if (total > 0 && left > 0) { Text( - stringResource(R.string.home_today_remaining, left), + pluralStringResource(R.plurals.home_today_remaining_count, left, left), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), ) @@ -485,7 +640,7 @@ private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) { color = MaterialTheme.colorScheme.onPrimaryContainer, trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f), ) - Text("$done/$total", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.progress_fraction_compact, done, total), style = MaterialTheme.typography.titleMedium) } } } @@ -514,7 +669,7 @@ private fun SmartPairRow(counts: List, onOpenFilter: (TaskFilter) -> @Composable private fun UpcomingPreview( tasks: List, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, onViewAll: () -> Unit, ) { Surface( @@ -524,7 +679,7 @@ private fun UpcomingPreview( ) { Column { tasks.forEach { task -> - UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) }) + UpcomingRow(task = task, onClick = { onOpenTask(task) }) } Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) { Row( @@ -605,7 +760,7 @@ private fun upcomingDueLabel(task: Task): String? { return when (dueDate) { today -> stringResource(R.string.home_due_today) today.plusDays(1) -> stringResource(R.string.home_due_tomorrow) - else -> due.formatDateTimeCompact(task.isAllDay) + else -> due.formatDateTimeCompact(task.isAllDay, LocalUse24HourFormat.current) } } @@ -655,12 +810,25 @@ private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: } @Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp), - ) +private fun SectionHeader(text: String, action: (@Composable () -> Unit)? = null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 28.dp, + end = if (action == null) 28.dp else 20.dp, + top = if (action == null) 16.dp else 12.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + action?.invoke() + } } @Composable diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index a6d5071..223f440 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -3,22 +3,73 @@ package de.jeanlucmakiola.agendula.ui.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.floret.time.DayWindow import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskList +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.ZoneId import javax.inject.Inject import kotlin.time.Clock +/** + * What a list write failed at. The screens turn this into wording; the view + * models stay free of resources. + * + * ⚠️ The four server-side entries are not decoration. A list that lives on a + * server can fail in ways a device-only one cannot — the server refuses, the + * network is gone, the share is read-only, the account has no MKCALENDAR — and + * every one of them has a different thing for the user to do next. Collapsing + * them into SAVE tells someone on a train that their server said no. + */ +enum class ListWriteFailure { + SAVE, + DELETE, + + /** The server understood and refused. Retrying changes nothing. */ + SERVER_REFUSED, + + /** The server could not be reached. Worth trying again later. */ + OFFLINE, + + /** A read-only share: the write belongs to whoever owns it. */ + READ_ONLY, + + /** This account cannot make collections at all — iCloud, Posteo, Google. */ + UNSUPPORTED, +} + +/** Maps a remote outcome onto the wording the screens already know how to show. */ +internal fun RemoteListRepository.Outcome.asFailure(local: ListWriteFailure): ListWriteFailure? = + when (this) { + RemoteListRepository.Outcome.Done -> null + is RemoteListRepository.Outcome.Refused -> ListWriteFailure.SERVER_REFUSED + RemoteListRepository.Outcome.Unreachable -> ListWriteFailure.OFFLINE + RemoteListRepository.Outcome.Unsupported -> ListWriteFailure.UNSUPPORTED + RemoteListRepository.Outcome.ReadOnly -> ListWriteFailure.READ_ONLY + // The account is gone, stopped or undecryptable, or the server said + // something this call cannot place. Nothing specific to say beyond + // "that did not save", which is what [local] is — and specifically not + // "you are offline", which neither of them is a claim about. + RemoteListRepository.Outcome.NoAccount, + RemoteListRepository.Outcome.Unexpected, + -> local + } + data class ListOverview(val list: TaskList, val openCount: Int) data class AccountGroup(val accountName: String, val lists: List) data class SmartCount(val smart: SmartList, val count: Int) @@ -44,9 +95,28 @@ private const val UPCOMING_PREVIEW = 3 /** The home overview: smart lists with live counts, then user lists by account. */ @HiltViewModel class ListsViewModel @Inject constructor( - repository: TasksRepository, + private val repository: TasksRepository, + private val remoteLists: RemoteListRepository, + settingsPrefs: SettingsPrefs, ) : ViewModel() { + /** + * Where a new list may go: the device, then every account whose server said + * it would take one. + * + * ⚠️ Refreshed when the sheet opens rather than held. The answer comes from + * an OPTIONS, and an account added — or upgraded — since this screen was + * built has to be able to appear without a restart. + */ + private val _destinations = MutableStateFlow(emptyList()) + val destinations: StateFlow> = _destinations.asStateFlow() + + fun refreshDestinations(deviceLabel: String) = viewModelScope.launch { + val accounts = runCatching { remoteLists.creatableAccounts() }.getOrDefault(emptyList()) + _destinations.value = listOf(ListDestination(null, deviceLabel)) + + accounts.map { ListDestination(it.id, it.displayName) } + } + val state: StateFlow = combine( repository.taskLists(), @@ -54,29 +124,33 @@ class ListsViewModel @Inject constructor( // Open smart lists drop completed tasks, but the Today ring needs the // ones already ticked off to show "x of y done", so read them too. repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)), - ) { lists, openTasks, completedTasks -> - buildContent(lists, openTasks, completedTasks) as ListsUiState - }.catch { emit(ListsUiState.Failure) } + settingsPrefs.settings.map { it.hiddenFromSmartLists }.distinctUntilChanged(), + ) { lists, openTasks, completedTasks, hidden -> + buildContent(lists, openTasks, completedTasks, hidden) as ListsUiState + }.recoveringFromProviderFailure { ListsUiState.Failure } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading) private fun buildContent( lists: List, openTasks: List, completedTasks: List, + hidden: Set, ): ListsUiState.Content { val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) // Count only top-level tasks: a subtask is represented by its parent (and // its progress chip), and an open subtask under a *completed* parent must // not read as an open item here. Mirrors the task list collapsing subtasks. val topLevel = openTasks.filter { !it.isSubtask } + // The smart counts leave out the hidden lists; each list's own count does not. + val smartTopLevel = topLevel.filter { it.listId !in hidden } fun count(smart: SmartList) = - topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) } + smartTopLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) } val smartCounts = listOf( SmartCount(SmartList.TODAY, count(SmartList.TODAY)), SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)), SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)), - SmartCount(SmartList.ALL, topLevel.size), + SmartCount(SmartList.ALL, smartTopLevel.size), ) // Today ring: completed vs. total tasks *due today*. The numerator is the @@ -84,13 +158,13 @@ class ListsViewModel @Inject constructor( // denominator adds the still-open ones (the Today smart count above). val openToday = count(SmartList.TODAY) val completedDueToday = completedTasks.count { - !it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd + !it.isSubtask && it.listId !in hidden && it.due != null && it.due >= todayStart && it.due < todayEnd } val todayTotal = openToday + completedDueToday // Upcoming preview: the next handful of open tasks due tomorrow onward, // already sorted by the repository's default ordering. - val upcoming = topLevel + val upcoming = smartTopLevel .filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) } .take(UPCOMING_PREVIEW) val openByList = topLevel.groupingBy { it.listId }.eachCount() @@ -112,4 +186,36 @@ class ListsViewModel @Inject constructor( allTasks = openTasks + completedTasks, ) } + + private val _writeFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val writeFailure: StateFlow = _writeFailure.asStateFlow() + + fun clearWriteFailure() { _writeFailure.value = null } + + fun reorderLists(listIds: List) = viewModelScope.launch { + runCatching { repository.reorderLists(listIds) } + } + + /** + * Create a list, on the device or on a server. + * + * The lists flow picks it up on the store change; a refusal — External mode, + * a provider that says no, a server that answers 403 — surfaces through + * [writeFailure] rather than vanishing, because the sheet has already closed. + */ + fun createList(name: String, color: Int, accountId: Long?) = viewModelScope.launch { + if (name.isBlank()) return@launch + if (accountId == null) { + runCatching { repository.createLocalList(name.trim(), color) } + .onFailure { _writeFailure.value = ListWriteFailure.SAVE } + return@launch + } + // ⚠️ Server first. A row written before the MKCALENDAR would be a list + // that exists on the phone and nowhere else, with nothing to say so. + val outcome = runCatching { remoteLists.create(accountId, name.trim(), color) } + .getOrElse { RemoteListRepository.Outcome.Unreachable } + _writeFailure.value = outcome.asFailure(ListWriteFailure.SAVE) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AdaptiveLayout.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AdaptiveLayout.kt new file mode 100644 index 0000000..cc1411c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AdaptiveLayout.kt @@ -0,0 +1,134 @@ +package de.jeanlucmakiola.agendula.ui.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.TaskAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen +import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel +import kotlin.time.Instant + +/** Wide enough for a list and a task side by side: Material 3's expanded width class. */ +internal val LIST_DETAIL_MIN_WIDTH = 840.dp + +private val MAX_CONTENT_WIDTH = 720.dp + +/** + * A phone-shaped column centred on a tablet or a landscape screen: a task list + * or a form stretched edge to edge across 1200dp reads as a spreadsheet. + */ +@Composable +internal fun ReadableWidth(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.TopCenter, + ) { + Box(modifier = Modifier.fillMaxSize().widthIn(max = MAX_CONTENT_WIDTH)) { content() } + } +} + +/** + * The task list with the selected task's detail beside it, for wide windows. + * Opening a task selects it instead of navigating; back clears the selection + * before it leaves the list. + */ +@Composable +internal fun TaskListDetailPanes( + list: @Composable (onOpenTask: (Task) -> Unit) -> Unit, + onEdit: (Task) -> Unit, +) { + var selectedId by rememberSaveable { mutableLongStateOf(Dest.NO_ID) } + var selectedOccurrence by rememberSaveable { mutableLongStateOf(Dest.NO_ID) } + val select: (Task) -> Unit = { task -> + selectedId = task.taskId + selectedOccurrence = task.occurrenceStart?.toEpochMilliseconds() ?: Dest.NO_ID + } + val clear = { selectedId = Dest.NO_ID } + BackHandler(enabled = selectedId != Dest.NO_ID, onBack = clear) + + Row( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.weight(1f).fillMaxHeight()) { list(select) } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(topStart = 28.dp, bottomStart = 28.dp), + modifier = Modifier.weight(1.2f).fillMaxHeight(), + ) { + if (selectedId == Dest.NO_ID) { + EmptyDetailPane() + } else { + val vm: TaskDetailViewModel = hiltViewModel(key = "task-detail-pane") + LaunchedEffect(selectedId, selectedOccurrence) { + vm.bind( + selectedId, + selectedOccurrence.takeIf { it != Dest.NO_ID }?.let(Instant::fromEpochMilliseconds), + ) + } + TaskDetailScreen( + viewModel = vm, + onEdit = onEdit, + onDeleted = clear, + onBack = clear, + onOpenTask = select, + onDuplicated = { id -> + selectedId = id + selectedOccurrence = Dest.NO_ID + }, + ) + } + } + } +} + +@Composable +private fun EmptyDetailPane() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Rounded.TaskAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(R.string.detail_pane_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt index 72566c0..b9f56e3 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AgendulaNavHost.kt @@ -5,10 +5,13 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import de.jeanlucmakiola.agendula.domain.Task +import kotlin.time.Instant import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -17,6 +20,7 @@ import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel +import de.jeanlucmakiola.agendula.ui.imports.ImportScreen import de.jeanlucmakiola.agendula.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen @@ -32,9 +36,34 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel * unaware of the nav library. */ @Composable -fun AgendulaNavHost(modifier: Modifier = Modifier) { +fun AgendulaNavHost( + modifier: Modifier = Modifier, + navRequest: NavRequest? = null, + onNavRequestConsumed: () -> Unit = {}, +) { val nav = rememberNavController() + LaunchedEffect(navRequest) { + when (navRequest) { + null -> return@LaunchedEffect + is NavRequest.OpenTask -> + nav.navigate(Dest.TaskDetail.build(navRequest.taskId, navRequest.occurrenceStart)) + NavRequest.OpenAccounts -> + nav.navigate(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)) { launchSingleTop = true } + is NavRequest.OpenAccount -> + nav.navigate(Dest.Settings.buildAccount(navRequest.accountId)) { launchSingleTop = true } + is NavRequest.NewTask -> nav.navigate(Dest.TaskEdit.buildNew(title = navRequest.title)) + is NavRequest.OpenSmart -> nav.navigate(Dest.TaskList.build(TaskFilter.Smart(navRequest.list))) { + launchSingleTop = true + } + is NavRequest.OpenList -> nav.navigate(Dest.TaskList.build(TaskFilter.OfList(navRequest.listId))) { + launchSingleTop = true + } + is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri)) + } + onNavRequestConsumed() + } + NavHost( navController = nav, startDestination = Dest.LISTS, @@ -50,16 +79,33 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) { popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() }, ) { composable(Dest.LISTS) { - ListsScreen( - onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, - onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) }, - onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, - onOpenSettings = { nav.navigate(Dest.SETTINGS) }, - ) + ReadableWidth { + ListsScreen( + onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, + onOpenTask = { task -> nav.navigate(task.detailRoute()) }, + onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, + onOpenSettings = { nav.navigate(Dest.SETTINGS) }, + ) + } } - composable(Dest.SETTINGS) { - SettingsScreen(onBack = { nav.popBackStack() }) + composable(Dest.Settings.route, arguments = Dest.Settings.arguments) { entry -> + ReadableWidth { + SettingsScreen( + onBack = { nav.popBackStack() }, + initialSection = entry.arguments?.getString(Dest.Settings.ARG_SECTION), + initialAccountId = entry.arguments?.getLong(Dest.Settings.ARG_ACCOUNT)?.takeIf { it != Dest.NO_ID }, + ) + } + } + + composable(Dest.Import.route, arguments = Dest.Import.arguments) { entry -> + ReadableWidth { + ImportScreen( + onBack = { nav.popBackStack() }, + initialUri = entry.arguments?.getString(Dest.Import.ARG_URI)?.let(android.net.Uri::parse), + ) + } } composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry -> @@ -71,32 +117,45 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) { val vm: TaskListViewModel = hiltViewModel() LaunchedEffect(filter) { vm.bind(filter) } - TaskListScreen( - filter = filter, - viewModel = vm, - onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) }, - onNewTask = { - val presetListId = (filter as? TaskFilter.OfList)?.listId - nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId)) - }, - onBack = { nav.popBackStack() }, - ) + val taskList: @Composable (onOpenTask: (Task) -> Unit) -> Unit = { onOpenTask -> + TaskListScreen( + filter = filter, + viewModel = vm, + onOpenTask = onOpenTask, + onNewTask = { + val presetListId = (filter as? TaskFilter.OfList)?.listId + nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId)) + }, + onBack = { nav.popBackStack() }, + ) + } + BoxWithConstraints { + if (maxWidth >= LIST_DETAIL_MIN_WIDTH) { + TaskListDetailPanes(list = taskList, onEdit = { task -> nav.navigate(task.editRoute()) }) + } else { + ReadableWidth { taskList { task -> nav.navigate(task.detailRoute()) } } + } + } } composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry -> val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID + val occurrence = entry.arguments?.getLong(Dest.TaskDetail.ARG_OCC)?.takeIf { it != Dest.NO_ID } val vm: TaskDetailViewModel = hiltViewModel() - LaunchedEffect(taskId) { vm.bind(taskId) } + LaunchedEffect(taskId, occurrence) { vm.bind(taskId, occurrence?.let(Instant::fromEpochMilliseconds)) } - TaskDetailScreen( - viewModel = vm, - onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) }, - onDeleted = { nav.popBackStack() }, - onBack = { nav.popBackStack() }, - // A subtask opens its own detail — another entry on the stack, with - // its own VM bound to that id (it may have children of its own). - onOpenTask = { subtaskId -> nav.navigate(Dest.TaskDetail.build(subtaskId)) }, - ) + ReadableWidth { + TaskDetailScreen( + viewModel = vm, + onEdit = { task -> nav.navigate(task.editRoute()) }, + onDeleted = { nav.popBackStack() }, + onBack = { nav.popBackStack() }, + // A subtask opens its own detail — another entry on the stack, with + // its own VM bound to that id (it may have children of its own). + onOpenTask = { subtask -> nav.navigate(subtask.detailRoute()) }, + onDuplicated = { id -> nav.navigate(Dest.TaskDetail.build(id)) }, + ) + } } composable(Dest.TaskEdit.route, arguments = Dest.TaskEdit.arguments) { entry -> @@ -104,23 +163,33 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) { val taskId = args?.getLong(Dest.TaskEdit.ARG_TASK_ID) ?: Dest.NO_ID val presetListId = args?.getLong(Dest.TaskEdit.ARG_PRESET_LIST_ID) ?: Dest.NO_ID val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID + val occurrence = args?.getLong(Dest.TaskEdit.ARG_OCCURRENCE)?.takeIf { it != Dest.NO_ID } + val title = args?.getString(Dest.TaskEdit.ARG_TITLE) val vm: TaskEditViewModel = hiltViewModel() LaunchedEffect(taskId, presetListId, parentId) { if (taskId != Dest.NO_ID) { - vm.bindEdit(taskId) + vm.bindEdit(taskId, occurrence?.let(Instant::fromEpochMilliseconds)) } else { vm.bindNew( presetListId = presetListId.takeIf { it != Dest.NO_ID }, parentId = parentId.takeIf { it != Dest.NO_ID }, + initialTitle = title, ) } } - TaskEditScreen( - viewModel = vm, - onSaved = { nav.popBackStack() }, - onBack = { nav.popBackStack() }, - ) + ReadableWidth { + TaskEditScreen( + viewModel = vm, + onSaved = { nav.popBackStack() }, + onBack = { nav.popBackStack() }, + ) + } } } } + +/** A task's detail route, pinned to its occurrence when it is one of a series. */ +private fun Task.detailRoute(): String = Dest.TaskDetail.build(taskId, occurrenceStart?.toEpochMilliseconds()) + +private fun Task.editRoute(): String = Dest.TaskEdit.buildEdit(taskId, occurrenceStart?.toEpochMilliseconds()) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AppShortcuts.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AppShortcuts.kt new file mode 100644 index 0000000..5c8ed14 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/AppShortcuts.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.agendula.ui.navigation + +import android.content.Context +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import de.jeanlucmakiola.agendula.MainActivity +import de.jeanlucmakiola.agendula.R + +/** + * The launcher's long-press shortcuts. Published at runtime rather than from a + * static `shortcuts.xml`, whose intents would have to name the release package + * and so break in the suffixed debug and releaseTest builds — and so the labels + * follow the app language. + */ +object AppShortcuts { + + fun publish(context: Context) { + val shortcuts = listOf( + ShortcutInfoCompat.Builder(context, "new_task") + .setShortLabel(context.getString(R.string.shortcut_new_task)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_new_task)) + .setIntent(MainActivity.newTaskIntent(context)) + .build(), + ShortcutInfoCompat.Builder(context, "today") + .setShortLabel(context.getString(R.string.shortcut_today)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_today)) + .setIntent(MainActivity.todayIntent(context)) + .build(), + ) + runCatching { ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt index 274bbbf..a89ca47 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/Destinations.kt @@ -23,9 +23,35 @@ object Dest { /** Home — smart lists + the user's lists. */ const val LISTS = "lists" - /** App preferences. */ + /** App preferences, on the hub. */ const val SETTINGS = "settings" + /** An `.ics` handed to us by another app. */ + object Import { + const val ARG_URI = "uri" + const val route = "import?$ARG_URI={$ARG_URI}" + val arguments = listOf(navArgument(ARG_URI) { type = NavType.StringType; nullable = true; defaultValue = null }) + fun build(uri: android.net.Uri): String = "import?$ARG_URI=${android.net.Uri.encode(uri.toString())}" + } + + /** App preferences, optionally opened on one sub-screen. */ + object Settings { + const val ARG_SECTION = "section" + const val ARG_ACCOUNT = "account" + const val route = "settings?$ARG_SECTION={$ARG_SECTION}&$ARG_ACCOUNT={$ARG_ACCOUNT}" + const val SECTION_ACCOUNTS = "Accounts" + + val arguments = listOf( + navArgument(ARG_SECTION) { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument(ARG_ACCOUNT) { type = NavType.LongType; defaultValue = NO_ID }, + ) + + fun buildAccount(accountId: Long): String = "settings?$ARG_ACCOUNT=$accountId" + + fun build(section: String? = null): String = + if (section == null) SETTINGS else "settings?$ARG_SECTION=$section" + } + /** * One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or* * `smart` (a [SmartList] name); whichever is set decides the filter. @@ -51,12 +77,20 @@ object Dest { else TaskFilter.OfList(listId) } - /** A single task's detail. */ + /** A single task's detail; `occ` (epoch millis) picks one occurrence of a series. */ object TaskDetail { const val ARG_TASK_ID = "taskId" - const val route = "taskDetail/{$ARG_TASK_ID}" - val arguments = listOf(navArgument(ARG_TASK_ID) { type = NavType.LongType }) - fun build(taskId: Long): String = "taskDetail/$taskId" + + /** The occurrence of a recurring task, as epoch millis; [NO_ID] = the current one. */ + const val ARG_OCC = "occ" + const val route = "taskDetail/{$ARG_TASK_ID}?$ARG_OCC={$ARG_OCC}" + val arguments = listOf( + navArgument(ARG_TASK_ID) { type = NavType.LongType }, + navArgument(ARG_OCC) { type = NavType.LongType; defaultValue = NO_ID }, + ) + + fun build(taskId: Long, occurrenceStart: Long? = null): String = + if (occurrenceStart == null) "taskDetail/$taskId" else "taskDetail/$taskId?$ARG_OCC=$occurrenceStart" } /** @@ -67,23 +101,32 @@ object Dest { const val ARG_TASK_ID = "taskId" const val ARG_PRESET_LIST_ID = "presetListId" const val ARG_PARENT_ID = "parentId" + const val ARG_OCCURRENCE = "occ" + const val ARG_TITLE = "title" const val route = "taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" + "&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" + - "&$ARG_PARENT_ID={$ARG_PARENT_ID}" + "&$ARG_PARENT_ID={$ARG_PARENT_ID}" + + "&$ARG_OCCURRENCE={$ARG_OCCURRENCE}" + + "&$ARG_TITLE={$ARG_TITLE}" val arguments = listOf( navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_PRESET_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID }, navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID }, + navArgument(ARG_OCCURRENCE) { type = NavType.LongType; defaultValue = NO_ID }, + navArgument(ARG_TITLE) { type = NavType.StringType; nullable = true; defaultValue = null }, ) - fun buildEdit(taskId: Long): String = "taskEdit?$ARG_TASK_ID=$taskId" + fun buildEdit(taskId: Long, occurrenceStart: Long? = null): String = + if (occurrenceStart == null) "taskEdit?$ARG_TASK_ID=$taskId" + else "taskEdit?$ARG_TASK_ID=$taskId&$ARG_OCCURRENCE=$occurrenceStart" - fun buildNew(presetListId: Long? = null, parentId: Long? = null): String { + fun buildNew(presetListId: Long? = null, parentId: Long? = null, title: String? = null): String { val params = buildList { if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId") if (parentId != null) add("$ARG_PARENT_ID=$parentId") + if (!title.isNullOrBlank()) add("$ARG_TITLE=${android.net.Uri.encode(title)}") } return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}" } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt new file mode 100644 index 0000000..d4c94d9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/navigation/NavRequest.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.agendula.ui.navigation + +import android.net.Uri +import de.jeanlucmakiola.agendula.domain.SmartList + +/** + * A navigation something outside the app asked for — a notification tap. + * Parsed from the launch intent in `MainActivity` and consumed once by + * [AgendulaNavHost], which pushes it over whatever is showing. + */ +sealed interface NavRequest { + /** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */ + data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest + + /** Settings → Accounts, where a sync report's detail lives. */ + data object OpenAccounts : NavRequest + + /** One account's detail — where "sign in again" lives. */ + data class OpenAccount(val accountId: Long) : NavRequest + + /** A new task, optionally titled with text shared from another app. */ + data class NewTask(val title: String? = null) : NavRequest + + /** One of the smart lists — the launcher's "Today" shortcut, a widget header. */ + data class OpenSmart(val list: SmartList) : NavRequest + + /** One real list — a widget header. */ + data class OpenList(val listId: Long) : NavRequest + + /** An `.ics` another app handed over. */ + data class Import(val uri: Uri) : NavRequest +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/BackupStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/BackupStep.kt new file mode 100644 index 0000000..e610c70 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/BackupStep.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SaveAlt +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R + +/** + * The device-only branch's last word: their lists live here and nowhere else, + * and this says where the export that fixes that lives. There is nothing to + * save yet on a first run, so it points the way rather than exporting an empty list. + */ +@Composable +internal fun BackupStep( + chrome: StepChrome, + onContinue: () -> Unit, +) { + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.SaveAlt) }, + actions = { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_backup_continue), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_backup_title), + body = stringResource(R.string.onboarding_backup_body_later), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/ExactAlarmStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/ExactAlarmStep.kt new file mode 100644 index 0000000..a94fd12 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/ExactAlarmStep.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Alarm +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.exactAlarmSettingsIntent + +/** + * Shown after reminders were turned on, when exact alarms are not yet granted. + * The grant lives on a system page, so this sends the user there and moves on + * whatever they chose — without it reminders still arrive, just less precisely. + */ +@Composable +internal fun ExactAlarmStep(chrome: StepChrome, onContinue: () -> Unit) { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { onContinue() } + + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.Alarm) }, + actions = { + Button( + onClick = { + val launched = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + runCatching { launcher.launch(context.exactAlarmSettingsIntent()) }.isSuccess + if (!launched) onContinue() + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_exact_alarms_allow), + style = MaterialTheme.typography.titleMedium, + ) + } + TextButton(onClick = onContinue, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.reminder_onboarding_skip_button)) + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_exact_alarms_title), + body = stringResource(R.string.onboarding_exact_alarms_body), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/FirstListStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/FirstListStep.kt new file mode 100644 index 0000000..ad2ba90 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/FirstListStep.kt @@ -0,0 +1,82 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.agendula.ui.lists.ListColorGrid +import de.jeanlucmakiola.agendula.ui.lists.ListNameField +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * Somewhere to put a task, so first run does not end on an empty screen. Only + * reached when the user has no lists at all, so there is no way past it — the + * name and colour come prefilled and the button is one tap. + * + * The list is always a device-local one, even though the app can create lists on + * a server (`RemoteListRepository`): this step stays one tap, and a synced user + * who lands here (an account whose server held no task collections) can make a + * server list from the lists screen afterwards. + */ +@Composable +internal fun FirstListStep(chrome: StepChrome, onCreate: (name: String, color: Int) -> Unit) { + val suggested = stringResource(R.string.onboarding_list_default_name) + var name by rememberSaveable { mutableStateOf(suggested) } + var color by rememberSaveable { mutableIntStateOf(DefaultListColor) } + val commit = { if (name.isNotBlank()) onCreate(name, color) } + + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.Folder) }, + // The field and the palette carry their own inset. + contentPadding = 0.dp, + actions = { + Button( + onClick = commit, + enabled = name.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_list_create), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_list_title), + body = stringResource(R.string.onboarding_list_body), + horizontalPadding = GroupedListInset, + ) + + Spacer(Modifier.height(OnboardingSpace.lg)) + + // Prefilled and not focused: the step is a one-tap accept by default, and + // raising the keyboard over the palette would hide the other half of it. + ListNameField( + name = name, + color = color, + autoFocus = false, + onNameChange = { name = it }, + onImeAction = { commit() }, + ) + Spacer(Modifier.height(20.dp)) + ListColorGrid(selected = color, onSelect = { color = it }) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt new file mode 100644 index 0000000..6afa7fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingFlow.kt @@ -0,0 +1,183 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen +import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel +import de.jeanlucmakiola.floret.components.OnboardingProgress +import de.jeanlucmakiola.floret.components.OnboardingScaffold +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * First run, end to end: welcome, reminders, the offer to sync, and then the + * loose ends that offer leaves — a list, a copy, the quick-add bar. Which steps + * run is [OnboardingViewModel]'s business; this only draws the one it is on. + * + * [OnboardingStep.Account] hands the whole screen to the add-account wizard, + * which already speaks `OnboardingScaffold` — it just needs to be told where in + * *this* flow its own steps sit, so the progress stays one bar. + */ +@Composable +fun OnboardingFlow( + modifier: Modifier = Modifier, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + if (state.step == OnboardingStep.Account) { + // The same instance the wizard resolves for itself, so its length can be + // read here without keeping a second copy of its state. + val account: AddAccountViewModel = hiltViewModel() + val accountState by account.state.collectAsStateWithLifecycle() + // ⚠️ The wizard grows a step for a provider that needs an app password + // minted first, and the outer bar has to grow with it — otherwise first + // run counts one flow while the screen inside it counts another. + LaunchedEffect(accountState.totalSteps) { + viewModel.onAccountStepsChanged(accountState.totalSteps) + } + AddAccountScreen( + onDone = { viewModel.onAccountFinished(added = true) }, + onBack = { viewModel.onAccountFinished(added = false) }, + stepOffset = state.position - 1, + totalSteps = state.total, + viewModel = account, + ) + return + } + + val chrome = StepChrome(state, onBack = viewModel::back, modifier = modifier) + // System back steps back through the flow rather than leaving the app mid-way. + BackHandler(enabled = state.canGoBack) { viewModel.back() } + + when (state.step) { + OnboardingStep.Welcome -> WelcomeStep(chrome, onContinue = viewModel::onWelcomeDone) + OnboardingStep.Reminders -> RemindersStep(chrome, onAnswered = viewModel::onRemindersAnswered) + OnboardingStep.ExactAlarms -> ExactAlarmStep(chrome, onContinue = viewModel::onExactAlarmsDone) + OnboardingStep.SyncOffer -> SyncOfferStep(chrome, onAnswered = viewModel::onSyncAnswered) + OnboardingStep.FirstList -> FirstListStep(chrome, onCreate = viewModel::createFirstList) + OnboardingStep.Backup -> BackupStep(chrome, onContinue = viewModel::onBackupDone) + OnboardingStep.QuickAdd -> QuickAddStep( + chrome = chrome, + enabled = state.bottomAddBar, + onToggle = viewModel::setBottomAddBar, + onContinue = viewModel::finish, + ) + OnboardingStep.Account -> Unit + } +} + +/** + * The parts of a step's screen that belong to the flow rather than the step: + * where it sits in the progress, and whether there is a way back from it. Passed + * down so a step supplies only what is its own. + */ +@Immutable +internal data class StepChrome( + val state: OnboardingUiState, + val onBack: () -> Unit, + val modifier: Modifier, +) + +/** The family's onboarding shell with the flow's [chrome] already filled in. */ +@Composable +internal fun StepScaffold( + chrome: StepChrome, + hero: @Composable () -> Unit, + actions: @Composable ColumnScope.() -> Unit, + contentPadding: Dp = OnboardingSpace.md, + topSpacing: Dp = OnboardingSpace.md, + scrollingActions: Boolean = false, + body: @Composable ColumnScope.() -> Unit, +) { + OnboardingScaffold( + modifier = chrome.modifier, + hero = hero, + progress = { + OnboardingProgress( + step = chrome.state.position, + total = chrome.state.total, + label = stringResource( + R.string.add_account_step_of, + chrome.state.position, + chrome.state.total, + ), + ) + }, + navigationIcon = if (!chrome.state.canGoBack) { + null + } else { + { + IconButton(onClick = chrome.onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + }, + contentPadding = contentPadding, + topSpacing = topSpacing, + scrollingActions = scrollingActions, + actions = actions, + body = body, + ) +} + +/** + * A step's opening lines: an optional eyebrow, the headline, and one paragraph, + * centred above whatever the step asks for. [horizontalPadding] is for a step + * that passed `contentPadding = 0` because its own content carries the inset. + */ +@Composable +internal fun ColumnScope.StepHeader( + title: String, + body: String, + eyebrow: String? = null, + horizontalPadding: Dp = 0.dp, +) { + if (eyebrow != null) { + Text( + text = eyebrow, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 2.sp, + ) + Spacer(Modifier.height(OnboardingSpace.xs)) + } + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = horizontalPadding), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/OnboardingHero.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingHero.kt similarity index 96% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/OnboardingHero.kt rename to app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingHero.kt index 8795a00..2e960f6 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/OnboardingHero.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingHero.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.agendula.ui.permission +package de.jeanlucmakiola.agendula.ui.onboarding import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..91f7a33 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,250 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.ui.accounts.add.ADD_ACCOUNT_STEPS +import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A stop in the first-run flow. [Account] is the add-account wizard hosted + * inline, which is why it is worth more than one segment of the progress bar. + */ +enum class OnboardingStep { + Welcome, + Reminders, + ExactAlarms, + SyncOffer, + Account, + FirstList, + Backup, + QuickAdd, + ; + + /** + * How many progress segments the step occupies, given the length the inline + * wizard currently reports. + * + * ⚠️ Not a constant for [Account]. The wizard grows a step for a service + * that needs an app password minted first, so its length is only known once + * a provider has been picked — and every step after it shifts with it. + */ + fun slots(accountSlots: Int): Int = if (this == Account) accountSlots else 1 +} + +data class OnboardingUiState( + val step: OnboardingStep = OnboardingStep.Welcome, + /** 1-based segment this step starts at, for [OnboardingStep.Account] its first. */ + val position: Int = 1, + val total: Int = 1, + val canGoBack: Boolean = false, + val bottomAddBar: Boolean = false, +) + +/** + * Drives first run: welcome, reminders, an offer to connect a CalDAV account, + * then whatever the answer to that leaves outstanding — a list to put tasks in, + * a copy of them off the device, and the quick-add bar. + * + * The flow branches, so the step list is computed rather than fixed: connecting + * an account replaces the backup step (the server *is* the copy), and the list + * step only appears for a user who would otherwise finish with nowhere to put a + * task — which a synced account usually settles on its own. + * + * Going back is offered only where nothing has been written yet. Once a list + * exists or an account has been added, the way on is forward. + */ +@HiltViewModel +class OnboardingViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val prefs: SettingsPrefs, + private val repository: TasksRepository, +) : ViewModel() { + + /** Null until DataStore's first emission, so the flow neither flashes nor is skipped. */ + val done: StateFlow = prefs.onboardingDone + .map { it as Boolean? } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null) + + private val hasLists = repository.taskLists() + .recoveringFromProviderFailure { emptyList() } + .map { it.isNotEmpty() } + + private val step = MutableStateFlow(OnboardingStep.Welcome) + + /** + * How long the inline add-account wizard says it is. + * + * Seeded with its shortest form and corrected by the flow itself once a + * provider is chosen, so the bar is honest from the first frame and stays + * honest when the choice makes the flow longer. + */ + private val accountSlots = MutableStateFlow(ADD_ACCOUNT_STEPS) + + fun onAccountStepsChanged(count: Int) { + accountSlots.value = count + } + + /** Answered at [OnboardingStep.SyncOffer]; null while it is still the question. */ + private val connectsAccount = MutableStateFlow(null) + + /** + * Snapshotted rather than observed: the list step decides whether it belongs + * in the flow *before* it runs, and must not vanish from under the user the + * moment their own list makes the answer false. Seeded from the store so the + * progress bar is honest from the first frame rather than resizing later. + */ + private val needsList = MutableStateFlow(true) + + init { + viewModelScope.launch { needsList.value = !hasLists.first() } + } + + /** + * Whether the exact-alarm step is in the flow: reminders on and the grant + * missing. Counted up front since reminders default on, dropped by "not now". + */ + private val needsExactAlarms = MutableStateFlow(!context.canScheduleExactAlarms()) + + val state: StateFlow = + combine( + step, + connectsAccount, + combine(needsList, needsExactAlarms, ::Pair), + prefs.settings, + accountSlots, + ) { step, sync, (needsList, needsExact), settings, slots -> + val plan = plan(sync ?: false, needsList, needsExact) + OnboardingUiState( + step = step, + position = plan.takeWhile { it != step }.sumOf { it.slots(slots) } + 1, + total = plan.sumOf { it.slots(slots) }, + canGoBack = canGoBack(step, sync), + bottomAddBar = settings.bottomAddBar, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), OnboardingUiState()) + + fun onWelcomeDone() = advanceTo(OnboardingStep.Reminders) + + /** Reminders default on; "not now" turns the in-app toggle off. */ + fun onRemindersAnswered(enabled: Boolean) { + viewModelScope.launch { + prefs.setRemindersEnabled(enabled) + needsExactAlarms.value = enabled && !context.canScheduleExactAlarms() + advanceTo(if (needsExactAlarms.value) OnboardingStep.ExactAlarms else OnboardingStep.SyncOffer) + } + } + + fun onExactAlarmsDone() = advanceTo(OnboardingStep.SyncOffer) + + fun onSyncAnswered(connect: Boolean) { + connectsAccount.value = connect + if (connect) { + step.value = OnboardingStep.Account + } else { + advanceTo(OnboardingStep.FirstList) + } + } + + /** The wizard finished, or was abandoned from its own first step. */ + fun onAccountFinished(added: Boolean) { + if (!added) { + connectsAccount.value = null + step.value = OnboardingStep.SyncOffer + return + } + advanceTo(OnboardingStep.FirstList) + } + + fun createFirstList(name: String, color: Int) { + if (name.isBlank()) return + viewModelScope.launch { + runCatching { repository.createLocalList(name.trim(), color) } + afterFirstList() + } + } + + fun onBackupDone() = advanceTo(OnboardingStep.QuickAdd) + + fun setBottomAddBar(enabled: Boolean) { + viewModelScope.launch { prefs.setBottomAddBar(enabled) } + } + + fun finish() { + viewModelScope.launch { prefs.setOnboardingDone() } + } + + fun back() { + step.value = when (step.value) { + OnboardingStep.Reminders -> OnboardingStep.Welcome + OnboardingStep.ExactAlarms -> OnboardingStep.Reminders + // Once granted there is nothing left to ask, so back skips the step. + OnboardingStep.SyncOffer -> + if (needsExactAlarms.value && !context.canScheduleExactAlarms()) OnboardingStep.ExactAlarms + else OnboardingStep.Reminders + OnboardingStep.FirstList -> OnboardingStep.SyncOffer + else -> return + } + } + + /** + * Back is offered only where nothing has been written. The list step + * qualifies on the way through the local branch, but not when an account has + * just been added behind it — there is nothing back there to change. + */ + private fun canGoBack(step: OnboardingStep, sync: Boolean?): Boolean = when (step) { + OnboardingStep.Reminders, OnboardingStep.ExactAlarms, OnboardingStep.SyncOffer -> true + OnboardingStep.FirstList -> sync != true + else -> false + } + + /** + * Moves to [target], or past it when the flow no longer needs it. Only the + * list step is conditional, and only it re-reads the store: a synced account + * has usually brought lists of its own by now, and a restored backup may have + * done the same. + */ + private fun advanceTo(target: OnboardingStep) { + if (target != OnboardingStep.FirstList) { + step.value = target + return + } + viewModelScope.launch { + val already = hasLists.first() + needsList.value = !already + step.value = if (already) stepAfterFirstList() else OnboardingStep.FirstList + } + } + + private fun afterFirstList() { + step.value = stepAfterFirstList() + } + + private fun stepAfterFirstList(): OnboardingStep = + if (connectsAccount.value == true) OnboardingStep.QuickAdd else OnboardingStep.Backup + + private fun plan(sync: Boolean, needsList: Boolean, needsExact: Boolean): List = buildList { + add(OnboardingStep.Welcome) + add(OnboardingStep.Reminders) + if (needsExact) add(OnboardingStep.ExactAlarms) + add(OnboardingStep.SyncOffer) + if (sync) add(OnboardingStep.Account) + if (needsList) add(OnboardingStep.FirstList) + if (!sync) add(OnboardingStep.Backup) + add(OnboardingStep.QuickAdd) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/QuickAddStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/QuickAddStep.kt new file mode 100644 index 0000000..2050271 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/QuickAddStep.kt @@ -0,0 +1,104 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.tasklist.TaskListPreview +import de.jeanlucmakiola.floret.components.GroupedListInset +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OnboardingSpace +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.ViewPreviewFrame +import de.jeanlucmakiola.floret.components.positionOf + +/** + * The last step: how a list offers to add a task — the bar pinned to the bottom, + * or the floating button. Built like the family's other preview pickers: a live + * task list over connected grouped rows, where picking applies immediately and + * the preview is the confirmation, so the answer is given by looking rather than + * by imagining. + */ +@Composable +internal fun QuickAddStep( + chrome: StepChrome, + enabled: Boolean, + onToggle: (Boolean) -> Unit, + onContinue: () -> Unit, +) { + val options = listOf(true, false) + + StepScaffold( + chrome = chrome, + // Full-bleed: the preview plate and the rows carry the inset themselves. + contentPadding = 0.dp, + topSpacing = OnboardingSpace.xs, + // The preview gets the room it needs; Continue scrolls in under the + // options rather than pinning to the bottom. + scrollingActions = true, + hero = { + Text( + text = stringResource(R.string.onboarding_quick_add_title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.fillMaxWidth().padding(horizontal = GroupedListInset), + ) + }, + actions = { + Button( + onClick = onContinue, + modifier = Modifier + .padding(horizontal = GroupedListInset) + .fillMaxWidth() + .height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_quick_add_done), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + ViewPreviewFrame( + selected = enabled, + label = "quick-add-preview", + modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md), + ) { bottomAddBar -> + TaskListPreview(bottomAddBar = bottomAddBar, height = PreviewHeight) + } + + options.forEachIndexed { index, option -> + val isSelected = option == enabled + GroupedRow( + title = stringResource( + if (option) R.string.onboarding_quick_add_bar else R.string.onboarding_quick_add_button, + ), + summary = stringResource( + if (option) { + R.string.onboarding_quick_add_bar_hint + } else { + R.string.onboarding_quick_add_button_hint + }, + ), + position = positionOf(index, options.size), + selected = isSelected, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { onToggle(option) }, + ) + } + } +} + +/** The size the family's other preview pickers give it. */ +private val PreviewHeight: Dp = 280.dp diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/RemindersStep.kt similarity index 66% rename from app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingScreen.kt rename to app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/RemindersStep.kt index 80242d0..7c370d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/RemindersStep.kt @@ -1,4 +1,4 @@ -package de.jeanlucmakiola.agendula.ui.permission +package de.jeanlucmakiola.agendula.ui.onboarding import android.Manifest import android.os.Build @@ -18,34 +18,29 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.floret.components.BenefitRow -import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingSpace /** - * One-time onboarding step after the tasks-provider grant: explains that Agendula - * delivers due reminders itself (tasks providers don't broadcast reminders) and - * requests `POST_NOTIFICATIONS` (a system dialog on API 33+ only). + * Offers due reminders and requests `POST_NOTIFICATIONS` (a system dialog on + * API 33+ only). Framed as Agendula's own feature: it schedules and delivers + * them itself, which is also what makes it work over an external store, since + * no tasks provider broadcasts reminders. * - * Reminders default ON: [onFinished] gets true from the primary action even if + * Reminders default ON: [onAnswered] gets true from the primary action even if * the system dialog is declined — the OS permission is the real gate, and the * Settings toggle re-requests it. "Not now" turns the in-app toggle off. */ @Composable -fun ReminderOnboardingScreen( - onFinished: (remindersEnabled: Boolean) -> Unit, - modifier: Modifier = Modifier, -) { +internal fun RemindersStep(chrome: StepChrome, onAnswered: (enabled: Boolean) -> Unit) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), - ) { onFinished(true) } + ) { onAnswered(true) } - OnboardingScaffold( - modifier = modifier, + StepScaffold( + chrome = chrome, hero = { SquircleHero(Icons.Rounded.Notifications) }, actions = { Button( @@ -53,7 +48,7 @@ fun ReminderOnboardingScreen( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { - onFinished(true) + onAnswered(true) } }, modifier = Modifier.fillMaxWidth().height(56.dp), @@ -64,31 +59,16 @@ fun ReminderOnboardingScreen( ) } TextButton( - onClick = { onFinished(false) }, + onClick = { onAnswered(false) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.reminder_onboarding_skip_button)) } }, ) { - Text( - text = stringResource(R.string.app_name).uppercase(), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - letterSpacing = 2.sp, - ) - Spacer(Modifier.height(OnboardingSpace.xs)) - Text( - text = stringResource(R.string.reminder_onboarding_title), - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(12.dp)) - Text( - text = stringResource(R.string.reminder_onboarding_body), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, + StepHeader( + title = stringResource(R.string.reminder_onboarding_title), + body = stringResource(R.string.reminder_onboarding_body), ) Spacer(Modifier.height(OnboardingSpace.xl)) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/SyncOfferStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/SyncOfferStep.kt new file mode 100644 index 0000000..9f64819 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/SyncOfferStep.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Backup +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.BenefitRow +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** + * The fork: connect a CalDAV account now, or stay on the device. Saying yes + * hands the next three steps to the add-account wizard; saying no leaves a list + * to make and a copy to think about. + */ +@Composable +internal fun SyncOfferStep(chrome: StepChrome, onAnswered: (connect: Boolean) -> Unit) { + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.CloudSync) }, + actions = { + Button( + onClick = { onAnswered(true) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_sync_connect), + style = MaterialTheme.typography.titleMedium, + ) + } + TextButton( + onClick = { onAnswered(false) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.onboarding_sync_skip)) + } + }, + ) { + StepHeader( + title = stringResource(R.string.onboarding_sync_title), + body = stringResource(R.string.onboarding_sync_body), + ) + + Spacer(Modifier.height(OnboardingSpace.xl)) + + BenefitRow( + icon = Icons.Rounded.Devices, + title = stringResource(R.string.onboarding_sync_devices_title), + body = stringResource(R.string.onboarding_sync_devices_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.Backup, + title = stringResource(R.string.onboarding_sync_copy_title), + body = stringResource(R.string.onboarding_sync_copy_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.Lock, + title = stringResource(R.string.onboarding_sync_yours_title), + body = stringResource(R.string.onboarding_sync_yours_body), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt new file mode 100644 index 0000000..4c74bc7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/onboarding/WelcomeStep.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.agendula.ui.onboarding + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.BenefitRow +import de.jeanlucmakiola.floret.components.OnboardingSpace + +/** What the app is, before it asks for anything. */ +@Composable +internal fun WelcomeStep(chrome: StepChrome, onContinue: () -> Unit) { + StepScaffold( + chrome = chrome, + hero = { SquircleHero(Icons.Rounded.Checklist) }, + actions = { + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.onboarding_welcome_button), + style = MaterialTheme.typography.titleMedium, + ) + } + }, + ) { + StepHeader( + eyebrow = stringResource(R.string.app_name).uppercase(), + title = stringResource(R.string.onboarding_welcome_title), + body = stringResource(R.string.onboarding_welcome_body), + ) + + Spacer(Modifier.height(OnboardingSpace.xl)) + + BenefitRow( + icon = Icons.Rounded.Checklist, + title = stringResource(R.string.onboarding_welcome_lists_title), + body = stringResource(R.string.onboarding_welcome_lists_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.NotificationsActive, + title = stringResource(R.string.onboarding_welcome_reminders_title), + body = stringResource(R.string.onboarding_welcome_reminders_body), + ) + Spacer(Modifier.height(OnboardingSpace.sm)) + BenefitRow( + icon = Icons.Rounded.CloudSync, + title = stringResource(R.string.onboarding_welcome_sync_title), + body = stringResource(R.string.onboarding_welcome_sync_body), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt index 9e6760a..5c1e89c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/PermissionViewModel.kt @@ -1,13 +1,25 @@ package de.jeanlucmakiola.agendula.ui.permission import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus +import de.jeanlucmakiola.agendula.data.tasks.StorageMode import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject data class PermissionUiState( @@ -20,23 +32,51 @@ data class PermissionUiState( * Gates app entry: is a tasks provider installed, and do we hold its permissions? * The Composable owns the actual permission-launcher and store intents; this VM * supplies the [status] and the exact permission strings to ask for. + * + * In the default Own mode this gate never appears at all — the store is our own + * Room database, so there is nothing to install and nothing to grant. It exists + * for External mode, which also makes it the only screen an External user can + * reach once their provider app stops answering: hence [useOwnStore]. */ @HiltViewModel class PermissionViewModel @Inject constructor( private val repository: TasksRepository, private val providerResolver: ProviderResolver, + private val prefs: SettingsPrefs, ) : ViewModel() { - private val _state = MutableStateFlow(PermissionUiState()) - val state: StateFlow = _state.asStateFlow() + private val refreshes = MutableStateFlow(0) - init { refresh() } + // Re-evaluated when the *resolver's* mode lands, not when the preference is + // written: anything read in between still answers for the store we just left. + // Conflated, as everywhere else this signal is bridged: only the latest mode + // matters, and a full buffer drops it rather than the ones it supersedes. + private val modeChanges: Flow = callbackFlow { + trySend(Unit) + val handle = providerResolver.onModeChanged { trySend(Unit) } + awaitClose { handle.close() } + }.buffer(Channel.CONFLATED) + + val state: StateFlow = + combine(refreshes, modeChanges) { _, _ -> currentState() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState()) /** Re-read provider + permission state (call after returning from a request). */ - fun refresh() { + fun refresh() = refreshes.update { it + 1 } + + /** + * Leave a store this device can no longer read. The provider app can be + * uninstalled, or its permission revoked, after External was chosen — and the + * gate is then the only screen reachable, Settings included. Our own store + * always reads, so it is the way out. + */ + fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) } + + private fun currentState(): PermissionUiState { val provider = providerResolver.resolve() - _state.value = PermissionUiState( + return PermissionUiState( status = repository.providerStatus(), + // Null in OWN mode, where there is no provider and nothing to grant. permissionsToRequest = provider ?.let { listOf(it.readPermission, it.writePermission) } .orEmpty(), diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingViewModel.kt deleted file mode 100644 index cac8cda..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/permission/ReminderOnboardingViewModel.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.jeanlucmakiola.agendula.ui.permission - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * Gates the one-time reminder onboarding step shown after the tasks-provider - * grant. [onboardingDone] is null until DataStore's first emission so the step - * neither flashes for users who completed it nor gets skipped. - */ -@HiltViewModel -class ReminderOnboardingViewModel @Inject constructor( - private val prefs: SettingsPrefs, -) : ViewModel() { - - val onboardingDone: StateFlow = prefs.reminderOnboardingDone - .map { done -> done as Boolean? } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), - initialValue = null, - ) - - /** Close the step, recording whether due reminders stay on. */ - fun finish(remindersEnabled: Boolean) { - viewModelScope.launch { - prefs.setRemindersEnabled(remindersEnabled) - prefs.setReminderOnboardingDone() - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt new file mode 100644 index 0000000..b4137bc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppearanceScreen.kt @@ -0,0 +1,164 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.os.Build +import android.text.format.DateFormat +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref +import de.jeanlucmakiola.agendula.data.prefs.is24Hour +import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.locale.currentLocale +import java.time.DayOfWeek +import java.time.format.TextStyle + +/** How the app looks: the theme, wallpaper colours, and how times and weeks are written. */ +@Composable +internal fun AppearanceScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + var showTheme by remember { mutableStateOf(false) } + var showTimeFormat by remember { mutableStateOf(false) } + var showWeekStart by remember { mutableStateOf(false) } + val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) + val locale = currentLocale() + + CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_theme), + summary = stringResource(themeLabel(state.settings.themeMode)), + position = Position.Top, + onClick = { showTheme = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_dynamic_color), + summary = if (dynamicColorAvailable) { + stringResource(R.string.settings_dynamic_color_hint) + } else { + stringResource(R.string.settings_dynamic_color_unavailable) + }, + position = Position.Bottom, + trailing = { + Switch( + checked = state.settings.dynamicColor && dynamicColorAvailable, + onCheckedChange = viewModel::setDynamicColor, + enabled = dynamicColorAvailable, + ) + }, + onClick = if (dynamicColorAvailable) { + { viewModel.setDynamicColor(!state.settings.dynamicColor) } + } else { + null + }, + ) + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_time_format), + summary = timeFormatLabel(state.settings.timeFormat), + position = Position.Top, + onClick = { showTimeFormat = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_week_start), + summary = weekStartLabel(state.settings.weekStart), + position = Position.Bottom, + onClick = { showWeekStart = true }, + ) + } + + if (showTimeFormat) { + OptionPicker( + title = stringResource(R.string.settings_time_format), + options = TimeFormatPref.entries, + selected = state.settings.timeFormat, + label = { timeFormatLabel(it) }, + summary = { pref -> + if (pref == TimeFormatPref.AUTO) { + stringResource( + R.string.settings_time_format_auto_summary, + formatMinuteOfDay(SAMPLE_MINUTE_OF_DAY, pref.is24Hour(systemIs24Hour), locale), + ) + } else { + null + } + }, + onSelect = viewModel::setTimeFormat, + onDismiss = { showTimeFormat = false }, + ) + } + + if (showWeekStart) { + OptionPicker( + title = stringResource(R.string.settings_week_start), + options = listOf(null) + DayOfWeek.entries, + selected = state.settings.weekStart, + label = { weekStartLabel(it) }, + summary = { day -> + if (day == null) { + stringResource( + R.string.settings_week_start_auto_summary, + localeFirstDayOfWeek(locale).getDisplayName(TextStyle.FULL, locale), + ) + } else { + null + } + }, + onSelect = viewModel::setWeekStart, + onDismiss = { showWeekStart = false }, + ) + } + + if (showTheme) { + OptionPicker( + title = stringResource(R.string.settings_theme), + options = ThemeMode.entries, + selected = state.settings.themeMode, + label = { stringResource(themeLabel(it)) }, + onSelect = viewModel::setThemeMode, + onDismiss = { showTheme = false }, + ) + } +} + +@Composable +private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource( + when (pref) { + TimeFormatPref.AUTO -> R.string.settings_time_format_auto + TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h + TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h + }, +) + +/** "Automatic", or the localised weekday name. */ +@Composable +private fun weekStartLabel(day: DayOfWeek?): String = + day?.getDisplayName(TextStyle.FULL, currentLocale()) ?: stringResource(R.string.settings_week_start_auto) + +/** 14:00, the sample time "Automatic" is shown with. */ +private const val SAMPLE_MINUTE_OF_DAY = 14 * 60 + +private fun themeLabel(mode: ThemeMode): Int = when (mode) { + ThemeMode.SYSTEM -> R.string.settings_theme_system + ThemeMode.LIGHT -> R.string.settings_theme_light + ThemeMode.DARK -> R.string.settings_theme_dark +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt new file mode 100644 index 0000000..f852078 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/RemindersScreen.kt @@ -0,0 +1,424 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.Manifest +import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS +import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier +import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.agendula.ui.common.canScheduleExactAlarms +import de.jeanlucmakiola.agendula.ui.common.openExactAlarmSettings +import de.jeanlucmakiola.agendula.ui.common.ReminderDefaultPicker +import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert +import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay +import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.identity.expandEnter +import de.jeanlucmakiola.floret.reminders.ReminderOverride +import de.jeanlucmakiola.floret.reminders.reminderOverrideFor +import java.time.LocalTime + +/** Whether due tasks notify at all, how far ahead, and which lists differ. */ +@Composable +internal fun RemindersScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val context = LocalContext.current + var notificationsAllowed by remember { mutableStateOf(context.notificationsAllowed()) } + OnResume { notificationsAllowed = context.notificationsAllowed() } + val notifLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { + // Pref already set; a denial just leaves the OS gate shut, and the warning row says so. + notificationsAllowed = context.notificationsAllowed() + } + // From the warning row: a refusal the system no longer asks about can only be + // undone in the app's notification settings. + val fixLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + notificationsAllowed = context.notificationsAllowed() + val activity = context as? Activity + if (!granted && activity != null && + !ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) + ) { + context.openNotificationSettings() + } + } + val fixNotifications = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !context.hasNotificationPermission()) { + fixLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + context.openNotificationSettings() + } + } + val toggleReminders: (Boolean) -> Unit = { enabled -> + viewModel.setRemindersEnabled(enabled) + if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !context.hasNotificationPermission()) { + notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + val canExact = rememberExactAlarmAllowed(context) + val exactAlarmRow = showExactAlarmRow() + val dark = isSystemInDarkTheme() + var showOffset by remember { mutableStateOf(false) } + var showAllDay by remember { mutableStateOf(false) } + var showSnooze by remember { mutableStateOf(false) } + var showAllDayTime by remember { mutableStateOf(false) } + var perListExpanded by remember { mutableStateOf(false) } + var expandedLists by remember { mutableStateOf(emptySet()) } + var overrideTarget by remember { mutableStateOf(null) } + val allDayFiresAt = stringResource( + R.string.settings_allday_reminder_fires_at, + formatMinuteOfDay(state.settings.allDayReminderMinuteOfDay, LocalUse24HourFormat.current), + ) + val lists = state.lists.filter { it.id > 0L } + + CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) { + if (state.settings.remindersEnabled && !notificationsAllowed) { + GroupedRow( + title = stringResource(R.string.settings_notifications_blocked), + summary = stringResource(R.string.settings_notifications_blocked_hint), + position = Position.Alone, + container = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + leading = { Icon(Icons.Rounded.NotificationsOff, contentDescription = null) }, + onClick = fixNotifications, + ) + Spacer(Modifier.height(24.dp)) + } + GroupedRow( + title = stringResource(R.string.settings_reminders), + summary = stringResource(R.string.settings_reminders_hint), + position = Position.Top, + trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) }, + onClick = { toggleReminders(!state.settings.remindersEnabled) }, + ) + if (exactAlarmRow) { + GroupedRow( + title = stringResource(R.string.settings_exact_alarms), + summary = stringResource( + if (canExact) R.string.settings_exact_alarms_allowed + else R.string.settings_exact_alarms_blocked, + ), + position = Position.Middle, + trailing = if (canExact) ({ SelectedCheck() }) else null, + onClick = { context.openExactAlarmSettings() }, + ) + } + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = reminderChoiceLabel(state.settings.defaultReminderMinutes), + position = Position.Middle, + onClick = { showOffset = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_default_reminder_allday), + summary = reminderChoiceLabel(state.settings.defaultAllDayReminderMinutes, allDay = true), + position = Position.Middle, + onClick = { showAllDay = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_allday_reminder_time), + summary = stringResource( + R.string.settings_allday_reminder_time_summary, + formatMinuteOfDay(state.settings.allDayReminderMinuteOfDay, LocalUse24HourFormat.current), + ), + position = Position.Bottom, + onClick = { showAllDayTime = true }, + ) + + Spacer(Modifier.height(24.dp)) + val batteryExempt = rememberBatteryOptimizationExempt(context) + GroupedRow( + title = stringResource(R.string.settings_reliable_delivery), + summary = stringResource( + if (batteryExempt) R.string.settings_reliable_delivery_exempt + else R.string.settings_reliable_delivery_hint, + ), + position = Position.Top, + trailing = if (batteryExempt) ({ SelectedCheck() }) else null, + onClick = { context.openBatteryOptimizationSettings() }, + ) + GroupedRow( + title = stringResource(R.string.settings_snooze_duration), + summary = snoozeLabel(state.settings.snoozeMinutes), + position = Position.Bottom, + onClick = { showSnooze = true }, + ) + + // Per-list overrides: the whole section folds behind one header. Each + // list may keep, drop, or replace the global default for its tasks. + if (lists.isNotEmpty()) { + Spacer(Modifier.height(24.dp)) + // The header and the list rows form one connected run: the header + // opens its bottom (Top) when expanded, and the last list rounds it off. + GroupedRow( + title = stringResource(R.string.settings_list_reminders_title), + summary = stringResource(R.string.settings_list_reminders_hint), + position = if (perListExpanded) Position.Top else Position.Alone, + trailing = { + Icon( + imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { perListExpanded = !perListExpanded }, + ) + AnimatedVisibility( + visible = perListExpanded, + enter = expandEnter(), + exit = collapseExit(), + ) { + Column { + lists.forEachIndexed { index, list -> + val expanded = list.id in expandedLists + val last = index == lists.lastIndex + // Every list is mid-run under the header; the last rounds + // off, unless it is open and its two rows close the run. + GroupedRow( + title = list.name, + position = if (last && !expanded) Position.Bottom else Position.Middle, + leading = { + Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) + }, + trailing = { + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + onClick = { + expandedLists = if (expanded) expandedLists - list.id else expandedLists + list.id + }, + ) + AnimatedVisibility(visible = expanded, enter = expandEnter(), exit = collapseExit()) { + Column { + GroupedRow( + title = stringResource(R.string.settings_default_reminder), + summary = listOverrideSummary( + state.settings.perListReminderOverride.reminderOverrideFor(list.id), + state.settings.defaultReminderMinutes, + allDay = false, + ), + position = Position.Middle, + onClick = { overrideTarget = OverrideTarget(list.id, allDay = false) }, + ) + GroupedRow( + title = stringResource(R.string.settings_default_reminder_allday), + summary = listOverrideSummary( + state.settings.perListAllDayReminderOverride.reminderOverrideFor(list.id), + state.settings.defaultAllDayReminderMinutes, + allDay = true, + ), + position = Position.Middle, + onClick = { overrideTarget = OverrideTarget(list.id, allDay = true) }, + ) + GroupedRow( + title = stringResource(R.string.settings_list_notifications), + summary = stringResource(R.string.settings_list_notifications_hint), + position = if (last) Position.Bottom else Position.Middle, + onClick = { context.openChannelSettings(viewModel.listChannelId(list)) }, + ) + } + } + } + } + } + } + } + + if (showOffset) { + ReminderDefaultPicker( + title = stringResource(R.string.settings_default_reminder), + selected = state.settings.defaultReminderMinutes.let { + if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it) + }, + allowInherit = false, + onSelect = { viewModel.setDefaultReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) }, + onDismiss = { showOffset = false }, + ) + } + if (showAllDay) { + ReminderDefaultPicker( + title = stringResource(R.string.settings_default_reminder_allday), + selected = state.settings.defaultAllDayReminderMinutes.let { + if (it.isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(it) + }, + allowInherit = false, + allDay = true, + leadTimeSummary = allDayFiresAt, + onSelect = { viewModel.setDefaultAllDayReminderMinutes((it as? ReminderOverride.Minutes)?.minutes.orEmpty()) }, + onDismiss = { showAllDay = false }, + ) + } + if (showSnooze) { + OptionPicker( + title = stringResource(R.string.settings_snooze_duration), + options = SNOOZE_PRESETS, + selected = state.settings.snoozeMinutes, + label = { snoozeLabel(it) }, + onSelect = viewModel::setSnoozeMinutes, + onDismiss = { showSnooze = false }, + ) + } + if (showAllDayTime) { + val minute = state.settings.allDayReminderMinuteOfDay + TimePickerAlert( + initial = LocalTime.of(minute / 60, minute % 60), + onConfirm = { + viewModel.setAllDayReminderMinuteOfDay(it.hour * 60 + it.minute) + showAllDayTime = false + }, + onDismiss = { showAllDayTime = false }, + ) + } + overrideTarget?.let { target -> + val overrides = if (target.allDay) state.settings.perListAllDayReminderOverride + else state.settings.perListReminderOverride + ReminderDefaultPicker( + title = state.lists.firstOrNull { it.id == target.listId }?.name + ?: stringResource(R.string.settings_default_reminder), + selected = overrides.reminderOverrideFor(target.listId), + allowInherit = true, + allDay = target.allDay, + leadTimeSummary = if (target.allDay) allDayFiresAt else null, + onSelect = { + if (target.allDay) viewModel.setListAllDayReminderOverride(target.listId, it) + else viewModel.setListReminderOverride(target.listId, it) + }, + onDismiss = { overrideTarget = null }, + ) + } +} + +/** "10 minutes", "1 hour". */ +@Composable +private fun snoozeLabel(minutes: Int): String = + if (minutes % 60 == 0) pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60) + else pluralStringResource(R.plurals.duration_minutes, minutes, minutes) + +/** Which list's override picker is open, and for which kind of task. */ +private data class OverrideTarget(val listId: Long, val allDay: Boolean) + +/** Row summary for a list: its override, or the inherited global default. */ +@Composable +private fun listOverrideSummary(choice: ReminderOverride, globalDefault: List, allDay: Boolean): String = + when (choice) { + ReminderOverride.Inherit -> + stringResource(R.string.settings_list_reminder_inherits, reminderChoiceLabel(globalDefault, allDay)) + ReminderOverride.None -> stringResource(R.string.reminder_none) + is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes, allDay) + } + +/** "30 minutes before, At due time", or "No reminder" for an empty set. */ +@Composable +private fun reminderChoiceLabel(minutes: List, allDay: Boolean = false): String = + if (minutes.isEmpty()) stringResource(R.string.reminder_none) + else minutes.map { reminderLeadTimeLabel(it, allDay) }.joinToString(", ") + +/** SCHEDULE_EXACT_ALARM is user-granted from API 31; below that exact alarms need no grant. */ +private fun showExactAlarmRow(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +@Composable +private fun rememberExactAlarmAllowed(context: Context): Boolean { + var allowed by remember { mutableStateOf(context.canScheduleExactAlarms()) } + OnResume { allowed = context.canScheduleExactAlarms() } + return allowed +} + +@Composable +private fun rememberBatteryOptimizationExempt(context: Context): Boolean { + var exempt by remember { mutableStateOf(context.isIgnoringBatteryOptimizations()) } + OnResume { exempt = context.isIgnoringBatteryOptimizations() } + return exempt +} + +private fun Context.isIgnoringBatteryOptimizations(): Boolean = + getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) + +/** + * The system's optimisation list, falling back to the app's own info page. Not the direct + * exemption dialog: that needs REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, which Play restricts. + */ +private fun Context.openBatteryOptimizationSettings() { + if (runCatching { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) }.isFailure) { + runCatching { + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri())) + } + } +} + +private fun Context.hasNotificationPermission(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + +/** Whether a reminder can actually reach the user: permission, app switch and reminder channels. */ +private fun Context.notificationsAllowed(): Boolean { + if (!hasNotificationPermission() || !NotificationManagerCompat.from(this).areNotificationsEnabled()) return false + return TaskNotifier.remindersAudible(getSystemService(NotificationManager::class.java)) +} + +private fun Context.openNotificationSettings() { + runCatching { + startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName)) + } +} + +private fun Context.openChannelSettings(channelId: String) { + runCatching { + startActivity( + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt new file mode 100644 index 0000000..99b828a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsCommon.kt @@ -0,0 +1,135 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.floret.components.GroupedListInset + +/** + * Pieces shared by the settings hub and its sub-screens. Each sub-screen owns + * whatever only it uses; anything two of them need lives here. + */ + +/** + * Accent for a leading icon chip. The chips are a scanning aid, so no two rows + * in one group share an accent; [Neutral] is a step back for reference rows + * rather than a fourth colour to rotate through. + */ +internal enum class ChipAccent { Neutral, Primary, Secondary, Tertiary } + +/** Leading circular icon chip, coloured by an M3 container/on-container pair. */ +@Composable +internal fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { + val scheme = MaterialTheme.colorScheme + val (background, iconColor) = when (accent) { + ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant + ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer + ChipAccent.Secondary -> scheme.secondaryContainer to scheme.onSecondaryContainer + ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer + } + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(background), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(22.dp), + ) + } +} + +/** Muted supporting text under a section header, matching the form-fields hint. */ +@Composable +internal fun SettingsHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp), + ) +} + +/** + * The app icon as a rounded chip: the launcher mark over its background colour, + * oversized and clipped the way a launcher mask would. Sized just past the two + * text lines beside it; [MARK_OVERSCAN] holds the crop as it shrinks. + */ +@Composable +internal fun AppLogo() { + Box( + modifier = Modifier + .size(LOGO_SIZE) + .clip(RoundedCornerShape(16.dp)) + .background(colorResource(R.color.ic_launcher_background)), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.settings_about_logo_desc), + modifier = Modifier.requiredSize(LOGO_SIZE * MARK_OVERSCAN), + ) + } +} + +/** Roughly the height of the app name and author lines it sits beside. */ +private val LOGO_SIZE = 56.dp + +/** + * How far the mark overruns its chip. An adaptive icon's foreground carries a + * wide safe margin, so at 1:1 it would sit small and lost; launchers crop it too. + */ +private const val MARK_OVERSCAN = 1.5f + +/** Plain centred version mark at the foot of the settings list (no card). */ +@Composable +internal fun AppVersionText() { + val context = LocalContext.current + val versionName = remember { + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } + .getOrNull() ?: "—" + } + Text( + text = stringResource(R.string.settings_about_version, versionName), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + ) +} + +internal fun openUrl(context: Context, url: String) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + runCatching { context.startActivity(intent) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 712a61d..1bbdf46 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -1,131 +1,130 @@ package de.jeanlucmakiola.agendula.ui.settings -import android.Manifest -import android.app.AlarmManager -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build -import android.provider.Settings import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.Image import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PrivacyTip import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.rounded.AccountTree -import androidx.compose.material.icons.rounded.Circle -import androidx.compose.material.icons.rounded.Flag -import androidx.compose.material.icons.rounded.Percent -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.Storage import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R -import de.jeanlucmakiola.agendula.data.prefs.ThemeMode -import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen +import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen +import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel +import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity +import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen +import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountViewModel +import de.jeanlucmakiola.agendula.ui.export.ExportScreen +import de.jeanlucmakiola.agendula.ui.imports.ImportScreen +import de.jeanlucmakiola.agendula.ui.licences.LicencesScreen import de.jeanlucmakiola.floret.components.AboutCard -import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker -import de.jeanlucmakiola.floret.components.pastelize -import de.jeanlucmakiola.floret.components.positionOf -import de.jeanlucmakiola.floret.identity.collapseExit +import de.jeanlucmakiola.floret.crash.CrashReportDialog +import de.jeanlucmakiola.floret.crash.CrashReporter +import de.jeanlucmakiola.floret.crash.openIssueTracker +import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.locale.AppLanguage -import de.jeanlucmakiola.floret.identity.expandEnter -import de.jeanlucmakiola.floret.reminders.ReminderOverride -import de.jeanlucmakiola.floret.reminders.reminderOverrideFor -import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel /** The settings sub-screens reached from the hub's category rows. */ -private enum class SettingsSection { Appearance, TaskForm, Reminders } +private enum class SettingsSection { + Appearance, + TaskForm, + Reminders, + Storage, + Export, + Import, + Accounts, + AddAccount, + Account, + Licences, + ; -/** - * Token-based accent for a leading icon chip (container / on-container pair), - * so each accent stays correctly paired across theme, dark mode and dynamic - * colour. - */ -private enum class ChipAccent { Neutral, Primary, Tertiary } + /** Where back goes: Export is opened from Storage, the account screens from Accounts. */ + val parent: SettingsSection? + get() = when (this) { + Export, Import -> Storage + AddAccount, Account -> Accounts + else -> null + } +} /** * Settings, structured (after Calendula) as a category hub with sliding * sub-screens. The hub and the sub-screens share [CollapsingScaffold] and the * grouped-row card system; option lists use the full-screen [OptionPicker]. - * A full-screen destination; [onBack] pops it. + * A full-screen destination; [onBack] pops it. [initialSection] names a + * sub-screen to open on (see [de.jeanlucmakiola.agendula.ui.navigation.Dest.Settings]). + * + * Every sub-screen is its own file. This one owns the hub, the section enum and + * the back rules between them, and nothing else. */ @Composable fun SettingsScreen( onBack: () -> Unit, modifier: Modifier = Modifier, + initialSection: String? = null, + initialAccountId: Long? = null, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - var section by rememberSaveable { mutableStateOf(null) } + var section by rememberSaveable(initialAccountId, initialSection) { + mutableStateOf( + if (initialAccountId != null) SettingsSection.Account + else SettingsSection.entries.firstOrNull { it.name == initialSection }, + ) + } + // Hoisted so the add flow can refresh the list it returns to. + val accountsViewModel: AccountsViewModel = hiltViewModel() + // Shared with the add flow's own lookup, so "sign in again" can prefill it. + val addAccountViewModel: AddAccountViewModel = hiltViewModel() + val signInAgain: (AccountEntity) -> Unit = { account -> + addAccountViewModel.startReauthentication(account.id, account.principalUrl, account.username) + section = SettingsSection.AddAccount + } + // Which account the detail screen is showing; the section alone cannot say. + var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) } // Inside a sub-screen, system back (button or gesture) returns to the hub // rather than popping the whole Settings destination to the lists overview. - BackHandler(enabled = section != null) { section = null } + BackHandler(enabled = section != null) { section = section?.parent } Box( modifier = modifier @@ -143,6 +142,70 @@ fun SettingsScreen( SlideInSection(visible = section == SettingsSection.Reminders) { RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) } + // Storage stays composed under Export, so the deeper screen slides over it. + val storageOpen = section == SettingsSection.Storage || + section?.parent == SettingsSection.Storage + SlideInSection(visible = storageOpen) { + StorageScreen( + viewModel = viewModel, + onOpenExport = { section = SettingsSection.Export }, + onOpenImport = { section = SettingsSection.Import }, + onBack = { section = null }, + ) + } + SlideInSection(visible = section == SettingsSection.Export) { + ExportScreen(onBack = { section = SettingsSection.Storage }) + } + SlideInSection(visible = section == SettingsSection.Import) { + ImportScreen(onBack = { section = SettingsSection.Storage }) + } + // Accounts stays composed under Add account, for the same reason Storage + // stays composed under Export: the deeper screen slides over it. + val accountsOpen = section == SettingsSection.Accounts || + section?.parent == SettingsSection.Accounts + SlideInSection(visible = accountsOpen) { + AccountsScreen( + onAddAccount = { + // A sign-in abandoned half-way must not turn this into one. + if (addAccountViewModel.state.value.reauthenticating) addAccountViewModel.onStartOver() + section = SettingsSection.AddAccount + }, + onSignInAgain = signInAgain, + onOpenAccount = { + openAccount = it + section = SettingsSection.Account + }, + onOpenStorage = { section = SettingsSection.Storage }, + onBack = { section = null }, + viewModel = accountsViewModel, + ) + } + SlideInSection(visible = section == SettingsSection.Licences) { + LicencesScreen(onBack = { section = null }) + } + SlideInSection(visible = section == SettingsSection.Account) { + openAccount?.let { id -> + AccountDetailScreen( + accountId = id, + onBack = { section = SettingsSection.Accounts }, + onRemoved = { section = SettingsSection.Accounts }, + onSignInAgain = signInAgain, + onOpenStorage = { section = SettingsSection.Storage }, + viewModel = accountsViewModel, + ) + } + } + SlideInSection(visible = section == SettingsSection.AddAccount) { + AddAccountScreen( + // The list stays composed underneath, so nothing re-runs its + // init and OnResume never fires on a section change. It no longer + // needs to: the accounts come from an observed query, so a new + // account — and every later sync — arrives on its own. + onDone = { section = SettingsSection.Accounts }, + onBack = { section = SettingsSection.Accounts }, + viewModel = addAccountViewModel, + ) + } } } @@ -159,43 +222,32 @@ private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) { // Hub // --------------------------------------------------------------------------- +/** + * Four named groups of category rows, after Calendula's hub: look & behaviour, + * data, app, and a foot of reference links. Source, licence and privacy sit in + * that last group rather than as buttons inside the About card — they are + * reference material, and the card is the app's identity. + */ @Composable private fun SettingsHub( onBack: () -> Unit, onOpenSection: (SettingsSection) -> Unit, ) { CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) { - Box(Modifier.padding(horizontal = 16.dp)) { - AboutCard( - logo = { AppLogo() }, - appName = stringResource(R.string.app_name), - author = stringResource(R.string.settings_about_author), - primaryLinks = listOf( - AboutLink( - icon = Icons.Default.Code, - label = stringResource(R.string.settings_about_source), - url = stringResource(R.string.about_source_url), - ), - AboutLink( - icon = Icons.Default.Gavel, - label = stringResource(R.string.settings_license), - url = stringResource(R.string.about_license_url), - ), - ), - highlightLink = AboutLink( - icon = Icons.Default.Favorite, - label = stringResource(R.string.settings_about_support), - url = stringResource(R.string.about_support_url), - ), - ) - } + // Card and support row are one grouped block, so the call to action + // continues the container instead of sitting inside it as a button. + Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() } + SupportRow() Spacer(Modifier.height(16.dp)) + // Each group cycles its chip accents so no two rows in it look alike + // (see [ChipAccent]). + GroupedSectionHeader(stringResource(R.string.settings_group_look)) GroupedRow( title = stringResource(R.string.settings_section_appearance), summary = stringResource(R.string.settings_appearance_subtitle), position = Position.Top, - leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Secondary) }, onClick = { onOpenSection(SettingsSection.Appearance) }, ) GroupedRow( @@ -208,17 +260,114 @@ private fun SettingsHub( GroupedRow( title = stringResource(R.string.settings_section_reminders), summary = stringResource(R.string.settings_reminders_subtitle), - position = Position.Middle, + position = Position.Bottom, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, onClick = { onOpenSection(SettingsSection.Reminders) }, ) - LanguageRow(position = Position.Middle) + + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_data)) + GroupedRow( + title = stringResource(R.string.settings_section_accounts), + summary = stringResource(R.string.settings_accounts_subtitle), + position = Position.Top, + leading = { CategoryIcon(Icons.Rounded.CloudSync, ChipAccent.Tertiary) }, + onClick = { onOpenSection(SettingsSection.Accounts) }, + ) + GroupedRow( + title = stringResource(R.string.settings_section_storage), + summary = stringResource(R.string.settings_storage_subtitle), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Secondary) }, + onClick = { onOpenSection(SettingsSection.Storage) }, + ) + + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_app)) + LanguageRow(position = Position.Top) ReportProblemRow(position = Position.Bottom) + // Source, licence and privacy sit at the bottom as reference material + // rather than inside the About card. + Spacer(Modifier.height(8.dp)) + GroupedSectionHeader(stringResource(R.string.settings_group_about)) + AboutLinkRow( + label = stringResource(R.string.settings_about_source), + url = stringResource(R.string.about_source_url), + icon = ImageVector.vectorResource(R.drawable.ic_codeberg), + position = Position.Top, + ) + AboutLinkRow( + label = stringResource(R.string.settings_license), + url = stringResource(R.string.about_license_url), + icon = Icons.Default.Gavel, + position = Position.Middle, + ) + GroupedRow( + title = stringResource(R.string.settings_licences), + summary = stringResource(R.string.settings_licences_subtitle), + position = Position.Middle, + leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) }, + onClick = { onOpenSection(SettingsSection.Licences) }, + ) + AboutLinkRow( + label = stringResource(R.string.settings_privacy), + url = stringResource(R.string.about_privacy_url), + icon = Icons.Default.PrivacyTip, + position = Position.Bottom, + ) + AppVersionText() } } +@Composable +private fun AboutCard() { + // Layout lives in floret-kit (components.AboutCard); Agendula supplies its + // own logo and author. Support is the [SupportRow] joined below. + AboutCard( + logo = { AppLogo() }, + appName = stringResource(R.string.app_name), + author = stringResource(R.string.settings_about_author), + primaryLinks = emptyList(), + position = Position.Top, + ) +} + +/** "Support development", as the closing row of the About card's group. */ +@Composable +private fun SupportRow() { + val context = LocalContext.current + val url = stringResource(R.string.about_support_url) + GroupedRow( + title = stringResource(R.string.settings_about_support), + position = Position.Bottom, + leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) }, + onClick = { openUrl(context, url) }, + ) +} + +/** + * One reference link at the foot of the hub (source, licence, privacy). + * + * ⚠️ The privacy policy is linked **in the app**, not only in the Play Console. + * Play requires both, and the in-app link is the half that is routinely missed. + * Agendula's data-safety declaration is *Collected, not Shared*, encrypted in + * transit: Play defines collection as transmitting off-device irrespective of + * who receives it, so "not collected" is not defensible for a client that PUTs + * the user's tasks to their own server. + */ +@Composable +private fun AboutLinkRow(label: String, url: String, icon: ImageVector, position: Position) { + val context = LocalContext.current + GroupedRow( + title = label, + position = position, + leading = { CategoryIcon(icon, ChipAccent.Neutral) }, + onClick = { openUrl(context, url) }, + ) +} + /** * The app-language row. Deliberately not floret-kit's `LanguagePickerRow`: the * picker it opens carries a "Help translate" header, and inviting contributions @@ -228,19 +377,20 @@ private fun SettingsHub( @Composable private fun LanguageRow(position: Position) { val context = LocalContext.current + val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) } // Setting a locale recreates the activity; mirror the choice locally so the // row updates instantly even before the recreation lands. - var current by remember { mutableStateOf(AppLanguage.currentTag()) } + var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } var showDialog by remember { mutableStateOf(false) } // null = follow the system; the rest are BCP-47 tags from locales_config.xml. - val options = remember { listOf(null) + AppLanguage.supportedTags(context, R.xml.locales_config) } + val options = remember(supported) { listOf(null) + supported } GroupedRow( title = stringResource(R.string.settings_language), summary = languageLabel(current), position = position, - leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) }, + leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Secondary) }, onClick = { showDialog = true }, ) @@ -276,419 +426,37 @@ private fun LanguageRow(position: Position) { private fun languageLabel(tag: String?): String = if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) -/** Opens the project's issue tracker; no data leaves the device until submitted. */ +/** + * Opens the project's issue tracker to report a problem. If a crash report was + * captured (and not yet sent), it surfaces that report first via the same + * dialog the next-launch prompt uses; otherwise it opens the issue template + * chooser. No data leaves the device until the user submits the issue. + */ @Composable private fun ReportProblemRow(position: Position) { val context = LocalContext.current - val issueUrl = stringResource(R.string.report_issue_url) + var report by remember { mutableStateOf(null) } + GroupedRow( title = stringResource(R.string.settings_report_problem), summary = stringResource(R.string.settings_report_problem_hint), position = position, leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) }, - onClick = { openUrl(context, issueUrl) }, + onClick = { + val pending = CrashReporter.pendingReport(context) + if (pending != null) report = pending else openIssueTracker(context) + }, ) -} -// --------------------------------------------------------------------------- -// Sub-screens -// --------------------------------------------------------------------------- - -@Composable -private fun AppearanceScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - var showTheme by remember { mutableStateOf(false) } - - CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) { - GroupedRow( - title = stringResource(R.string.settings_theme), - summary = stringResource(themeLabel(state.settings.themeMode)), - position = Position.Top, - onClick = { showTheme = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_dynamic_color), - summary = if (dynamicColorAvailable) { - stringResource(R.string.settings_dynamic_color_hint) - } else { - stringResource(R.string.settings_dynamic_color_unavailable) + report?.let { pending -> + CrashReportDialog( + report = pending, + onSend = { + submitCrashReport(context, pending) + CrashReporter.clearReport(context) + report = null }, - position = Position.Bottom, - trailing = { - Switch( - checked = state.settings.dynamicColor && dynamicColorAvailable, - onCheckedChange = viewModel::setDynamicColor, - enabled = dynamicColorAvailable, - ) - }, - onClick = if (dynamicColorAvailable) { - { viewModel.setDynamicColor(!state.settings.dynamicColor) } - } else { - null - }, - ) - } - - if (showTheme) { - OptionPicker( - title = stringResource(R.string.settings_theme), - options = ThemeMode.entries, - selected = state.settings.themeMode, - label = { stringResource(themeLabel(it)) }, - onSelect = viewModel::setThemeMode, - onDismiss = { showTheme = false }, + onDismiss = { report = null }, ) } } - -@Composable -private fun TaskFormScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - var showDefaultList by remember { mutableStateOf(false) } - - CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { - Text( - text = stringResource(R.string.settings_form_fields_hint), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - Spacer(Modifier.height(8.dp)) - val fields = TaskFormField.entries - fields.forEachIndexed { index, field -> - val checked = field in state.settings.defaultEditFields - GroupedRow( - title = stringResource(formFieldLabel(field)), - position = positionOf(index, fields.size), - leading = { - Icon( - imageVector = formFieldIcon(field), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - trailing = { - Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) }) - }, - onClick = { viewModel.setFormFieldDefault(field, !checked) }, - ) - } - - Spacer(Modifier.height(24.dp)) - GroupedRow( - title = stringResource(R.string.settings_default_list), - summary = defaultListLabel(state), - position = Position.Top, - onClick = { showDefaultList = true }, - ) - GroupedRow( - title = stringResource(R.string.settings_add_subtask_row), - summary = stringResource(R.string.settings_add_subtask_row_hint), - position = Position.Middle, - trailing = { - Switch( - checked = state.settings.showAddSubtaskRow, - onCheckedChange = viewModel::setShowAddSubtaskRow, - ) - }, - onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, - ) - GroupedRow( - title = stringResource(R.string.settings_bottom_add_bar), - summary = stringResource(R.string.settings_bottom_add_bar_hint), - position = Position.Bottom, - trailing = { - Switch( - checked = state.settings.bottomAddBar, - onCheckedChange = viewModel::setBottomAddBar, - ) - }, - onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) }, - ) - } - - if (showDefaultList) { - val dark = isSystemInDarkTheme() - // null = first available list; the rest are real list ids. - val options = remember(state.lists) { listOf(null) + state.lists.map { it.id } } - OptionPicker( - title = stringResource(R.string.settings_default_list), - options = options, - selected = state.settings.defaultListId, - label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) }, - leading = { id -> - state.lists.firstOrNull { it.id == id }?.let { list -> - Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) - } - }, - onSelect = { viewModel.setDefaultList(it) }, - onDismiss = { showDefaultList = false }, - ) - } -} - -@Composable -private fun RemindersScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, - onBack: () -> Unit, -) { - val context = LocalContext.current - val notifLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { /* pref already set; a denial just leaves the OS gate shut */ } - val toggleReminders: (Boolean) -> Unit = { enabled -> - viewModel.setRemindersEnabled(enabled) - if (enabled && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != - PackageManager.PERMISSION_GRANTED - ) { - notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } - val canExact = rememberExactAlarmAllowed(context) - val exactAlarmRow = showExactAlarmRow() - val dark = isSystemInDarkTheme() - var showOffset by remember { mutableStateOf(false) } - var perListExpanded by remember { mutableStateOf(false) } - var listPickerId by remember { mutableStateOf(null) } - val lists = state.lists.filter { it.id > 0L } - - CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) { - GroupedRow( - title = stringResource(R.string.settings_reminders), - summary = stringResource(R.string.settings_reminders_hint), - position = Position.Top, - trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) }, - onClick = { toggleReminders(!state.settings.remindersEnabled) }, - ) - GroupedRow( - title = stringResource(R.string.settings_default_reminder), - summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes), - position = if (exactAlarmRow) Position.Middle else Position.Bottom, - onClick = { showOffset = true }, - ) - if (exactAlarmRow) { - GroupedRow( - title = stringResource(R.string.settings_exact_alarms), - summary = stringResource( - if (canExact) R.string.settings_exact_alarms_allowed - else R.string.settings_exact_alarms_blocked, - ), - position = Position.Bottom, - onClick = if (canExact) null else ({ context.openExactAlarmSettings() }), - ) - } - - // Per-list overrides: the whole section folds behind one header. Each - // list may keep, drop, or replace the global default for its tasks. - if (lists.isNotEmpty()) { - Spacer(Modifier.height(24.dp)) - // The header and the list rows form one connected run: the header - // opens its bottom (Top) when expanded, and the last list rounds it off. - GroupedRow( - title = stringResource(R.string.settings_list_reminders_title), - summary = stringResource(R.string.settings_list_reminders_hint), - position = if (perListExpanded) Position.Top else Position.Alone, - trailing = { - Icon( - imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = { perListExpanded = !perListExpanded }, - ) - AnimatedVisibility( - visible = perListExpanded, - enter = expandEnter(), - exit = collapseExit(), - ) { - Column { - lists.forEachIndexed { index, list -> - GroupedRow( - title = list.name, - summary = listOverrideSummary( - listOverrideChoice(state, list.id), - state.settings.reminderLeadMinutes, - ), - // Every list is mid-run under the header; the last rounds off. - position = if (index == lists.lastIndex) Position.Bottom else Position.Middle, - leading = { - Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) - }, - onClick = { listPickerId = list.id }, - ) - } - } - } - } - } - - if (showOffset) { - ReminderLeadPicker( - title = stringResource(R.string.settings_default_reminder), - selected = ReminderOverride.Minutes(listOf(state.settings.reminderLeadMinutes)), - allowInherit = false, - allowNone = false, - onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes.first()) }, - onDismiss = { showOffset = false }, - ) - } - listPickerId?.let { id -> - ReminderLeadPicker( - title = state.lists.firstOrNull { it.id == id }?.name - ?: stringResource(R.string.settings_default_reminder), - selected = listOverrideChoice(state, id), - allowInherit = true, - allowNone = true, - onSelect = { viewModel.setListReminderOverride(id, it) }, - onDismiss = { listPickerId = null }, - ) - } -} - -/** The stored override for [listId], as a picker choice (absent → inherit). */ -private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride = - state.settings.perListReminderOverride.reminderOverrideFor(listId) - -/** Row summary for a list: its override, or the inherited global default. */ -@Composable -private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) { - ReminderOverride.Inherit -> - stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault)) - ReminderOverride.None -> stringResource(R.string.reminder_none) - is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes.first()) -} - -// --------------------------------------------------------------------------- -// About + shared building blocks -// --------------------------------------------------------------------------- - -@Composable -private fun AppLogo() { - Box( - modifier = Modifier - .size(72.dp) - .clip(RoundedCornerShape(20.dp)) - .background(colorResource(R.color.ic_launcher_background)), - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(R.drawable.ic_launcher_foreground), - contentDescription = stringResource(R.string.settings_about_logo_desc), - modifier = Modifier.requiredSize(108.dp), - ) - } -} - -@Composable -private fun AppVersionText() { - val context = LocalContext.current - val versionName = remember { - runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } - .getOrNull() ?: "—" - } - Text( - text = stringResource(R.string.settings_about_version, versionName), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), - ) -} - -/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */ -@Composable -private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) { - val scheme = MaterialTheme.colorScheme - val (background, iconColor) = when (accent) { - ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant - ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer - ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer - } - Box( - modifier = Modifier.size(40.dp).clip(CircleShape).background(background), - contentAlignment = Alignment.Center, - ) { - Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp)) - } -} - -@Composable -private fun defaultListLabel(state: SettingsUiState): String = - state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name - ?: stringResource(R.string.settings_default_list_first) - -private fun openUrl(context: Context, url: String) { - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) } -} - -private fun themeLabel(mode: ThemeMode): Int = when (mode) { - ThemeMode.SYSTEM -> R.string.settings_theme_system - ThemeMode.LIGHT -> R.string.settings_theme_light - ThemeMode.DARK -> R.string.settings_theme_dark -} - -private fun formFieldLabel(field: TaskFormField): Int = when (field) { - TaskFormField.Description -> R.string.field_description - TaskFormField.Priority -> R.string.edit_priority_label - TaskFormField.Progress -> R.string.edit_progress_label - TaskFormField.Parent -> R.string.edit_parent_label - TaskFormField.Reminder -> R.string.edit_reminder_label -} - -private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) { - TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes - TaskFormField.Priority -> Icons.Rounded.Flag - TaskFormField.Progress -> Icons.Rounded.Percent - TaskFormField.Parent -> Icons.Rounded.AccountTree - TaskFormField.Reminder -> Icons.Default.Notifications -} - -/** - * Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM, - * revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row - * would be a permanent no-op — hide it there. - */ -private fun showExactAlarmRow(): Boolean = - Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2 - -@Composable -private fun rememberExactAlarmAllowed(context: Context): Boolean { - var allowed by remember { - mutableStateOf( - Build.VERSION.SDK_INT < Build.VERSION_CODES.S || - context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), - ) - } - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() - } - } - lifecycle.addObserver(obs) - onDispose { lifecycle.removeObserver(obs) } - } - return allowed -} - -private fun Context.openExactAlarmSettings() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - runCatching { - startActivity( - Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()), - ) - } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index aa2fb24..e74a509 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -3,19 +3,36 @@ package de.jeanlucmakiola.agendula.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref +import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler +import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier +import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment +import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.legacy.OneShotImport +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure +import de.jeanlucmakiola.agendula.data.tasks.transfer.ExternalImport +import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts +import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.floret.reminders.ReminderOverride +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.DayOfWeek import javax.inject.Inject data class SettingsUiState( @@ -23,6 +40,54 @@ data class SettingsUiState( val lists: List = emptyList(), ) +/** + * The storage half of Settings: which store is active, and what picking the + * other one would mean on this device. + * + * Kept apart from [SettingsUiState] because that one is collected for the whole + * Activity lifetime to drive the theme, and re-probing PackageManager on every + * theme emission would be work for nothing. + */ +data class StorageUiState( + val mode: StorageMode, + /** The external provider installed here, or null when there is none to pick. */ + val external: TaskProvider? = null, + /** That provider's own app name, for a row that names what it is switching to. */ + val externalLabel: String? = null, + /** + * Whether copying that provider's tasks into our own store is still on offer: + * a provider is installed and permitted, and no copy has succeeded yet. + */ + val canCopyFromExternal: Boolean = false, + /** + * Whether the one-shot legacy import failed and is still retryable. Almost + * always false: the bundled provider it reads from never shipped in a release. + */ + val legacyImportFailed: Boolean = false, +) + +/** How the last copy ended — kept on screen rather than flashed past. */ +sealed interface TransferOutcome { + data class Copied(val counts: TransferCounts) : TransferOutcome + data object NothingToCopy : TransferOutcome + data object Failed : TransferOutcome +} + +/** + * The copy-from-external flow: a confirmation that names real numbers, then the + * run, then a receipt that stays put. + * + * [preview] is null while the counts are still being read — the dialog opens + * first and fills in, because counting means querying every list in the provider + * and that is not instant on a big store. + */ +data class TransferUiState( + val confirming: Boolean = false, + val preview: TransferCounts? = null, + val running: Boolean = false, + val outcome: TransferOutcome? = null, +) + /** * Drives both the Settings screen and the app theme (MainActivity collects the * same instance), so a theme change applies app-wide at once. @@ -30,19 +95,150 @@ data class SettingsUiState( @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, + private val resolver: ProviderResolver, + private val environment: ProviderEnvironment, + private val externalImport: ExternalImport, + private val oneShotImport: OneShotImport, + private val reminderScheduler: ReminderScheduler, + private val taskNotifier: TaskNotifier, + @IoDispatcher io: CoroutineDispatcher, repository: TasksRepository, ) : ViewModel() { + // MainActivity collects this for the theme, above the permission gate and for + // the whole Activity lifetime — so the list flow must survive the pre-grant + // SecurityException and recover once permission is given, not die for good. val state: StateFlow = - combine(prefs.settings, repository.taskLists().catch { emit(emptyList()) }) { settings, lists -> + combine( + prefs.settings, + repository.taskLists().recoveringFromProviderFailure { emptyList() }, + ) { settings, lists -> SettingsUiState(settings, lists) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) + // Bumped to re-probe the device; installing a provider or granting its + // permission happens outside the app, so nothing else would emit. + private val providerProbe = MutableStateFlow(0) + + // Null until the first emission lands: the mode comes from DataStore and the + // rest from PackageManager, off the main thread, so any seeded default would + // name the wrong store for the first frames. flowOn, because every field here + // costs a PackageManager lookup or a permission check. + val storage: StateFlow = + combine( + prefs.storageMode, + providerProbe, + externalImport.hasRun, + oneShotImport.lastAttemptFailed, + ) { stored, _, copied, legacyFailed -> + val external = resolver.resolveExternal() + StorageUiState( + // No stored choice is the normal state; show what autoMode resolves + // to rather than a default that may not be the store in use. + mode = stored ?: resolver.autoMode(), + external = external, + externalLabel = external?.packageName?.let(environment::appLabel), + canCopyFromExternal = !copied && + external != null && + resolver.hasPermission(external), + legacyImportFailed = legacyFailed, + ) + }.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Re-read the device's provider state, after a permission request or a resume. */ + /** The list's reminder channel, created first so the system settings can open it. */ + fun listChannelId(list: TaskList): String = taskNotifier.ensureListChannel(list.id, list.name) + + fun refreshStorage() = providerProbe.update { it + 1 } + + fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) } + + // --- copying an external provider's tasks into our own store --------------- + + private val transferState = MutableStateFlow(TransferUiState()) + + val transfer: StateFlow = transferState + + /** Open the confirmation and start counting what a copy would move. */ + fun startCopyFromExternal() { + if (transferState.value.running) return + transferState.update { it.copy(confirming = true, preview = null, outcome = null) } + viewModelScope.launch { + val counts = externalImport.preview() + // The dialog may already be gone — dismissing while the count runs is + // the normal way out of a store too big to count quickly. + transferState.update { if (it.confirming) it.copy(preview = counts) else it } + } + } + + fun dismissCopyFromExternal() = transferState.update { it.copy(confirming = false) } + + /** + * Run the copy, then re-arm reminders: the tasks that just landed carry their + * own alarm rows, and nothing else would notice them — the scheduler is driven + * by explicit syncs, not by a store observer. + */ + fun confirmCopyFromExternal() { + if (transferState.value.running) return + transferState.update { it.copy(confirming = false, running = true, outcome = null) } + viewModelScope.launch { + val result = externalImport.run() + if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() } + transferState.update { + it.copy( + running = false, + outcome = when (result) { + is TransferResult.Copied -> TransferOutcome.Copied(result.counts) + TransferResult.NothingToCopy -> TransferOutcome.NothingToCopy + is TransferResult.Failed -> TransferOutcome.Failed + }, + ) + } + } + } + + /** + * Retry the legacy import against the archived `tasks.db.imported`. Truncates + * and replaces rather than merging, so tapping twice cannot double-import; on + * success the flag clears and the row offering this disappears with it. + */ + fun retryLegacyImport() { + if (retryingLegacyImport.value) return + retryingLegacyImport.value = true + viewModelScope.launch { + runCatching { oneShotImport.reimportFromArchive() } + runCatching { reminderScheduler.sync() } + retryingLegacyImport.value = false + } + } + + private val retryingLegacyImport = MutableStateFlow(false) + + val legacyImportRetrying: StateFlow = retryingLegacyImport + fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } - fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) } - fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) } + fun setDefaultReminderMinutes(minutes: List) = viewModelScope.launch { + prefs.setDefaultReminderMinutes(minutes) + resyncReminders() + } + fun setDefaultAllDayReminderMinutes(minutes: List) = viewModelScope.launch { + prefs.setDefaultAllDayReminderMinutes(minutes) + resyncReminders() + } + fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) = viewModelScope.launch { + prefs.setAllDayReminderMinuteOfDay(minuteOfDay) + resyncReminders() + } + fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { + prefs.setRemindersEnabled(enabled) + resyncReminders() + } + fun setAutofocusTitle(enabled: Boolean) = viewModelScope.launch { prefs.setAutofocusTitle(enabled) } + fun setTimeFormat(pref: TimeFormatPref) = viewModelScope.launch { prefs.setTimeFormat(pref) } + fun setWeekStart(day: DayOfWeek?) = viewModelScope.launch { prefs.setWeekStart(day) } + fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) } @@ -54,5 +250,19 @@ class SettingsViewModel @Inject constructor( /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */ fun setListReminderOverride(listId: Long, override: ReminderOverride) = - viewModelScope.launch { prefs.setListReminderOverride(listId, override) } + viewModelScope.launch { + prefs.setListReminderOverride(listId, override) + resyncReminders() + } + + fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = + viewModelScope.launch { + prefs.setListAllDayReminderOverride(listId, override) + resyncReminders() + } + + /** Every reminder setting moves alarms that are already armed. */ + private suspend fun resyncReminders() { + runCatching { reminderScheduler.sync() } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt new file mode 100644 index 0000000..62ebe04 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/StorageScreen.kt @@ -0,0 +1,409 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Apps +import androidx.compose.material.icons.rounded.MoveDown +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.tasks.StorageMode +import de.jeanlucmakiola.agendula.data.tasks.TaskProvider +import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts +import de.jeanlucmakiola.agendula.ui.common.OnResume +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck + +/** + * Where the tasks live: the store picker the resolver's `autoMode()` has always + * assumed, plus the way out of a store that lives in our own private storage. + */ +@Composable +internal fun StorageScreen( + viewModel: SettingsViewModel, + onOpenExport: () -> Unit, + onOpenImport: () -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val storage by viewModel.storage.collectAsStateWithLifecycle() + val transfer by viewModel.transfer.collectAsStateWithLifecycle() + val retryingLegacyImport by viewModel.legacyImportRetrying.collectAsStateWithLifecycle() + var showPicker by remember { mutableStateOf(false) } + var denied by remember { mutableStateOf(false) } + // The store the user picked and has not yet confirmed. Switching stores moves + // nothing, so it is worth one dialog rather than an empty app and a guess. + var pendingMode by remember { mutableStateOf(null) } + + // The mode is committed only once the grant is in — switching first drops the + // user on the app-wide permission gate. + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + viewModel.refreshStorage() + val granted = grants.isNotEmpty() && grants.values.all { it } + denied = !granted + if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL) + } + + // A provider can be installed, or its permission revoked, while we're away. + // Deliberately does not clear [denied]: this fires on returning from the + // permission dialog too, and would wipe the refusal before it is read. + OnResume { viewModel.refreshStorage() } + + CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) { + GroupedRow( + title = stringResource(R.string.settings_task_store), + summary = storage?.let { storeLabel(it) }, + position = Position.Top, + // Nothing to pick until the stored mode has landed; opening the picker + // on the seeded state would offer the wrong store as the current one. + onClick = storage?.let { + { + denied = false + showPicker = true + } + }, + ) + if (storage?.canCopyFromExternal == true) { + GroupedRow( + title = stringResource( + R.string.settings_copy_from_external, + storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(), + ), + summary = stringResource(R.string.settings_copy_from_external_hint), + position = Position.Middle, + leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) }, + // Inert while a copy is in flight; the receipt below says so. + onClick = if (transfer.running) null else viewModel::startCopyFromExternal, + ) + } + GroupedRow( + title = stringResource(R.string.settings_export), + summary = stringResource(R.string.settings_export_hint), + position = Position.Middle, + onClick = onOpenExport, + ) + GroupedRow( + title = stringResource(R.string.import_title), + summary = stringResource(R.string.settings_import_hint), + position = Position.Bottom, + onClick = onOpenImport, + ) + + TransferStatus(transfer) + + // Only ever shown to someone whose upgrade import failed — their tasks are + // still in the archived file, and this is the one way back to them. + if (storage?.legacyImportFailed == true) { + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.settings_legacy_import_failed), + summary = stringResource( + if (retryingLegacyImport) { + R.string.settings_legacy_import_retrying + } else { + R.string.settings_legacy_import_failed_hint + }, + ), + position = Position.Alone, + onClick = if (retryingLegacyImport) null else viewModel::retryLegacyImport, + ) + } + + if (denied) { + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.settings_store_permission_denied), + summary = stringResource(R.string.settings_store_permission_denied_hint), + position = Position.Alone, + onClick = { context.openAppSettings() }, + ) + } + } + + storage?.let { state -> + if (showPicker) { + StorePicker( + storage = state, + // Picking the store already in use is not a switch — no dialog. + onSelect = { mode -> if (mode != state.mode) pendingMode = mode }, + onDismiss = { showPicker = false }, + ) + } + + pendingMode?.let { mode -> + SwitchStoreDialog( + target = mode, + otherStore = when (mode) { + StorageMode.OWN -> externalTitle(state.external, state.externalLabel) + StorageMode.EXTERNAL -> stringResource(R.string.settings_store_own) + }, + onConfirm = { + pendingMode = null + val external = state.external + if (mode == StorageMode.EXTERNAL && external != null) { + // Asked even when the grant looks held: an already-granted + // request returns at once, a stale belief would strand them. + permissionLauncher.launch( + arrayOf(external.readPermission, external.writePermission), + ) + } else { + viewModel.setStorageMode(mode) + } + }, + onDismiss = { pendingMode = null }, + ) + } + + if (transfer.confirming) { + CopyFromExternalDialog( + source = externalTitle(state.external, state.externalLabel), + counts = transfer.preview, + onConfirm = viewModel::confirmCopyFromExternal, + onDismiss = viewModel::dismissCopyFromExternal, + ) + } + } +} + +/** + * The switch itself, behind a confirm. Neither store hands its rows to the other + * when the mode changes — the tasks stay where they were written — so the app + * looks emptied to anyone who expected a move. Saying so once is cheaper than + * the support thread. + */ +@Composable +private fun SwitchStoreDialog( + target: StorageMode, + otherStore: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) }, + title = { Text(stringResource(R.string.settings_store_switch_title)) }, + text = { + Text( + stringResource( + when (target) { + StorageMode.OWN -> R.string.settings_store_switch_to_own + StorageMode.EXTERNAL -> R.string.settings_store_switch_to_external + }, + otherStore, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.settings_store_switch_confirm)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** + * The copy, behind a confirm that names real numbers — it is irreversible in the + * only sense that matters: nothing merges the result back, so running it twice + * would leave two of everything. Confirm stays disabled until the count lands. + */ +@Composable +private fun CopyFromExternalDialog( + source: String, + counts: TransferCounts?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Rounded.MoveDown, contentDescription = null) }, + title = { Text(stringResource(R.string.settings_copy_confirm_title)) }, + text = { + Text( + text = when { + counts == null -> stringResource(R.string.settings_copy_counting) + counts.tasks == 0 -> stringResource(R.string.settings_copy_confirm_empty, source) + else -> pluralStringResource( + R.plurals.settings_copy_confirm_message, + counts.tasks, + counts.tasks, + source, + ) + }, + ) + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = counts != null && counts.tasks > 0) { + Text(stringResource(R.string.settings_copy_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** The running spinner, then whatever the last copy ended as — it stays put. */ +@Composable +private fun TransferStatus(state: TransferUiState) { + val outcome = state.outcome + when { + state.running -> Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(18.dp)) + Text( + text = stringResource(R.string.settings_copy_running), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + outcome is TransferOutcome.Copied -> StatusText( + text = pluralStringResource( + R.plurals.settings_copy_done, + outcome.counts.tasks, + outcome.counts.tasks, + outcome.counts.lists, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + outcome is TransferOutcome.NothingToCopy -> StatusText( + text = stringResource(R.string.settings_copy_nothing), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + outcome is TransferOutcome.Failed -> StatusText( + text = stringResource(R.string.settings_copy_failed), + color = MaterialTheme.colorScheme.error, + ) + } +} + +@Composable +private fun StatusText(text: String, color: Color) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = color, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) +} + +/** + * The two stores, as rows. External is offered only when a provider is actually + * installed — dimmed and inert otherwise, because a mode with nothing behind it + * empties the app. + */ +@Composable +private fun StorePicker( + storage: StorageUiState, + onSelect: (StorageMode) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) { + Text( + text = stringResource(R.string.settings_task_store_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(8.dp)) + + val select: (StorageMode) -> Unit = { chosen -> + onSelect(chosen) + onDismiss() + } + val external = storage.external + GroupedRow( + title = stringResource(R.string.settings_store_own), + summary = stringResource(R.string.settings_store_own_hint), + position = Position.Top, + selected = storage.mode == StorageMode.OWN, + leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.OWN) { + { SelectedCheck() } + } else { + null + }, + onClick = { select(StorageMode.OWN) }, + ) + GroupedRow( + title = externalTitle(external, storage.externalLabel), + summary = stringResource( + if (external == null) { + R.string.settings_store_external_missing + } else { + R.string.settings_store_external_hint + }, + ), + position = Position.Bottom, + selected = storage.mode == StorageMode.EXTERNAL, + dimmed = external == null, + leading = { Icon(Icons.Rounded.Apps, contentDescription = null) }, + trailing = if (storage.mode == StorageMode.EXTERNAL) { + { SelectedCheck() } + } else { + null + }, + onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null, + ) + Spacer(Modifier.height(24.dp)) + } +} + +/** The active store, named the way the picker names it. */ +@Composable +private fun storeLabel(storage: StorageUiState): String = when (storage.mode) { + StorageMode.OWN -> stringResource(R.string.settings_store_own) + StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel) +} + +/** The provider's own app name, its authority, or the generic wording. */ +@Composable +private fun externalTitle(provider: TaskProvider?, label: String?): String = + label ?: provider?.authority ?: stringResource(R.string.settings_store_external) + +private fun Context.openAppSettings() { + runCatching { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt new file mode 100644 index 0000000..d8d7268 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/TaskFormScreen.kt @@ -0,0 +1,175 @@ +package de.jeanlucmakiola.agendula.ui.settings + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.rounded.AccountTree +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Percent +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.ui.common.icon +import de.jeanlucmakiola.agendula.ui.common.labelRes +import de.jeanlucmakiola.agendula.domain.TaskFormField +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.floret.components.CollapsingScaffold +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf + +/** What a new task's edit form starts out as: its fields, its list, and the add affordances. */ +@Composable +internal fun TaskFormScreen( + state: SettingsUiState, + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + var showDefaultList by remember { mutableStateOf(false) } + + CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) { + SettingsHint(stringResource(R.string.settings_form_fields_hint)) + Spacer(Modifier.height(8.dp)) + val fields = TaskFormField.entries + fields.forEachIndexed { index, field -> + val checked = field in state.settings.defaultEditFields + GroupedRow( + title = stringResource(formFieldLabel(field)), + position = positionOf(index, fields.size), + leading = { + Icon( + imageVector = formFieldIcon(field), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailing = { + Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) }) + }, + onClick = { viewModel.setFormFieldDefault(field, !checked) }, + ) + } + + Spacer(Modifier.height(24.dp)) + GroupedRow( + title = stringResource(R.string.settings_default_list), + summary = defaultListLabel(state), + position = Position.Top, + onClick = { showDefaultList = true }, + ) + GroupedRow( + title = stringResource(R.string.settings_autofocus_title), + summary = stringResource(R.string.settings_autofocus_title_hint), + position = Position.Middle, + trailing = { + Switch( + checked = state.settings.autofocusTitle, + onCheckedChange = viewModel::setAutofocusTitle, + ) + }, + onClick = { viewModel.setAutofocusTitle(!state.settings.autofocusTitle) }, + ) + GroupedRow( + title = stringResource(R.string.settings_add_subtask_row), + summary = stringResource(R.string.settings_add_subtask_row_hint), + position = Position.Middle, + trailing = { + Switch( + checked = state.settings.showAddSubtaskRow, + onCheckedChange = viewModel::setShowAddSubtaskRow, + ) + }, + onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, + ) + GroupedRow( + title = stringResource(R.string.settings_bottom_add_bar), + summary = stringResource(R.string.settings_bottom_add_bar_hint), + position = Position.Bottom, + trailing = { + Switch( + checked = state.settings.bottomAddBar, + onCheckedChange = viewModel::setBottomAddBar, + ) + }, + onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) }, + ) + } + + if (showDefaultList) { + DefaultListPicker( + lists = state.lists, + selectedId = state.settings.defaultListId, + onSelect = { viewModel.setDefaultList(it); showDefaultList = false }, + onDismiss = { showDefaultList = false }, + ) + } +} + +/** + * "First available" above the lists a new task can go into, grouped under their + * account like the editor's list picker. Read-only lists are left out. + */ +@Composable +private fun DefaultListPicker( + lists: List, + selectedId: Long?, + onSelect: (Long?) -> Unit, + onDismiss: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val groups = remember(lists) { + lists.filter { it.id > 0L && it.acceptsWrites }.groupBy { it.accountName } + } + FullScreenPicker(title = stringResource(R.string.settings_default_list), onDismiss = onDismiss) { + val firstSelected = groups.values.none { inAccount -> inAccount.any { it.id == selectedId } } + GroupedRow( + title = stringResource(R.string.settings_default_list_first), + position = Position.Alone, + selected = firstSelected, + trailing = if (firstSelected) ({ SelectedCheck() }) else null, + onClick = { onSelect(null) }, + ) + groups.forEach { (account, inAccount) -> + Spacer(Modifier.height(8.dp)) + if (account.isNotBlank()) GroupedSectionHeader(account) + inAccount.forEachIndexed { index, list -> + val selected = list.id == selectedId + GroupedRow( + title = list.name, + position = positionOf(index, inAccount.size), + selected = selected, + leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) }, + trailing = if (selected) ({ SelectedCheck() }) else null, + onClick = { onSelect(list.id) }, + ) + } + } + } +} + +@Composable +private fun defaultListLabel(state: SettingsUiState): String = + state.lists.firstOrNull { it.id == state.settings.defaultListId && it.acceptsWrites }?.name + ?: stringResource(R.string.settings_default_list_first) + +private fun formFieldLabel(field: TaskFormField): Int = field.labelRes() + +private fun formFieldIcon(field: TaskFormField): ImageVector = field.icon() diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListPreview.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListPreview.kt new file mode 100644 index 0000000..6bab6be --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListPreview.kt @@ -0,0 +1,125 @@ +package de.jeanlucmakiola.agendula.ui.tasklist + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.ui.common.DefaultListColor +import de.jeanlucmakiola.floret.components.ScaledViewPreview +import de.jeanlucmakiola.floret.components.positionOf + +/** + * A task list as it will look, for a chooser that offers a choice about it — + * today only the bottom quick-add bar against the floating "New task" button. + * + * Drawn from the screen's own [TaskRowContent] and [InlineAdd] over sample + * tasks, so what the preview shows cannot drift from what the app does. The + * chrome around them (a title, the FAB) is a still of the real scaffold rather + * than the scaffold itself: [TaskListScreen] needs a ViewModel, a list id and a + * store behind it, none of which exist during onboarding. + */ +@Composable +internal fun TaskListPreview(bottomAddBar: Boolean, height: Dp) { + ScaledViewPreview(height = height) { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.tasks_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 16.dp), + ) + SampleTasks.forEachIndexed { index, task -> + TaskRowContent( + task = task, + position = positionOf(index, SampleTasks.size), + showListName = false, + subtaskDone = task.subtaskDone, + subtaskTotal = task.subtaskTotal, + expanded = false, + onToggleExpand = null, + onToggle = {}, + onClick = {}, + ) + if (index != SampleTasks.lastIndex) Spacer(Modifier.height(2.dp)) + } + } + + if (bottomAddBar) { + Box(Modifier.align(Alignment.BottomCenter)) { + InlineAdd(onAdd = {}, elevated = true) + } + } else { + ExtendedFloatingActionButton( + onClick = {}, + icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.new_task)) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) + } + } + } +} + +/** Enough rows to fill the viewport, and varied enough to show a meta line. */ +private val SampleTasks: List = listOf( + sampleTask(1, "Water the balcony plants", priority = Priority.MEDIUM), + sampleTask(2, "Book the dentist", subtaskTotal = 3, subtaskDone = 1), + sampleTask(3, "Reply to the landlord"), + sampleTask(4, "Pick up the parcel", status = TaskStatus.COMPLETED), +) + +private fun sampleTask( + id: Long, + title: String, + priority: Priority = Priority.NONE, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + subtaskTotal: Int = 0, + subtaskDone: Int = 0, +) = Task( + taskId = id, + listId = 1, + title = title, + description = null, + location = null, + url = null, + priority = priority, + status = status, + percentComplete = null, + start = null, + due = null, + isAllDay = false, + timeZone = null, + completedAt = null, + listColor = DefaultListColor, + taskColor = null, + listName = null, + accountName = null, + parentId = null, + isRecurring = false, + distanceFromCurrent = null, + created = null, + lastModified = null, + subtaskTotal = subtaskTotal, + subtaskDone = subtaskDone, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index c4c6a0b..118c10e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.Flag import androidx.compose.material3.Checkbox @@ -55,12 +56,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material3.AlertDialog import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.ui.res.pluralStringResource +import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.floret.components.InlineTextField +import de.jeanlucmakiola.floret.components.OptionPicker import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable @@ -86,7 +92,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.floret.time.DayWindow @@ -96,7 +102,11 @@ import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections +import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.priorityAccent +import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet +import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChipHeight @@ -117,7 +127,7 @@ import kotlin.time.Clock @Composable fun TaskListScreen( filter: TaskFilter, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, onNewTask: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -126,28 +136,33 @@ fun TaskListScreen( val state by viewModel.state.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val content = state as? TaskListUiState.Content - val listName = content?.listName + val list = content?.list + val listName = list?.name val listId = (filter as? TaskFilter.OfList)?.listId + // Editing is offered for a device-only list. A collection that belongs to an + // account is the server's to rename or remove, not ours. + var editingList by rememberSaveable { mutableStateOf(false) } + val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() + val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() + // The list this screen is about is gone; there is nothing left to show. + LaunchedEffect(listDeleted) { if (listDeleted) onBack() } + LaunchedEffect(listWriteFailure) { + if (listWriteFailure != null) { + delay(4_000) + viewModel.clearListWriteFailure() + } + } // One add affordance, never two: a real list with the setting on gets a pinned // bottom quick-add bar; everything else (incl. smart lists, which have no single // target list) gets the floating "New task" button. val showBottomAddBar = content?.bottomAddBar == true && listId != null - // Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip - // gives an undo window: Undo restores it, otherwise the delete commits. A - // second delete while a chip is up commits the first (its window is over). - var undoTarget by remember { mutableStateOf(null) } - val onRequestDelete: (Task) -> Unit = { task -> - undoTarget?.let { viewModel.commitDelete(it.taskId) } - viewModel.markPendingDelete(task) - undoTarget = task - } - LaunchedEffect(undoTarget) { - val target = undoTarget ?: return@LaunchedEffect - delay(4_000) - viewModel.commitDelete(target.taskId) - if (undoTarget?.taskId == target.taskId) undoTarget = null - } + // Swipe-delete and completing a task both leave a floating undo chip; the + // view model owns the window, so leaving the screen commits instead of losing it. + val undo by viewModel.undo.collectAsStateWithLifecycle() + val onRequestDelete: (Task) -> Unit = viewModel::requestDelete + var showSort by rememberSaveable { mutableStateOf(false) } + var confirmClear by rememberSaveable { mutableStateOf(false) } Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -162,6 +177,19 @@ fun TaskListScreen( ) } }, + actions = { + IconButton(onClick = { showSort = true }, enabled = content != null) { + Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title)) + } + if (list != null && list.isLocal) { + IconButton(onClick = { editingList = true }) { + Icon( + Icons.Rounded.Edit, + contentDescription = stringResource(R.string.list_edit_title), + ) + } + } + }, scrollBehavior = scrollBehavior, ) }, @@ -176,7 +204,7 @@ fun TaskListScreen( }, bottomBar = { if (showBottomAddBar) { - listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) } + QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, listId) }) } }, ) { inner -> @@ -184,7 +212,10 @@ fun TaskListScreen( when (val s = state) { TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) - is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel) + is TaskListUiState.Content -> TaskListBody( + s, filter, inner, onOpenTask, onRequestDelete, viewModel, + onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null, + ) } // A FAB-height band anchored at the bottom-start with the FAB's own // margin; centring the chip in it lines the chip up beside the bottom-end @@ -199,18 +230,74 @@ fun TaskListScreen( .height(SnackChipHeight), contentAlignment = Alignment.CenterStart, ) { - SnackChip( - visible = undoTarget != null, - message = stringResource(R.string.task_deleted), - actionLabel = stringResource(R.string.undo), - onAction = { - undoTarget?.let { viewModel.undoDelete(it.taskId) } - undoTarget = null - }, - ) + // One chip, one anchor: the undo receipt takes precedence, and a + // refused list write reports itself once the undo window is clear. + val failure = listWriteFailure + if (undo != null || failure == null) { + SnackChip( + visible = undo != null, + message = stringResource( + if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted, + ), + actionLabel = stringResource(R.string.undo), + onAction = viewModel::undo, + ) + } else { + SnackChip( + visible = true, + message = stringResource(listWriteFailureMessage(failure)), + ) + } } } } + + if (showSort && content != null) { + OptionPicker( + title = stringResource(R.string.sort_title), + options = TaskSortOrder.entries, + selected = content.sortOrder, + label = { stringResource(sortLabel(it)) }, + onSelect = { viewModel.setSortOrder(it); showSort = false }, + onDismiss = { showSort = false }, + ) + } + + if (confirmClear && listId != null) { + val count = content?.tasks?.count { it.isCompleted } ?: 0 + AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text(stringResource(R.string.clear_completed_title)) }, + text = { Text(pluralStringResource(R.plurals.clear_completed_body, count, count)) }, + confirmButton = { + TextButton(onClick = { confirmClear = false; viewModel.clearCompleted(listId) }) { + Text(stringResource(R.string.clear_completed_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { confirmClear = false }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) + } + + if (editingList && list != null) { + ListEditorSheet( + initial = list, + onSave = { name, color, _ -> viewModel.updateList(list.id, name, color, list.accountId) }, + onDismiss = { editingList = false }, + hiddenFromSmartLists = content?.hiddenFromSmartLists ?: false, + onHiddenFromSmartListsChange = { viewModel.setHiddenFromSmartLists(list.id, it) }, + // ⚠️ Absent for a read-only share, whose removal belongs to whoever + // owns it — and which the server would refuse anyway. Present for + // the account's own collections, where it now deletes on the server + // as well as here. + onDelete = if (list.isReadOnly) { + null + } else { + { viewModel.deleteList(list.id, list.accountId) } + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -219,9 +306,10 @@ private fun TaskListBody( state: TaskListUiState.Content, filter: TaskFilter, inner: PaddingValues, - onOpenTask: (Long) -> Unit, + onOpenTask: (Task) -> Unit, onRequestDelete: (Task) -> Unit, viewModel: TaskListViewModel, + onClearCompleted: (() -> Unit)? = null, ) { val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) } // A parent represents its children via a progress chip (counts come from the @@ -288,6 +376,7 @@ private fun TaskListBody( count = section.tasks.size, expanded = completedExpanded, onToggle = { completedExpanded = !completedExpanded }, + onClear = onClearCompleted, ) } else { SectionHeader(sectionLabel(section.section)) @@ -311,7 +400,9 @@ private fun TaskListBody( val children = childrenOf(task) val canExpand = task.subtaskTotal > 0 val isExpanded = canExpand && task.taskId in expandedParents - val hasExpansionContent = children.isNotEmpty() || state.showAddSubtaskRow + // No quick-add into a read-only share: it would never upload. + val showAdd = state.showAddSubtaskRow && !task.isReadOnly + val hasExpansionContent = children.isNotEmpty() || showAdd // Top-level tasks keep their own connected run — full top // only on the first, full bottom only on the last — so the // task after an expanded one stays mid-run (small top). An @@ -329,12 +420,12 @@ private fun TaskListBody( // Subtasks connect up to the parent (small top); with the // add row present every subtask is mid-run, otherwise the // last one closes the group (full bottom). - val closesGroup = !state.showAddSubtaskRow && c == sorted.lastIndex + val closesGroup = !showAdd && c == sorted.lastIndex add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = closesGroup))) } // The add row rounds the group off (full bottom), // independent of whatever top-level task follows. - if (state.showAddSubtaskRow) { + if (showAdd) { add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true))) } } @@ -369,7 +460,7 @@ private fun TaskListBody( }, onToggle = { viewModel.toggleComplete(row.task) }, onDelete = { onRequestDelete(row.task) }, - onClick = { onOpenTask(row.task.taskId) }, + onClick = { onOpenTask(row.task) }, ) is ListRow.Sub -> SubtaskRow( modifier = rowModifier, @@ -377,7 +468,8 @@ private fun TaskListBody( position = r.position, lastInRun = lastInRun, onToggle = { viewModel.toggleComplete(row.task) }, - onClick = { onOpenTask(row.task.taskId) }, + onDelete = { onRequestDelete(row.task) }, + onClick = { onOpenTask(row.task) }, ) is ListRow.AddSub -> AddSubtaskRow( modifier = rowModifier, @@ -392,10 +484,13 @@ private fun TaskListBody( } } -/** Inline "add a task" — title only, into the current list. */ +/** + * Inline "add a task" — title only, into the current list. Shared with the + * onboarding step that previews the bottom bar this sits in. + */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) { +internal fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) { var text by remember { mutableStateOf("") } fun submit() { if (text.isNotBlank()) { @@ -423,29 +518,30 @@ private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) { shadowElevation = if (elevated) 8.dp else 0.dp, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), ) { - TextField( - value = text, - onValueChange = { text = it }, - placeholder = { Text(stringResource(R.string.add_task_hint)) }, - leadingIcon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - trailingIcon = { - if (text.isNotBlank()) { - IconButton(onClick = ::submit) { - Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task)) - } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 16.dp, end = 4.dp), + ) { + Icon(Icons.Rounded.Add, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Box(modifier = Modifier.weight(1f)) { + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.add_task_hint), + textStyle = MaterialTheme.typography.bodyLarge, + imeAction = ImeAction.Done, + onImeAction = ::submit, + ) + } + if (text.isNotBlank()) { + IconButton(onClick = ::submit) { + Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task)) } - }, - singleLine = true, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - modifier = Modifier.fillMaxWidth(), - ) + } else { + Spacer(Modifier.width(12.dp)) + } + } } } @@ -510,6 +606,8 @@ private fun TaskRow( // dismissDirection (not targetValue) tracks the live drag, so the colour + // icon reveal as you swipe rather than only once the threshold is crossed. backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, + // A read-only share takes neither swipe: both are writes the server refuses. + gesturesEnabled = !task.isReadOnly, ) { TaskRowContent(task, position, showListName, subtaskDone, subtaskTotal, expanded, onToggleExpand, onToggle, onClick) } @@ -517,7 +615,7 @@ private fun TaskRow( @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -private fun TaskRowContent( +internal fun TaskRowContent( task: Task, position: Position, showListName: Boolean, @@ -533,11 +631,11 @@ private fun TaskRowContent( val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") val dark = isSystemInDarkTheme() - val due = task.due?.formatDateTimeCompact(task.isAllDay) + val due = task.due?.formatDateTimeCompact(task.isAllDay, LocalUse24HourFormat.current) val listName = task.listName?.takeIf { showListName && it.isNotBlank() } // Everything secondary collapses into one quiet supporting line below the title. val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 || - listName != null + listName != null || task.isOccurrence Surface( onClick = onClick, @@ -563,7 +661,11 @@ private fun TaskRowContent( .clip(RoundedCornerShape(3.dp)) .background(pastelize(task.effectiveColor, dark)), ) - Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) + Checkbox( + checked = task.isCompleted, + onCheckedChange = { onToggle() }, + enabled = !task.isReadOnly, + ) Spacer(Modifier.width(4.dp)) Column(modifier = Modifier.weight(1f)) { Text( @@ -583,6 +685,7 @@ private fun TaskRowContent( TaskMetaLine( priority = task.priority, due = due, + recurring = task.isOccurrence, subtaskDone = subtaskDone, subtaskTotal = subtaskTotal, listName = listName, @@ -613,6 +716,7 @@ private fun TaskRowContent( private fun TaskMetaLine( priority: Priority, due: String?, + recurring: Boolean, subtaskDone: Int, subtaskTotal: Int, listName: String?, @@ -638,7 +742,24 @@ private fun TaskMetaLine( } } } - if (due != null) add { Text(due, style = style, color = muted) } + if (due != null || recurring) { + add { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + if (recurring) { + Icon( + Icons.Rounded.Repeat, + contentDescription = stringResource(R.string.cd_repeats), + tint = muted, + modifier = Modifier.size(13.dp), + ) + } + if (due != null) Text(due, style = style, color = muted) + } + } + } if (subtaskTotal > 0) { add { Row( @@ -651,7 +772,7 @@ private fun TaskMetaLine( tint = muted, modifier = Modifier.size(13.dp), ) - Text("$subtaskDone / $subtaskTotal", style = style, color = muted) + Text(stringResource(R.string.subtask_progress, subtaskDone, subtaskTotal), style = style, color = muted) } } } @@ -773,20 +894,20 @@ private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) { /** A visual row in a flattened section run: a top-level task, or one of its subtasks. */ private sealed interface ListRow { - val key: Long + val key: String data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow { - override val key: Long get() = task.taskId + override val key: String get() = task.occurrenceKey } data class Sub(val task: Task) : ListRow { - override val key: Long get() = task.taskId + override val key: String get() = task.occurrenceKey } /** The inline "add a subtask" row that closes an expanded group. */ data class AddSub(val parent: Task) : ListRow { - // Negative so it never collides with a real (positive) provider task id. - override val key: Long get() = -parent.taskId + // Prefixed so it never collides with the task row it belongs to. + override val key: String get() = "add-${parent.occurrenceKey}" } } @@ -819,14 +940,29 @@ private fun SubtaskRow( position: Position, lastInRun: Boolean, onToggle: () -> Unit, + onDelete: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + when (value) { + SwipeToDismissBoxValue.StartToEnd -> { onToggle(); false } + SwipeToDismissBoxValue.EndToStart -> { onDelete(); false } + SwipeToDismissBoxValue.Settled -> false + } + }, + ) val interaction = remember { MutableInteractionSource() } val pressed by interaction.collectIsPressedAsState() val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "subFullCorner") val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "subSmallCorner") val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp) + SwipeToDismissBox( + state = dismissState, + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, + ) { Surface( onClick = onClick, shape = cardShape(position, full, small), @@ -834,7 +970,7 @@ private fun SubtaskRow( // reads as a distinct, recessive tone — the nesting cue alongside shape. color = MaterialTheme.colorScheme.surfaceContainer, interactionSource = interaction, - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), + modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier @@ -847,7 +983,11 @@ private fun SubtaskRow( // as a subtask. Reserve the bar's 5dp so the checkbox still lines up // with the parent rows' checkboxes rather than indenting. Spacer(Modifier.width(5.dp)) - Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() }) + Checkbox( + checked = task.isCompleted, + onCheckedChange = { onToggle() }, + enabled = !task.isReadOnly, + ) Spacer(Modifier.width(4.dp)) Text( text = task.title.ifBlank { stringResource(R.string.task_untitled) }, @@ -864,6 +1004,7 @@ private fun SubtaskRow( ) } } + } } /** The colored reveal behind a swiping row: complete on the left, delete on the right. */ @@ -929,6 +1070,7 @@ private fun CollapsibleSectionHeader( count: Int, expanded: Boolean, onToggle: () -> Unit, + onClear: (() -> Unit)? = null, ) { val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron") Surface( @@ -952,6 +1094,9 @@ private fun CollapsibleSectionHeader( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) + if (onClear != null) { + TextButton(onClick = onClear) { Text(stringResource(R.string.clear_completed_action)) } + } Icon( imageVector = Icons.Rounded.ExpandMore, contentDescription = null, @@ -1035,3 +1180,10 @@ internal fun priorityLabel(priority: Priority): String = stringResource( Priority.HIGH -> R.string.priority_high }, ) + +private fun sortLabel(order: TaskSortOrder): Int = when (order) { + TaskSortOrder.DUE -> R.string.sort_due + TaskSortOrder.PRIORITY -> R.string.sort_priority + TaskSortOrder.TITLE -> R.string.sort_title_alpha + TaskSortOrder.CREATED -> R.string.sort_created +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 3120e14..71f991c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -4,17 +4,30 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.domain.TaskSortOrder +import de.jeanlucmakiola.agendula.domain.comparator +import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure +import de.jeanlucmakiola.agendula.ui.lists.asFailure +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -28,17 +41,20 @@ sealed interface TaskListUiState { data object Failure : TaskListUiState /** - * [listName] is the real list's name when the filter is a - * [TaskFilter.OfList] (for the top-bar title), `null` for smart lists — - * the screen falls back to the smart label in that case. + * [list] is the real list when the filter is a [TaskFilter.OfList] — it + * titles the bar and backs the edit action — and `null` for smart lists, + * where the screen falls back to the smart label. */ data class Content( val tasks: List, - val listName: String? = null, + val list: TaskList? = null, /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ val showAddSubtaskRow: Boolean = true, /** Whether a real list uses the bottom quick-add bar instead of the FAB. */ val bottomAddBar: Boolean = false, + val sortOrder: TaskSortOrder = TaskSortOrder.DUE, + /** This real list is kept out of the smart lists. */ + val hiddenFromSmartLists: Boolean = false, ) : TaskListUiState } @@ -47,17 +63,34 @@ sealed interface TaskListUiState { * the list re-emits live as the provider changes. Actions are fire-and-forget — * the resulting provider change flows back through the list automatically. */ +/** What the undo chip can take back. */ +sealed interface UndoReceipt { + val task: Task + data class Deleted(override val task: Task) : UndoReceipt + data class Completed(override val task: Task, val subtasks: List = emptyList()) : UndoReceipt +} + +private const val UNDO_WINDOW_MS = 4_000L + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class TaskListViewModel @Inject constructor( private val repository: TasksRepository, private val settingsPrefs: SettingsPrefs, + private val remoteLists: RemoteListRepository, + @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { private val filter = MutableStateFlow(null) - /** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */ - private val pendingDeletes = MutableStateFlow>(emptySet()) + /** Occurrences swiped to delete but not yet committed — hidden until the undo lapses. */ + private val pendingDeletes = MutableStateFlow>(emptyMap()) + + private val _undo = MutableStateFlow(null) + + /** The action the floating undo chip is offering to take back, if any. */ + val undo: StateFlow = _undo.asStateFlow() + private var undoTimer: Job? = null val state: StateFlow = filter.filterNotNull() @@ -65,8 +98,8 @@ class TaskListViewModel @Inject constructor( val tasks = repository.tasks(f) val content: kotlinx.coroutines.flow.Flow = when (f) { is TaskFilter.OfList -> - combine(tasks, repository.taskLists()) { list, lists -> - TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name) + combine(tasks, repository.taskLists()) { rows, lists -> + TaskListUiState.Content(rows, lists.firstOrNull { it.id == f.listId }) } is TaskFilter.Smart -> tasks.map { TaskListUiState.Content(it) } @@ -74,20 +107,26 @@ class TaskListViewModel @Inject constructor( // Drop rows pending an undoable delete so the row vanishes on swipe // while the actual provider delete waits for the snackbar to commit, // and fold in the live UI settings (add-subtask row, bottom add bar). - val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar } - combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) -> + combine(content, pendingDeletes, settingsPrefs.settings) { st, pending, settings -> if (st is TaskListUiState.Content) { st.copy( - tasks = st.tasks.filter { it.taskId !in pending }, - showAddSubtaskRow = showRow, - bottomAddBar = bottomBar, + tasks = settings.visibleIn(f, st.tasks) + .filter { it.occurrenceKey !in pending } + .sortedWith(settings.sortOrder.comparator()), + showAddSubtaskRow = settings.showAddSubtaskRow, + bottomAddBar = settings.bottomAddBar, + sortOrder = settings.sortOrder, + hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false, ) } else { st } } .onStart { emit(TaskListUiState.Loading) } - .catch { emit(TaskListUiState.Failure) } + // Recover rather than terminate: a provider hiccup (mid-update, + // permission not yet granted) shows Failure but keeps retrying, + // so the screen heals itself instead of staying stuck. + .recoveringFromProviderFailure { TaskListUiState.Failure } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) @@ -112,6 +151,9 @@ class TaskListViewModel @Inject constructor( combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() } } } + // Without this an exception here escapes stateIn's coroutine, past + // viewModelScope's SupervisorJob, and crashes the process. + .recoveringFromProviderFailure { emptyMap() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) /** The screen reports which expanded parents need their children fetched. */ @@ -119,28 +161,106 @@ class TaskListViewModel @Inject constructor( fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } - fun toggleComplete(task: Task) = viewModelScope.launch { - runCatching { repository.setCompleted(task.taskId, !task.isCompleted) } - } - - /** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */ - fun markPendingDelete(task: Task) { - pendingDeletes.value = pendingDeletes.value + task.taskId - } - - /** Undo — stop hiding the task; it returns to the list. */ - fun undoDelete(taskId: Long) { - pendingDeletes.value = pendingDeletes.value - taskId + fun toggleComplete(task: Task) { + if (task.isCompleted) { + viewModelScope.launch { + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, false) } + } + return + } + viewModelScope.launch { + // Completing a parent completes its open subtasks; undo has to know which. + val openSubtasks = if (task.occurrenceStart == null) { + runCatching { repository.subtasks(task.taskId).first() }.getOrDefault(emptyList()).filter { !it.isClosed } + } else { + emptyList() + } + runCatching { repository.setCompleted(task.taskId, task.occurrenceStart, true) } + offerUndo(UndoReceipt.Completed(task, openSubtasks)) + } } /** - * Commit the delete once the undo window passes. The id stays in the pending - * set: the task is gone from the provider so it can't reappear, and removing - * it could briefly un-hide the row before the provider change propagates. + * Swipe-delete: the row hides at once and the delete commits once the undo + * window lapses — or when this screen goes away, whichever comes first. A + * swiped occurrence of a series takes only that occurrence with it. */ - fun commitDelete(taskId: Long) = viewModelScope.launch { - if (taskId !in pendingDeletes.value) return@launch - runCatching { repository.deleteTask(taskId) } + fun requestDelete(task: Task) { + commitDeletes() + pendingDeletes.value = pendingDeletes.value + (task.occurrenceKey to task) + offerUndo(UndoReceipt.Deleted(task)) + } + + fun undo() { + when (val receipt = _undo.value) { + is UndoReceipt.Deleted -> pendingDeletes.value = pendingDeletes.value - receipt.task.occurrenceKey + is UndoReceipt.Completed -> viewModelScope.launch { + runCatching { + repository.setCompleted(receipt.task.taskId, receipt.task.occurrenceStart, false) + receipt.subtasks.forEach { repository.setCompleted(it.taskId, it.occurrenceStart, false) } + } + } + null -> Unit + } + undoTimer?.cancel() + _undo.value = null + } + + /** Shows [receipt]; a delete already waiting is committed once its chip is replaced. */ + private fun offerUndo(receipt: UndoReceipt) { + if (receipt !is UndoReceipt.Deleted) commitDeletes() + undoTimer?.cancel() + _undo.value = receipt + undoTimer = viewModelScope.launch { + delay(UNDO_WINDOW_MS) + commitDeletes() + _undo.value = null + } + } + + /** + * Commits every pending delete. The rows stay hidden: they are gone from the + * store, and un-hiding them could flash them back before the change lands. + * Runs in the application scope so leaving the screen cannot cancel it. + */ + private fun commitDeletes() { + val due = pendingDeletes.value.values.filter { it.occurrenceKey !in committed } + if (due.isEmpty()) return + committed += due.map { it.occurrenceKey } + appScope.launch { + due.forEach { task -> + runCatching { repository.deleteTask(task.taskId, task.occurrenceStart, RecurringScope.ThisOccurrence) } + } + } + } + + private val committed = mutableSetOf() + + override fun onCleared() { + commitDeletes() + } + + fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = + viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) } + + fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) } + + /** + * Deletes every completed task in [listId]. A completed occurrence goes on its + * own, a series that is closed as a whole goes whole, and a parent that still + * has open subtasks stays — deleting it would take them along. + */ + fun clearCompleted(listId: Long) { + val done = (state.value as? TaskListUiState.Content)?.tasks + ?.filter { it.listId == listId && it.isCompleted && it.subtaskDone == it.subtaskTotal } + .orEmpty() + appScope.launch { + done.forEach { task -> + val wholeSeries = task.isOccurrence && task.taskId == task.seriesId + val scope = if (wholeSeries) RecurringScope.AllOccurrences else RecurringScope.ThisOccurrence + runCatching { repository.deleteTask(task.taskId, task.occurrenceStart, scope) } + } + } } /** Inline "add task" — title only, into [listId]. */ @@ -149,6 +269,62 @@ class TaskListViewModel @Inject constructor( runCatching { repository.createTask(TaskForm(title = title, listId = listId)) } } + private val _listWriteFailure = MutableStateFlow(null) + + /** Set when a list write is refused; the screen shows it and clears it. */ + val listWriteFailure: StateFlow = _listWriteFailure.asStateFlow() + + private val _listDeleted = MutableStateFlow(false) + + /** Flips once the list this screen shows is really gone, so it can leave. */ + val listDeleted: StateFlow = _listDeleted.asStateFlow() + + fun clearListWriteFailure() { _listWriteFailure.value = null } + + /** + * Rename / recolour the list this screen is showing. + * + * ⚠️ A synced list goes to the server **first**. Writing the row and setting + * `is_dirty` is what used to happen, and nothing ever read that flag — so a + * rename looked like it worked, never left the phone, and was silently + * reverted by whichever sync next re-read the collection's display name. + */ + fun updateList(listId: Long, name: String, color: Int, accountId: Long?) = + viewModelScope.launch { + if (name.isBlank()) return@launch + if (accountId == null) { + runCatching { repository.updateList(listId, name.trim(), color) } + .onFailure { _listWriteFailure.value = ListWriteFailure.SAVE } + return@launch + } + val outcome = runCatching { remoteLists.rename(listId, name.trim(), color) } + .getOrElse { RemoteListRepository.Outcome.Unreachable } + _listWriteFailure.value = outcome.asFailure(ListWriteFailure.SAVE) + } + + /** + * Delete the list **and its tasks**. The screen navigates away on + * [listDeleted], not on the call — leaving first would strand a refusal on a + * screen that no longer exists. + */ + fun deleteList(listId: Long, accountId: Long?) = viewModelScope.launch { + if (accountId == null) { + runCatching { repository.deleteList(listId) } + .onSuccess { _listDeleted.value = true } + .onFailure { _listWriteFailure.value = ListWriteFailure.DELETE } + return@launch + } + // ⚠️ The server first here too, and this one is destructive on both + // sides. Deleting only the row left the collection on the server with + // nothing pointing at it — the list vanished from the phone, kept + // existing for every other client, and could only be got back by + // re-adding the whole account. + val outcome = runCatching { remoteLists.delete(listId) } + .getOrElse { RemoteListRepository.Outcome.Unreachable } + val failure = outcome.asFailure(ListWriteFailure.DELETE) + if (failure == null) _listDeleted.value = true else _listWriteFailure.value = failure + } + /** Inline "add subtask" from an expanded list group — files it under [parent]. */ fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch { if (title.isBlank() || parent.listId <= 0L) return@launch diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt new file mode 100644 index 0000000..4da5e2a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidget.kt @@ -0,0 +1,267 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.agendula.MainActivity +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.time.formatDateTimeCompact +import de.jeanlucmakiola.floret.time.formatTime +import kotlinx.coroutines.flow.first +import kotlin.time.Instant + +/** + * "Tasks" home-screen widget: one smart list (Today by default) or one real + * list per instance, with a checkbox to complete each task in place. + * + * The data is collected live inside the composition rather than loaded once in + * the `provideGlance` preamble: an `update` on a running session only + * recomposes, so a preamble snapshot would stay stale for as long as the session + * lives — e.g. right after a checkbox tap. + */ +class TaskWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + override val sizeMode = SizeMode.Single + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val stored = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)[WIDGET_FILTER_KEY] + val initialFilter = parseWidgetFilter(stored) + val initial = runCatching { context.taskWidgetData(initialFilter).first() } + .getOrDefault(TaskWidgetData.Unavailable) + provideContent { + val filter = parseWidgetFilter(currentState(WIDGET_FILTER_KEY)) + val data by remember(filter) { context.taskWidgetData(filter) } + .collectAsState(initial = if (filter == initialFilter) initial else null) + val ready = data as? TaskWidgetData.Ready ?: initial as? TaskWidgetData.Ready + AgendulaGlanceTheme( + dynamicColor = ready?.dynamicColor ?: true, + themeMode = ready?.themeMode ?: ThemeMode.SYSTEM, + ) { + TaskWidgetBody(filter, data) + } + } + } +} + +/** Completes the tapped task — just the one occurrence for a recurring series. */ +class CompleteTaskAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + val taskId = parameters[TaskIdKey] ?: return + val occurrence = parameters[OccurrenceKey]?.takeIf { it != NO_OCCURRENCE } + ?.let { Instant.fromEpochMilliseconds(it) } + runCatching { + context.widgetEntryPoint().tasksRepository().setCompleted(taskId, occurrence, true) + } + TaskWidget().update(context.applicationContext, glanceId) + } + + companion object { + val TaskIdKey = ActionParameters.Key("taskId") + val OccurrenceKey = ActionParameters.Key("occurrenceStart") + const val NO_OCCURRENCE = Long.MIN_VALUE + } +} + +@Composable +private fun TaskWidgetBody(filter: TaskFilter, data: TaskWidgetData?) { + val context = LocalContext.current + val title = when (filter) { + is TaskFilter.Smart -> context.getString(smartListLabel(filter.list)) + is TaskFilter.OfList -> (data as? TaskWidgetData.Ready)?.listName + ?: context.getString(R.string.widget_tasks_label) + } + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.widgetBackground) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Header(title, filter) + Spacer(GlanceModifier.height(4.dp)) + when (data) { + null -> Unit + TaskWidgetData.Unavailable -> Message(context.getString(R.string.widget_unavailable)) + is TaskWidgetData.Ready -> + if (data.rows.isEmpty()) { + Message(context.getString(R.string.widget_empty)) + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { + items(data.rows.size) { index -> TaskRow(data.rows[index], data.use24Hour) } + } + } + } + } +} + +@Composable +private fun Header(title: String, filter: TaskFilter) { + val context = LocalContext.current + val open = when (filter) { + is TaskFilter.Smart -> MainActivity.smartListIntent(context, filter.list) + is TaskFilter.OfList -> MainActivity.listIntent(context, filter.listId) + } + Row( + modifier = GlanceModifier.fillMaxWidth().padding(start = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.primary, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ), + modifier = GlanceModifier.defaultWeight().clickable(actionStartActivity(open)), + ) + Box( + modifier = GlanceModifier + .size(40.dp) + .cornerRadius(20.dp) + .background(GlanceTheme.colors.primaryContainer) + .clickable(actionStartActivity(MainActivity.newTaskIntent(context))), + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(R.drawable.ic_widget_add), + contentDescription = context.getString(R.string.new_task), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimaryContainer), + modifier = GlanceModifier.size(22.dp), + ) + } + } +} + +@Composable +private fun TaskRow(row: WidgetTaskRow, use24Hour: Boolean) { + val context = LocalContext.current + val title = row.title.ifBlank { context.getString(R.string.task_untitled) } + val open = actionStartActivity( + MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()), + ) + Row( + modifier = GlanceModifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = GlanceModifier + .width(4.dp) + .height(32.dp) + .cornerRadius(2.dp) + .background(ColorProvider(day = pastelize(row.color, false), night = pastelize(row.color, true))), + ) {} + Box( + modifier = GlanceModifier + .size(40.dp) + .clickable( + actionRunCallback( + actionParametersOf( + CompleteTaskAction.TaskIdKey to row.taskId, + CompleteTaskAction.OccurrenceKey to + (row.occurrenceStart?.toEpochMilliseconds() ?: CompleteTaskAction.NO_OCCURRENCE), + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(R.drawable.ic_widget_checkbox), + contentDescription = context.getString(R.string.widget_complete_task, title), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), + modifier = GlanceModifier.size(22.dp), + ) + } + Column( + modifier = GlanceModifier.defaultWeight().padding(end = 8.dp, top = 4.dp, bottom = 4.dp) + .clickable(open), + ) { + Text( + text = title, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), + ) + dueText(context, row, use24Hour)?.let { due -> + Text( + text = due, + maxLines = 1, + style = TextStyle( + color = if (row.isOverdue) GlanceTheme.colors.error + else GlanceTheme.colors.onSurfaceVariant, + fontSize = 12.sp, + ), + ) + } + } + } +} + +@Composable +private fun Message(text: String) { + Box( + modifier = GlanceModifier.fillMaxSize().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp), + ) + } +} + +/** "Today · 14:00", "Tomorrow", or the compact date; `null` without a due date. */ +private fun dueText(context: Context, row: WidgetTaskRow, use24Hour: Boolean): String? { + val due = row.due ?: return null + val relative = when (row.dueKind) { + DueKind.TODAY -> context.getString(R.string.home_due_today) + DueKind.TOMORROW -> context.getString(R.string.home_due_tomorrow) + else -> null + } ?: return due.formatDateTimeCompact(row.isAllDay, use24Hour) + return if (row.isAllDay) relative else "$relative · ${due.formatTime(use24Hour)}" +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt new file mode 100644 index 0000000..f248321 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetConfigActivity.kt @@ -0,0 +1,160 @@ +package de.jeanlucmakiola.agendula.widget + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +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.material.icons.Icons +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.agendula.R +import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskList +import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.SelectedCheck +import de.jeanlucmakiola.floret.components.pastelize +import de.jeanlucmakiola.floret.components.positionOf +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Picks what one widget instance shows: a smart list or a real list. Runs when + * the widget is placed (below API 31) and on "reconfigure" from the launcher. + */ +@AndroidEntryPoint +class TaskWidgetConfigActivity : ComponentActivity() { + + @Inject lateinit var repository: TasksRepository + @Inject lateinit var settingsPrefs: SettingsPrefs + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + // Backing out cancels the placement, as the framework expects. + setResult(RESULT_CANCELED, resultIntent(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + enableEdgeToEdge() + val glanceId = GlanceAppWidgetManager(this).getGlanceIdBy(appWidgetId) + val listsFlow = runCatching { repository.taskLists() }.getOrDefault(emptyFlow()) + .catch { emit(emptyList()) } + + setContent { + val settings by settingsPrefs.settings.collectAsState(initial = Settings()) + val lists by remember { listsFlow }.collectAsState(initial = emptyList()) + var current by remember { mutableStateOf(null) } + LaunchedEffect(glanceId) { + current = parseWidgetFilter( + getAppWidgetState(this@TaskWidgetConfigActivity, PreferencesGlanceStateDefinition, glanceId)[WIDGET_FILTER_KEY], + ) + } + val dark = when (settings.themeMode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} + WidgetFilterPicker( + lists = lists.filter { it.isVisible }, + dark = dark, + selected = current ?: DEFAULT_WIDGET_FILTER, + onSelect = { filter -> + lifecycleScope.launch { + updateAppWidgetState(this@TaskWidgetConfigActivity, glanceId) { + it[WIDGET_FILTER_KEY] = filter.widgetStorageValue() + } + TaskWidget().update(this@TaskWidgetConfigActivity, glanceId) + setResult(RESULT_OK, resultIntent(appWidgetId)) + finish() + } + }, + onDismiss = { finish() }, + ) + } + } + } + + private fun resultIntent(appWidgetId: Int) = + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) +} + +@Composable +private fun WidgetFilterPicker( + lists: List, + selected: TaskFilter, + dark: Boolean, + onSelect: (TaskFilter) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker(title = stringResource(R.string.widget_config_title), onDismiss = onDismiss) { + GroupedSectionHeader(stringResource(R.string.widget_config_smart_lists)) + WIDGET_SMART_LISTS.forEachIndexed { index, smart -> + val filter = TaskFilter.Smart(smart) + val isSelected = filter == selected + GroupedRow( + title = stringResource(smartListLabel(smart)), + position = positionOf(index, WIDGET_SMART_LISTS.size), + selected = isSelected, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { onSelect(filter) }, + ) + } + if (lists.isNotEmpty()) { + GroupedSectionHeader(stringResource(R.string.lists_header)) + lists.forEachIndexed { index, list -> + val filter = TaskFilter.OfList(list.id) + val isSelected = filter == selected + GroupedRow( + title = list.name, + position = positionOf(index, lists.size), + selected = isSelected, + leading = { Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark)) }, + trailing = if (isSelected) ({ SelectedCheck() }) else null, + onClick = { onSelect(filter) }, + ) + } + } + } +} + +internal fun smartListLabel(list: SmartList): Int = when (list) { + SmartList.TODAY -> R.string.smart_today + SmartList.UPCOMING -> R.string.smart_upcoming + SmartList.OVERDUE -> R.string.smart_overdue + else -> R.string.smart_all +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt new file mode 100644 index 0000000..e0a0263 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetReceiver.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.agendula.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +/** Host-facing receiver for [TaskWidget]; also keeps the midnight rollover armed. */ +class TaskWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TaskWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetRolloverScheduler.sync(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + WidgetRolloverScheduler.sync(context) + } + + /** Self-heal on the system's `updatePeriodMillis` wake-up, should the alarm have been dropped. */ + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + WidgetRolloverScheduler.sync(context) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt new file mode 100644 index 0000000..7a3869f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/TaskWidgetUpdater.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Context +import androidx.glance.appwidget.updateAll +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps the task widget in step with the store: any change — our own writes, + * a sync, a storage-mode switch — schedules one debounced redraw. Started once + * from `AgendulaApp`, after the startup gate, so it observes the right store. + */ +@Singleton +class TaskWidgetUpdater @Inject constructor( + @ApplicationContext private val context: Context, + private val dataSource: TasksDataSource, + @ApplicationScope private val scope: CoroutineScope, +) { + private val requests = Channel(Channel.CONFLATED) + private var observer: AutoCloseable? = null + + @OptIn(FlowPreview::class) + fun start() { + if (observer != null) return + observer = runCatching { dataSource.registerObserver { requests.trySend(Unit) } }.getOrNull() + scope.launch(Dispatchers.IO) { + requests.receiveAsFlow().debounce(DEBOUNCE_MILLIS).collect { redraw() } + } + scope.launch(Dispatchers.IO) { runCatching { WidgetRolloverScheduler.sync(context) } } + // The process may have been started by a change it never heard about. + requestRefresh() + } + + /** Redraw soon; bursts collapse into one pass. */ + fun requestRefresh() { + requests.trySend(Unit) + } + + private suspend fun redraw() { + runCatching { + if (WidgetRolloverScheduler.hasPlacedWidgets(context)) TaskWidget().updateAll(context) + } + } + + private companion object { + const val DEBOUNCE_MILLIS = 500L + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt new file mode 100644 index 0000000..5a36905 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetData.kt @@ -0,0 +1,150 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Context +import android.text.format.DateFormat +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.data.prefs.is24Hour +import androidx.datastore.preferences.core.stringPreferencesKey +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.Task +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.calendarDate +import de.jeanlucmakiola.agendula.domain.comparator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Clock +import kotlin.time.Instant + +/** Per-instance Glance state key holding the widget's [TaskFilter] ([widgetStorageValue]). */ +internal val WIDGET_FILTER_KEY = stringPreferencesKey("task_filter") + +internal val DEFAULT_WIDGET_FILTER: TaskFilter = TaskFilter.Smart(SmartList.TODAY) + +/** The smart lists a widget can show, in picker order. */ +internal val WIDGET_SMART_LISTS = listOf(SmartList.TODAY, SmartList.UPCOMING, SmartList.OVERDUE, SmartList.ALL) + +/** + * Upper bound on rows, so the serialized RemoteViews stays well inside the + * binder transaction limit however long the list is. + */ +internal const val MAX_WIDGET_ROWS = 60 + +internal fun TaskFilter.widgetStorageValue(): String = when (this) { + is TaskFilter.OfList -> "list:$listId" + is TaskFilter.Smart -> "smart:${list.name}" +} + +/** Anything unreadable, or a smart list the widget doesn't offer, falls back to Today. */ +internal fun parseWidgetFilter(raw: String?): TaskFilter { + val value = raw ?: return DEFAULT_WIDGET_FILTER + return when { + value.startsWith("list:") -> + value.removePrefix("list:").toLongOrNull()?.takeIf { it > 0 }?.let { TaskFilter.OfList(it) } + value.startsWith("smart:") -> + WIDGET_SMART_LISTS.firstOrNull { it.name == value.removePrefix("smart:") }?.let { TaskFilter.Smart(it) } + else -> null + } ?: DEFAULT_WIDGET_FILTER +} + +/** Which day a due date falls on, relative to today. */ +enum class DueKind { PAST, TODAY, TOMORROW, LATER } + +/** The day [due] falls on relative to [today]; an all-day due is read as its UTC date. */ +internal fun dueKind(due: Instant, allDay: Boolean, today: LocalDate, zone: ZoneId): DueKind { + val date = due.calendarDate(allDay, zone) + return when { + date < today -> DueKind.PAST + date == today -> DueKind.TODAY + date == today.plusDays(1) -> DueKind.TOMORROW + else -> DueKind.LATER + } +} + +/** A timed task is overdue once its moment passes; an all-day one only the day after. */ +internal fun isOverdue(due: Instant, allDay: Boolean, now: Instant, today: LocalDate, zone: ZoneId): Boolean = + if (allDay) due.calendarDate(true, zone) < today else due < now + +data class WidgetTaskRow( + val taskId: Long, + val occurrenceStart: Instant?, + val title: String, + val due: Instant?, + val isAllDay: Boolean, + val dueKind: DueKind?, + val isOverdue: Boolean, + val color: Int, +) + +sealed interface TaskWidgetData { + /** No readable store yet (onboarding unfinished, permission revoked, provider gone). */ + data object Unavailable : TaskWidgetData + + data class Ready( + val filter: TaskFilter, + /** The real list's name; `null` for a smart list or a list that no longer exists. */ + val listName: String?, + val rows: List, + val dynamicColor: Boolean, + val themeMode: ThemeMode, + val use24Hour: Boolean, + ) : TaskWidgetData +} + +/** + * Open rows for [tasks], sorted the way the app sorts them. A subtask whose + * parent is also shown is left out, as on the list screen. + */ +internal fun widgetRows( + tasks: List, + comparator: Comparator, + now: Instant, + zone: ZoneId, +): List { + val open = tasks.filterNot { it.isClosed } + val present = open.mapTo(HashSet()) { it.taskId } + val today = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds()).atZone(zone).toLocalDate() + return open + .filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present } + .sortedWith(comparator) + .take(MAX_WIDGET_ROWS) + .map { task -> + WidgetTaskRow( + taskId = task.taskId, + occurrenceStart = task.occurrenceStart, + title = task.title, + due = task.due, + isAllDay = task.isAllDay, + dueKind = task.due?.let { dueKind(it, task.isAllDay, today, zone) }, + isOverdue = task.due?.let { isOverdue(it, task.isAllDay, now, today, zone) } ?: false, + color = task.effectiveColor, + ) + } +} + +/** + * Live widget data for [filter]. Re-emits on every store change, since the + * repository's flows observe the store; a store that can't be read (permission + * revoked, provider uninstalled) reads as [TaskWidgetData.Unavailable]. + */ +internal fun Context.taskWidgetData(filter: TaskFilter): Flow { + val ep = widgetEntryPoint() + val repository = ep.tasksRepository() + return combine( + repository.tasks(filter), + repository.taskLists(), + ep.settingsPrefs().settings, + ) { tasks, lists, settings -> + val data: TaskWidgetData = TaskWidgetData.Ready( + filter = filter, + listName = (filter as? TaskFilter.OfList)?.let { f -> lists.firstOrNull { it.id == f.listId }?.name }, + rows = widgetRows(settings.visibleIn(filter, tasks), settings.sortOrder.comparator(), Clock.System.now(), ZoneId.systemDefault()), + dynamicColor = settings.dynamicColor, + themeMode = settings.themeMode, + use24Hour = settings.timeFormat.is24Hour(DateFormat.is24HourFormat(this)), + ) + data + }.catch { emit(TaskWidgetData.Unavailable) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetEntryPoint.kt new file mode 100644 index 0000000..49d6447 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetEntryPoint.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Context +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.tasks.TasksRepository + +/** + * Hilt bridge for the Glance widget, which the framework instantiates and so + * cannot take constructor injection. Reads the same repository the app does. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WidgetEntryPoint { + fun tasksRepository(): TasksRepository + fun settingsPrefs(): SettingsPrefs +} + +internal fun Context.widgetEntryPoint(): WidgetEntryPoint = + EntryPointAccessors.fromApplication(applicationContext, WidgetEntryPoint::class.java) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt new file mode 100644 index 0000000..2c646c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverScheduler.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.agendula.widget + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.core.content.getSystemService +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * The app's own wake-up for the next local midnight, so "Today" on the widget + * flips over on the day boundary. `ACTION_DATE_CHANGED` isn't delivered to + * manifest receivers since Android 8, which is why this exists. + * + * One inexact alarm at a time, re-armed by every firing: `setAndAllowWhileIdle` + * needs no permission and survives doze, and a rollover a few minutes late is + * invisible on a sleeping screen. + */ +object WidgetRolloverScheduler { + + /** Fire just after midnight, so an early delivery doesn't still read the old date. */ + internal val ROLLOVER_SLACK = 5.seconds + + /** Arm the next rollover, or cancel it when no widget is placed. Idempotent. */ + fun sync(context: Context) { + val appContext = context.applicationContext + val alarmManager = appContext.getSystemService() ?: return + val pendingIntent = rolloverPendingIntent(appContext) + if (!hasPlacedWidgets(appContext)) { + alarmManager.cancel(pendingIntent) + return + } + val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault()) + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent, + ) + } + + /** + * The instant just after the next local midnight following [now] in [zone]. + * Uses the actual start of day, so it holds where DST skips midnight or a + * date-line move skips a whole date. + */ + fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { + val date = now.toLocalDateTime(zone).date + var days = 1 + while (days <= MAX_LOOKAHEAD_DAYS) { + val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK + if (candidate > now) return candidate + days++ + } + // Unreachable for any real zone; an hour rather than the slack so it can't loop. + return now + 1.hours + } + + fun hasPlacedWidgets(context: Context): Boolean { + val manager = AppWidgetManager.getInstance(context) ?: return false + return manager.getAppWidgetIds(ComponentName(context, TaskWidgetReceiver::class.java)).isNotEmpty() + } + + private fun rolloverPendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + ROLLOVER_REQUEST_CODE, + Intent(context, WidgetUpdateReceiver::class.java) + .setAction(WidgetUpdateReceiver.ACTION_ROLLOVER), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private const val ROLLOVER_REQUEST_CODE = 0x0DA1 + + private const val MAX_LOOKAHEAD_DAYS = 3 +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt new file mode 100644 index 0000000..5dcc3d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetTheme.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.agendula.widget + +import android.os.Build +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.material3.ColorProviders +import de.jeanlucmakiola.agendula.data.prefs.ThemeMode +import de.jeanlucmakiola.agendula.ui.theme.AgendulaDarkFallback +import de.jeanlucmakiola.agendula.ui.theme.AgendulaLightFallback + +private val AgendulaGlanceColors = ColorProviders( + light = AgendulaLightFallback, + dark = AgendulaDarkFallback, +) + +/** + * Glance counterpart of `AgendulaTheme`: Material You on API 31+ when dynamic + * colour is on, the brand fallback schemes otherwise. On [ThemeMode.SYSTEM] it + * draws only from colour-role tokens, so light/dark follows the system without a + * redraw; a forced light or dark theme pins one scheme for both. + */ +@Composable +fun AgendulaGlanceTheme(dynamicColor: Boolean, themeMode: ThemeMode, content: @Composable () -> Unit) { + val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val context = LocalContext.current + val colors = when (themeMode) { + ThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else AgendulaGlanceColors + ThemeMode.LIGHT -> ColorProviders( + if (dynamic) dynamicLightColorScheme(context) else AgendulaLightFallback, + ) + ThemeMode.DARK -> ColorProviders( + if (dynamic) dynamicDarkColorScheme(context) else AgendulaDarkFallback, + ) + } + GlanceTheme(colors = colors, content = content) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt new file mode 100644 index 0000000..ceb1cab --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/widget/WidgetUpdateReceiver.kt @@ -0,0 +1,56 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Redraws the task widget when its day or its language goes stale, and keeps the + * midnight rollover armed. Task data changes don't come through here — see + * [TaskWidgetUpdater]. + * + * Exported for the system broadcasts; an extra redraw from another app is harmless. + */ +class WidgetUpdateReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in HANDLED_ACTIONS) return + val appContext = context.applicationContext + val rearm = intent.action in REARM_ACTIONS + // The host redraws the widget itself after these two. + val redraw = intent.action !in REARM_ONLY_ACTIONS + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + if (rearm) WidgetRolloverScheduler.sync(appContext) + if (redraw && WidgetRolloverScheduler.hasPlacedWidgets(appContext)) { + TaskWidget().updateAll(appContext) + } + } finally { + pending.finish() + } + } + } + + companion object { + const val ACTION_ROLLOVER = "de.jeanlucmakiola.agendula.widget.ROLLOVER" + + private val REARM_ONLY_ACTIONS = setOf( + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + ) + + internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf( + ACTION_ROLLOVER, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_DATE_CHANGED, + ) + + internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_LOCALE_CHANGED + } +} diff --git a/app/src/main/res/drawable/ic_codeberg.xml b/app/src/main/res/drawable/ic_codeberg.xml new file mode 100644 index 0000000..8f5bfcf --- /dev/null +++ b/app/src/main/res/drawable/ic_codeberg.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_provider_fastmail.xml b/app/src/main/res/drawable/ic_provider_fastmail.xml new file mode 100644 index 0000000..c0f151a --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_fastmail.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_icloud.xml b/app/src/main/res/drawable/ic_provider_icloud.xml new file mode 100644 index 0000000..7d55522 --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_icloud.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_mailbox.xml b/app/src/main/res/drawable/ic_provider_mailbox.xml new file mode 100644 index 0000000..afec60c --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_mailbox.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_nextcloud.xml b/app/src/main/res/drawable/ic_provider_nextcloud.xml new file mode 100644 index 0000000..d069e5a --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_nextcloud.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_posteo.xml b/app/src/main/res/drawable/ic_provider_posteo.xml new file mode 100644 index 0000000..2603095 --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_posteo.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_qs_new_task.xml b/app/src/main/res/drawable/ic_qs_new_task.xml new file mode 100644 index 0000000..ee180ab --- /dev/null +++ b/app/src/main/res/drawable/ic_qs_new_task.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_shortcut_new_task.xml b/app/src/main/res/drawable/ic_shortcut_new_task.xml new file mode 100644 index 0000000..6b8d28e --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_new_task.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_shortcut_today.xml b/app/src/main/res/drawable/ic_shortcut_today.xml new file mode 100644 index 0000000..1672fbf --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_today.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_widget_add.xml b/app/src/main/res/drawable/ic_widget_add.xml new file mode 100644 index 0000000..4797a8b --- /dev/null +++ b/app/src/main/res/drawable/ic_widget_add.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/ic_widget_checkbox.xml b/app/src/main/res/drawable/ic_widget_checkbox.xml new file mode 100644 index 0000000..8ffa575 --- /dev/null +++ b/app/src/main/res/drawable/ic_widget_checkbox.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/preview_add_bg.xml b/app/src/main/res/drawable/preview_add_bg.xml new file mode 100644 index 0000000..7bd7cac --- /dev/null +++ b/app/src/main/res/drawable/preview_add_bg.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/preview_stripe.xml b/app/src/main/res/drawable/preview_stripe.xml new file mode 100644 index 0000000..222a3f6 --- /dev/null +++ b/app/src/main/res/drawable/preview_stripe.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/preview_widget_bg.xml b/app/src/main/res/drawable/preview_widget_bg.xml new file mode 100644 index 0000000..eee3dbf --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/widget_preview_tasks.xml b/app/src/main/res/layout/widget_preview_tasks.xml new file mode 100644 index 0000000..d9d14bc --- /dev/null +++ b/app/src/main/res/layout/widget_preview_tasks.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to app/src/main/res/mipmap-anydpi/ic_launcher_round.xml diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 42a6d68..dd9704e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -15,9 +15,6 @@ Abgeschlossen Aufgabe Diese Aufgabe ist nicht mehr verfügbar. - Aufgabe bearbeiten - Neue Aufgabe - Titel Konnte nicht gespeichert werden. Versuche es erneut. Aufgabe hinzufügen Unteraufgabe hinzufügen @@ -36,11 +33,8 @@ Niedrig Mittel Hoch - Als abgeschlossen markieren - Als nicht abgeschlossen markieren Liste Fällig - Beginn Priorität Fortschritt Unteraufgaben @@ -69,11 +63,6 @@ Überschreiben Keine Zum Start der Aufgabe - 5 Minuten vorher - 10 Minuten vorher - 30 Minuten vorher - 1 Stunde vorher - 1 Tag vorher Benutzerdefiniert Benutzerdefiniert (%1$s) Übernehmen @@ -98,9 +87,6 @@ Überfällig Anstehend Alle - %1$d offen - %1$d von %2$d erledigt - %1$d übrig Alles erledigt 🎉 Heute keine Fälligkeiten 🎉 Alle anzeigen @@ -108,7 +94,6 @@ Morgen Aufgaben suchen Suche zurücksetzen - Suche schließen Keine Aufgaben passen zu „%1$s“ Fälligkeiten %1$s Aufgaben Erinnerungen @@ -118,8 +103,6 @@ Erlaube Zugriff auf deine Aufgaben Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt. Zugriff gewähren - Installiere OpenTasks - Installiere tasks.org Verpasse nichts, was fällig ist Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist. Erinnerungen aktivieren @@ -162,7 +145,6 @@ Exakter Zeitpunkt Die Erinnerungen erfolgen zum exakten Zeitpunkt Blockiert – tippe, um exakte Erinnerungen zu erlauben - Aufgaben Standard-Liste Erste verfügbare Felder Zeile \"Unteraufgaben hinzufügen\" anzeigen diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml new file mode 100644 index 0000000..f5f5e1f --- /dev/null +++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml @@ -0,0 +1,9 @@ + + + @android:color/system_neutral1_900 + @android:color/system_neutral1_50 + @android:color/system_neutral2_200 + @android:color/system_accent1_200 + @android:color/system_accent1_700 + @android:color/system_accent1_100 + diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml new file mode 100644 index 0000000..eda3004 --- /dev/null +++ b/app/src/main/res/values-night/widget_preview_colors.xml @@ -0,0 +1,9 @@ + + + #161113 + #E9E0E3 + #B8ABB0 + #E2BAC9 + #583B49 + #F6D9E5 + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4ba6192..080fdcd 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,7 +1,6 @@ Agendula - Um app de tarefas em Material 3 Expressive. \nFlorescendo. Tarefa sem título Voltar Salvar @@ -14,9 +13,6 @@ Tarefa Esta tarefa não existe mais. Editar - Editar tarefa - Nova tarefa - Título Descrição Não foi possível salvar. Tente novamente. Adicionar tarefa @@ -37,11 +33,8 @@ Baixa Média Alta - Marcar como concluído - Marcar como não concluído Lista Prazo - Inicia Prioridade Progresso Subtarefas @@ -71,11 +64,6 @@ Sobrescrever Nenhum Na hora da tarefa - 5 minutos antes - 10 minutos antes - 30 minutos antes - 1 hora antes - 1 dia antes Personalizado Personalizado (%1$s) Ajustar @@ -100,9 +88,6 @@ Prazo expirado A seguir Tudo - %1$d abertos - %1$d de %2$d concluídos - %1$d restantes Tudo pronto 🎉 Nada terminando hoje 🎉 Ver tudo @@ -110,7 +95,6 @@ Amanhã Buscar tarefas Limpar buscas - Fechar busca Nenhuma tarefa corresponde a \"%1$s\" Termina em %1$s Lembretes de tarefas @@ -120,8 +104,6 @@ Permitir acesso às suas tarefas O Agendula precisa de permissão para ler e salvar suas tarefas. É tudo o que ele pede. Conceder acesso às tarefas - Instalar OpenTasks - Instalar tasks.org Nunca perca seus prazos Apps de tarefas não enviam lembretes sozinhos, então o Agendula os entrega para você. Ative para receber uma notificação de quando uma tarefa estiver no prazo. Ativar lembretes @@ -166,7 +148,6 @@ Na hora Lembretes disparam na mesma hora Bloqueado — toque para permitir lembretes na hora - Tarefas Lista padrão Primeira lista disponível Mostrar uma linha \"adicionar subtarefa\" diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml new file mode 100644 index 0000000..bba3eab --- /dev/null +++ b/app/src/main/res/values-v31/widget_preview_colors.xml @@ -0,0 +1,9 @@ + + + @android:color/system_neutral1_50 + @android:color/system_neutral1_900 + @android:color/system_neutral2_700 + @android:color/system_accent1_600 + @android:color/system_accent1_100 + @android:color/system_accent1_900 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 2498237..19d27a8 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,5 @@ - #FF7A5C6B #FF7A5C6B diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f8b7cd..f89e585 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ Agendula - A modern Material 3 Expressive task app.\nComing into bloom. Untitled task @@ -21,9 +20,6 @@ Edit - Edit task - New task - Title Description Could not save. Try again. @@ -34,6 +30,26 @@ Complete Reopen Deleted + Completed + Repeats + New task + Today + New task + Reorder lists + Done reordering + Drag to reorder + Sort by + Due date + Priority + Title + Recently added + Clear + Clear completed tasks? + + %1$d completed task in this list will be deleted. + %1$d completed tasks in this list will be deleted. + + Delete Undo Show subtasks Hide subtasks @@ -52,11 +68,8 @@ High - Mark complete - Mark not complete List Due - Starts Priority Progress Subtasks @@ -92,11 +105,8 @@ None At time of task - 5 minutes before - 10 minutes before - 30 minutes before - 1 hour before - 1 day before + On the day + Pick a task to see it here Custom @@ -110,28 +120,150 @@ Weeks Per-list reminders Override the default reminder for individual lists + Notifications + Sound, vibration and importance for this list’s reminders Default (%1$s) + + Repeat + Repeating task + Every day + Every week + Every month + Every year + + Every %1$d day + Every %1$d days + + + Every %1$d week + Every %1$d weeks + + + Every %1$d month + Every %1$d months + + + Every %1$d year + Every %1$d years + + + %1$s on %2$s + %1$s until %2$s + + %1$s, %2$d time + %1$s, %2$d times + + Does not repeat + Custom + Every + days + weeks + months + years + Enter a number from 1 to 999 + + Next: %1$s + This rule never repeats + Ends + Never + On a date + After a number of times + times + Only this task + This and following tasks + All tasks in the series + Enter a title. Choose a list. Due can\'t be before the start. Set a due date to use a reminder. + A repeating task needs a start or due date. + Repeat + Does not repeat + Location + Link + https://… + Discard changes? + Your changes to this task haven’t been saved. + Discard + Keep editing + Save changes to a repeating task + Add reminder + Add a reminder (otherwise the default applies) + Remove reminder + %1$s (before start) + %1$d / %2$d + %1$d/%2$d + Delete task? + This task will be deleted. + + This task and its subtask will be deleted. + This task and its %1$d subtasks will be deleted. + + Delete repeating task + Cancelled + In progress + More options + Cancel task + Restore task + Duplicate Lists New task Could not read your tasks. - No task lists yet. Add one in your tasks app or with the + button. + No task lists yet. + Create a list + + + New list + New list + Edit list + List name + Show in smart lists + Include this list\u2019s tasks in Today, Upcoming, All and the other smart lists + Colour + Delete list + Could not save the list. + Could not delete the list. + Delete list? + Where + On this device only + This list is shared with you read-only, so its name and colour are the owner\u2019s to change. + \u201c%1$s\u201d and all of its tasks will be deleted from the server, and from every other device signed in to this account. This can\u2019t be undone. + The server would not accept that change. + Couldn\u2019t reach the server. Try again when you\u2019re back online. + That list is shared with you read-only. + That account doesn\u2019t let apps create task lists. + “%1$s” and all of its tasks will be deleted. This can\'t be undone. + Mauve + Red + Orange + Amber + Olive + Green + Teal + Cyan + Blue + Indigo + Purple + Pink Today Overdue Upcoming All - %1$d open - %1$d of %2$d done - %1$d left + + %1$d of %2$d done + %1$d of %2$d done + + + %1$d left + %1$d left + All done 🎉 Nothing due today 🎉 @@ -143,49 +275,119 @@ Search tasks Clear search - Close search No tasks match “%1$s” Due %1$s + + today + tomorrow + yesterday + Done + Snooze %1$d min Task reminders Notifications for tasks that are due. A tasks app is needed - Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started. + You chose to keep tasks in another app, but no compatible one is installed. Install OpenTasks or tasks.org, or switch back to Agendula\'s own storage. Allow access to your tasks Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for. Grant task access - Install OpenTasks - Install tasks.org + + + Everything you meant to do + A calm place for your tasks, on a phone that stays yours. Let\'s set it up — it takes a minute. + Get started + Lists and subtasks + Keep work, home and a project apart, and break the big ones down. + Reminders that arrive + A notification when something is due, on your schedule. + Your server, or none at all + Sync over CalDAV with a server you choose, or keep everything on this device. + + + Sync with your own server? + Agendula talks CalDAV — Nextcloud, Radicale, Posteo, Fastmail and anything else that speaks it. No account of ours, ever. + Connect an account + Not now + Every device at once + Tick something off here and it is ticked off everywhere. + A copy that survives the phone + Your tasks live on the server too, so a lost device is not lost work. + Yours to choose + Self-hosted or a provider you already pay — Agendula only stores the password. + + + Your first list + Lists keep things apart. Start with one — you can add more, and rename this one, any time. + Personal + Create list + + + Keep a copy + Without an account, your lists live only on this device. Save them as iCalendar files any other task or calendar app can read, any time from Settings → Storage → Export. + Continue + + + How would you like to add tasks? + Quick add bar + A field at the bottom of every list: type, hit enter, on to the next one. + New task button + A floating button that opens the full form. + Finish - Never miss what\'s due - Tasks apps don\'t send reminders themselves, so Agendula delivers them for you. Turn them on to get a notification when a task is due. + Stay on top of your tasks + Agendula notifies you when something is due, so nothing has to live in your head. Enable reminders Not now - Agendula reminds you - It schedules a notification for each task with a due date. + A nudge when it matters + Every task with a due date gets its own notification. On your schedule - Choose how far ahead to be reminded in Settings. + Choose how far ahead to be reminded — for everything, or per list. Change it anytime Turn reminders off whenever you like — it\'s just a switch. + Right on time + Android asks before an app may notify you at an exact minute. Allow it and reminders arrive when they\'re due — without it they can be a few minutes late. + Allow exact timing Settings + + Look & behaviour + Data + App + + About Appearance - Theme and colour + Theme, colour and time format Task form Default fields, list and subtasks Notifications for due tasks Fields shown by default on a new task. The rest sit behind \"More fields\". Available on Android 12 and later + Focus title on new task + When you start a new task, place the cursor in the title and open the keyboard right away. + Time format + Automatic + 12-hour (2:00 PM) + 24-hour (14:00) + Following the system: %1$s + Week starts on + Automatic + Currently %1$s by Jean-Luc Makiola Source License Support development + Open source licenses + The projects Agendula is built on + Open source licenses + Agendula includes the work below. Tap any entry to get its source code. + Some of this code is included in modified form. Where that\u2019s the case, every change is listed in the source repository linked above. + Privacy policy Version %1$s Agendula app icon App language @@ -198,6 +400,7 @@ Crash report https://codeberg.org/jlmakiola/agendula/issues/new https://codeberg.org/jlmakiola/agendula/src/branch/main/LICENSE + https://jeanlucmakiola.de/agendula/privacy https://ko-fi.com/jeanlucmakiola https://weblate.dev.jeanlucmakiola.de/engage/agendula/ Theme @@ -209,11 +412,20 @@ Reminders Due reminders Notify me when a task is due + Notifications are off + Reminders can\u2019t reach you. Tap to allow notifications for Agendula. When to remind + Time for all-day reminders + All-day tasks count back from %1$s + All-day tasks + Notifies at %1$s Exact timing Reminders fire at the exact time Blocked — tap to allow exact reminders - Tasks + Reliable delivery + Snooze duration + Android may delay reminders to save battery. Exempt Agendula so they arrive on time. + Exempt from battery optimisation — reminders arrive on time. Default list First available list Show \"add a subtask\" row @@ -221,7 +433,116 @@ Bottom quick-add bar Add tasks from a bar pinned to the bottom of a list, instead of the floating button + Use this device\'s storage instead + + + Storage + Where tasks are kept, and export + Task store + Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them. + On this device + Agendula\'s own storage. Nothing else to install. + Another task app + Share tasks with the app that syncs them + No compatible task app is installed + Permission denied + The other app\'s tasks stay unreachable until you allow access. Tap to open app settings. + + + Switch task store? + Your tasks stay in %1$s — they are not moved. Agendula will show its own storage, which starts out empty unless you copy them over. + Your tasks stay in %1$s — they are not moved. Agendula will show the other app\'s tasks instead. + Switch + Copy tasks from %1$s + Bring them into Agendula\'s own storage. A one-time copy — the originals stay where they are. + Copy tasks over? + Counting what there is to copy… + %1$s holds no tasks to copy. + + %1$d task from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once. + %1$d tasks from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once. + + Copy + Copying tasks… + + Copied %1$d task into %2$d list. Pick “On this device” above to see them. + Copied %1$d tasks into %2$d lists. Pick “On this device” above to see them. + + There was nothing to copy + The tasks could not be copied. Nothing was changed — your originals are untouched. + Your earlier tasks could not be moved + They are still saved and nothing was lost. Tap to try moving them again. + Moving them now… + Export tasks + Save your lists as iCalendar files + One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip. + No lists to export + Save to a folder + Save as a zip file + Exporting… + Open + + Syncing %1$d list + Syncing %1$d lists + + + Exported %1$d list + Exported %1$d lists + + The export could not be written + The chosen folder could not be opened + The chosen folder is not writable + A file could not be created in the chosen folder + Access to the chosen location was lost + + + Import tasks + Bring in tasks from an iCalendar file + Pick an .ics file, or a zip of them like the export writes. Repeats, subtasks and everything else the tasks carry come along. Tasks already in the chosen list are skipped. + Importing writes into Agendula\'s own storage. Switch the task store to “On this device” to import. + Storage + Choose a file + Choose another file + Import another file + Reading the file… + Importing… + Picked file + + %1$d task + %1$d tasks + + + %1$d calendar event left out + %1$d calendar events left out + + Import into + + New list: %1$s + A new list on this device + Imported tasks + Import + + + Imported %1$d task into %2$s + Imported %1$d tasks into %2$s + + + %1$d already there, skipped + %1$d already there, skipped + + The file could not be read as iCalendar + The file holds no tasks + The tasks could not be saved + + + %d minute + %d minutes + + + %d hour + %d hours + %1$d minute before %1$d minutes before @@ -238,4 +559,205 @@ %1$d week before %1$d weeks before + + + Accounts + Sync your tasks with a CalDAV server + No accounts yet + Add a CalDAV account and Agendula keeps your task lists in step with it — Nextcloud, Radicale, Baïkal and anything else that speaks the protocol. + Add an account + Sync automatically + Only when you sync + How often every account syncs in the background. Edits you make are sent within a minute either way. Android may stretch the interval to save battery. + + Every %d minute + Every %d minutes + + + Every hour + Every %d hours + + Push + Off + Changes arrive instantly, via %1$s + + Instant for %1$d of %2$d list, via %3$s + Instant for %1$d of %2$d lists, via %3$s + + Your server doesn’t offer push yet + Setting up push with your server\u2026 + Install a UnifiedPush app to get changes instantly + Choose which app delivers push + With a UnifiedPush app such as ntfy installed, changes made on other devices arrive within seconds rather than at the next sync. Your server has to offer WebDAV-Push — on Nextcloud, that is the WebDAV Push app. + No UnifiedPush app is installed. Install one — ntfy, NextPush or Sunup, for example — and it appears here. + Changes arrive instantly + + Instant for %1$d of %2$d list + Instant for %1$d of %2$d lists + + This server doesn’t offer push + Remove account + %1$s stops syncing with this device. Nothing is deleted from the server. + Remove, keep the tasks + Its lists stay on this device as device-only lists. + Remove and delete the tasks + Also deletes this account\u2019s lists and tasks from this device. + Never synced + Last sync + CalDAV account + Last sync didn\u2019t finish + Sync failed \u2014 the server didn\u2019t accept the password + Sync failed \u2014 couldn\u2019t reach the server + Sync failed \u2014 the server\u2019s certificate isn\u2019t trusted + Sync failed \u2014 the server reported an error + Sync failed \u2014 the server reported an error (%1$d) + Sync failed \u2014 the account is incomplete; remove it and add it again + Sync now + Removing\u2026 + Lists + Choose which lists sync + Reading lists from the server\u2026 + Stop syncing lists + The lists you unticked stop syncing with this device. Nothing is deleted from the server. + Stop syncing, keep the tasks + They stay on this device as device-only lists. + Stop syncing and delete the tasks + Also deletes those lists and their tasks from this device. + Accounts need Agendula\u2019s own storage + Accounts sync into Agendula\u2019s own store, which isn\u2019t in use while your tasks come from another app. Switch in Settings \u2192 Storage. + Sign-in didn\u2019t finish + Step %1$d of %2$d + Where are your tasks? + Pick the service your task lists live on. Agendula syncs with anything that speaks CalDAV. + You run the server + Doesn\u2019t support tasks over CalDAV + Any CalDAV server + Other CalDAV server + Baïkal, DAViCal, SOGo, Radicale, or one you run yourself + What\u2019s your address there? + The email address you use with this service. Agendula finds the server itself. + Email address + You\u2019re set up + Agendula will keep these lists in sync from now on. You can change what syncs in Settings. + Done + Where are your tasks? + Enter the address of your CalDAV server. An email address works too, if the server publishes itself under its domain. + Sign in + Use an app password if your provider offers one \u2014 it can be revoked without changing your account password. + Pick the task lists to keep in sync. You can change this later. + That doesn\u2019t look like a web address. Try something like cloud.example.com. + That address answered, but it isn\u2019t a CalDAV server. If you pasted the website, try the server address your provider gives for calendars. + That server doesn\u2019t offer calendars. Check the address with your provider. + Couldn\u2019t reach that server. Check the address and your connection. + That address is unencrypted. Agendula only sends your password over https. + Signed in, but this account has no task lists. + The server ran into a problem. Try again in a moment. + Looking for a CalDAV server + Signing in + Reading your task lists + Adding the account + Google Calendar does not support tasks over CalDAV \u2014 its own documentation says so \u2014 so Agendula cannot sync with it. + This server keeps some task lists on %1$s, which Agendula can\u2019t sign in to yet. + That account is already set up. + Signed in, but this account has no task lists Agendula can use. + The account couldn\u2019t be saved. + This device wouldn\u2019t store the password. + That username or password was not accepted. + The approval window closed before the server answered. + The server is turning away repeated attempts. Wait a few minutes and try again. + The server is in maintenance mode. Try again once it is back. + The server didn\u2019t finish signing you in. Try again in a moment. + This device has no browser that can open the sign-in page. Use a password instead. + Fastmail needs an app password, not your account password \u2014 and CalDAV is not on the Basic plan. + iCloud needs an app-specific password, which you create at appleid.apple.com with two-factor on. + You\u2019ll do this in your browser, then come back here to finish. + iCloud needs an app-specific password + + Sign in at appleid.apple.com. + Turn on two-factor authentication if it isn\u2019t already \u2014 Apple only offers app passwords with it on. + Under Sign-In and Security, open App-Specific Passwords and create one. + Come back here and use that password with your iCloud address. + + Tasks kept in iCloud won\u2019t show up in Apple\u2019s Reminders app. + Fastmail needs an app password + + Sign in at fastmail.com. + Open Settings, then Privacy & Security, then Integrations. + Under App Passwords, create one and give it CalDAV access. + Come back here and use that password with your Fastmail address. + + CalDAV isn\u2019t included in Fastmail\u2019s Basic plan. + Google Calendar does not support tasks over CalDAV. + Sign in again to keep syncing + Sign in again + %1$s \u00b7 %2$s + Sync reports + When a sync replaces one of your edits with the server\u2019s copy, or stops trying to sync a task. + %1$s + %1$s \u00b7 %2$s + What the last sync changed + Got it + Retry + Replaced by the server\u2019s newer copy + Deleted on the server while you were editing it + You deleted it here, but the server changed it afterwards + Stopped syncing \u2014 the server keeps refusing this one + Sign-in needed + When an account stops syncing until you sign in again. + Sign in to %1$s again + The server stopped accepting Agendula\u2019s password, so this account has stopped syncing. + Syncing + Syncing tasks + + Server address + https://cloud.example.com + Continue + Start over + + User name + Password + Use an app password if your server offers one — it can be revoked without changing your account password. + Sign in + + Waiting for your browser + Approve Agendula in the browser, then come back here. Your password is never sent to this app — the server issues a separate app password you can revoke at any time. + Use a password instead + Try again + Check this before you sign in + The sign-in page is on your server, not in Agendula \u2014 so it is worth a look before you type your account password there. + Open the sign-in page + That sign-in page is unencrypted (http). Anything you type there, including your account password, travels in the clear. + The server sent us to %1$s, but you typed %2$s. That usually means its overwrite.cli.url setting is wrong. + The server reported its address as %1$s, which this device cannot reach, so %2$s was used instead. Its overwrite.cli.url or trusted_proxies setting is probably wrong. + + Which lists should sync? + Read-only + Shared with you + Add account + Pick at least one list + + An edit was replaced by the server + %1$d edits were replaced by the server + + + A task has stopped syncing + %1$d tasks have stopped syncing + + + 1 sync report to read + %1$d sync reports to read + + + and %1$d more + and %1$d more + + + + Tasks + Today\'s tasks, or any list, with a tap to tick them off + Nothing to do here + Open Agendula to finish setting up + Complete %1$s + Show in widget + Smart lists diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml new file mode 100644 index 0000000..8999c11 --- /dev/null +++ b/app/src/main/res/values/widget_preview_colors.xml @@ -0,0 +1,11 @@ + + + + #FEFBFC + #1F1A1C + #6F6468 + #6A4E5B + #F6D9E5 + #2A101C + diff --git a/app/src/main/res/xml/appwidget_info_tasks.xml b/app/src/main/res/xml/appwidget_info_tasks.xml new file mode 100644 index 0000000..9f4a86c --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_tasks.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/main/res/xml/authenticator.xml new file mode 100644 index 0000000..78a0c92 --- /dev/null +++ b/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 87d1f20..842b22d 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,4 +1,43 @@ - + + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index c6ba7b9..2fbb2ee 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,8 +1,42 @@ + - + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..f8684a7 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/sync_adapter.xml b/app/src/main/res/xml/sync_adapter.xml new file mode 100644 index 0000000..9b67c0b --- /dev/null +++ b/app/src/main/res/xml/sync_adapter.xml @@ -0,0 +1,18 @@ + + + diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt new file mode 100644 index 0000000..b477620 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/export/TaskExporterTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.agendula.data.export + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The export file name. The user picks the folder, so whatever comes out of here + * is what they will be looking at in a file manager a year from now. + */ +class TaskExporterTest { + + private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id) + + @Test + fun `keeps a plain name readable`() { + assertThat(name("Groceries")).isEqualTo("Groceries-3.ics") + } + + @Test + fun `replaces characters a filesystem would reject`() { + // SAF can land on FAT32 (an SD card), where these are simply illegal. + val result = name("Work / Home: notes?") + assertThat(result).doesNotContain("/") + assertThat(result).doesNotContain(":") + assertThat(result).doesNotContain("?") + assertThat(result).endsWith("-3.ics") + } + + @Test + fun `keeps the id so same-named lists cannot collide`() { + // Two accounts may each have a list called "Personal"; without the id one + // export would silently overwrite the other. + assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2)) + } + + @Test + fun `falls back when the name has nothing usable in it`() { + assertThat(name("///")).isEqualTo("list-3.ics") + assertThat(name("")).isEqualTo("list-3.ics") + } + + @Test + fun `does not leave dangling separators`() { + assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics") + } + + @Test + fun `caps the length`() { + // Many filesystems stop at 255 bytes for a name; a pathological list title + // should not be the thing that fails an export. + assertThat(name("x".repeat(500)).length).isAtMost(80) + } + + @Test + fun `keeps non-latin names instead of blanking them`() { + // isLetterOrDigit is Unicode-aware, so these survive rather than collapsing + // to the "list" fallback. + assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics") + assertThat(name("買い物")).isEqualTo("買い物-3.ics") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SettingsVisibilityTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SettingsVisibilityTest.kt new file mode 100644 index 0000000..2950c94 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/prefs/SettingsVisibilityTest.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.agendula.data.prefs + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.testTask +import org.junit.jupiter.api.Test + +class SettingsVisibilityTest { + + private val tasks = listOf(testTask(id = 1, listId = 1), testTask(id = 2, listId = 2)) + private val settings = Settings(hiddenFromSmartLists = setOf(2L)) + + @Test + fun `a smart list leaves out a hidden list`() { + assertThat(settings.visibleIn(TaskFilter.Smart(SmartList.ALL), tasks).map { it.taskId }).containsExactly(1L) + } + + @Test + fun `the hidden list still shows its own tasks`() { + assertThat(settings.visibleIn(TaskFilter.OfList(2), tasks)).isEqualTo(tasks) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt new file mode 100644 index 0000000..f2cd388 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderDiffTest.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ReminderDiffTest { + + private val a = ScheduledReminder(1, 1_000) + private val b = ScheduledReminder(2, 2_000) + private val c = ScheduledReminder(3, 3_000) + + @Test + fun `an armed alarm that is still wanted is left alone`() { + val diff = ReminderDiff.of(previous = setOf(a, b), desired = setOf(a, c)) { true } + assertThat(diff.keep).containsExactly(a) + assertThat(diff.cancel).containsExactly(b) + assertThat(diff.arm).containsExactly(c) + } + + @Test + fun `alarms the system forgot are armed again, although the store still lists them`() { + // A reboot or force-stop: the store came through, the alarms did not. + val diff = ReminderDiff.of(previous = setOf(a, b), desired = setOf(a, b)) { false } + assertThat(diff.arm).containsExactly(a, b) + assertThat(diff.keep).isEmpty() + assertThat(diff.cancel).isEmpty() + } + + @Test + fun `only the missing ones are re-armed`() { + val diff = ReminderDiff.of(previous = setOf(a, b), desired = setOf(a, b)) { it == a } + assertThat(diff.arm).containsExactly(b) + assertThat(diff.keep).containsExactly(a) + } + + @Test + fun `a reminder that already fired is not armed again`() { + val diff = ReminderDiff.of( + previous = setOf(a), + desired = setOf(a), + fired = setOf(a), + ) { false } + assertThat(diff.arm).isEmpty() + assertThat(diff.keep).containsExactly(a) + } + + @Test + fun `store entries round-trip, and junk is skipped`() { + val encoded = ScheduledReminderStore.encode(setOf(a, b)) + assertThat(encoded.mapNotNull(ScheduledReminderStore::decode)).containsExactly(a, b) + val withOccurrence = ScheduledReminder(4, 4_000, occurrenceStart = 3_000) + assertThat(ScheduledReminderStore.encode(setOf(withOccurrence)).map(ScheduledReminderStore::decode)) + .containsExactly(withOccurrence) + // Written before occurrences were recorded. + assertThat(ScheduledReminderStore.decode("4|4000")).isEqualTo(ScheduledReminder(4, 4_000)) + assertThat(ScheduledReminderStore.decode("x|1")).isNull() + assertThat(ScheduledReminderStore.decode("1")).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt new file mode 100644 index 0000000..7e761ba --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/reminders/ReminderPlannerTest.kt @@ -0,0 +1,189 @@ +package de.jeanlucmakiola.agendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.prefs.Settings +import de.jeanlucmakiola.agendula.data.tasks.TaskReminder +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.testTask +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Locale +import kotlin.time.Instant + +class ReminderPlannerTest { + + private val berlin = ZoneId.of("Europe/Berlin") + private val newYork = ZoneId.of("America/New_York") + private val date = LocalDate.of(2026, 7, 20) + + private fun millis(at: LocalDateTime, zone: ZoneId) = at.atZone(zone).toInstant().toEpochMilli() + + @Test + fun `an all-day reminder fires at the configured local time, not UTC midnight`() { + val trigger = ReminderPlanner.triggerAt(allDayInstantOf(date), true, 0, 9 * 60, berlin) + assertThat(trigger).isEqualTo(millis(date.atTime(9, 0), berlin)) + } + + @Test + fun `an all-day reminder stays on its date west of Greenwich`() { + val trigger = ReminderPlanner.triggerAt(allDayInstantOf(date), true, 0, 9 * 60, newYork) + assertThat(trigger).isEqualTo(millis(date.atTime(9, 0), newYork)) + } + + @Test + fun `the lead counts back from the all-day time`() { + val trigger = ReminderPlanner.triggerAt(allDayInstantOf(date), true, 24 * 60, 8 * 60 + 30, berlin) + assertThat(trigger).isEqualTo(millis(date.minusDays(1).atTime(8, 30), berlin)) + } + + @Test + fun `a timed reminder counts back from the instant itself`() { + val due = Instant.fromEpochMilliseconds(1_000_000_000L) + assertThat(ReminderPlanner.triggerAt(due, false, 15, 9 * 60, berlin)) + .isEqualTo(1_000_000_000L - 15 * 60_000L) + } + + @Test + fun `plan uses the all-day setting and skips what is out of the window`() { + val now = millis(date.minusDays(1).atTime(12, 0), berlin) + val allDay = testTask(id = 1, due = allDayInstantOf(date)).copy(isAllDay = true) + val farAway = testTask(id = 2, due = allDayInstantOf(date.plusDays(60))).copy(isAllDay = true) + val plan = ReminderPlanner.plan( + tasks = listOf(allDay, farAway), + perTask = emptyMap(), + settings = Settings(allDayReminderMinuteOfDay = 7 * 60), + now = now, + zone = berlin, + ) + assertThat(plan).containsExactly(ScheduledReminder(1, millis(date.atTime(7, 0), berlin))) + } + + @Test + fun `an all-day task takes the all-day defaults, not the timed ones`() { + val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 7) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = emptyMap(), + settings = Settings( + defaultReminderMinutes = listOf(5), + defaultAllDayReminderMinutes = listOf(0, 24 * 60), + ), + now = millis(date.minusDays(2).atTime(12, 0), berlin), + zone = berlin, + ) + assertThat(plan).containsExactly( + ScheduledReminder(6, millis(date.atTime(9, 0), berlin)), + ScheduledReminder(6, millis(date.minusDays(1).atTime(9, 0), berlin)), + ) + } + + @Test + fun `a list can override the all-day default`() { + val task = testTask(id = 7, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 8) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = emptyMap(), + settings = Settings(perListAllDayReminderOverride = mapOf(8L to emptyList())), + now = millis(date.minusDays(2).atTime(12, 0), berlin), + zone = berlin, + ) + assertThat(plan).isEmpty() + } + + @Test + fun `a start-relative reminder counts back from the start`() { + val start = Instant.fromEpochMilliseconds(millis(date.atTime(10, 0), berlin)) + val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin)) + val task = testTask(id = 3, due = due).copy(start = start) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = mapOf(3L to listOf(TaskReminder(minutesBefore = 30, fromStart = true))), + settings = Settings(), + now = millis(date.atTime(0, 0), berlin), + zone = berlin, + ) + assertThat(plan).containsExactly(ScheduledReminder(3, millis(date.atTime(9, 30), berlin))) + } + + @Test + fun `an all-day due is shown as its own date`() { + val text = formatReminderDue(allDayInstantOf(date).toEpochMilliseconds(), true, newYork, Locale.US) + assertThat(text).isEqualTo("Jul 20, 2026") + } + + private val words = RelativeDays(today = "today", tomorrow = "tomorrow", yesterday = "yesterday") + + private fun relative(at: LocalDateTime, today: LocalDate = date) = + formatReminderDue(millis(at, berlin), false, berlin, Locale.US, is24Hour = true, relative = words, today = today) + + @Test + fun `a due near today reads as a word`() { + assertThat(relative(date.atTime(9, 0))).isEqualTo("today, 09:00") + assertThat(relative(date.plusDays(1).atTime(9, 0))).isEqualTo("tomorrow, 09:00") + assertThat(relative(date.minusDays(1).atTime(9, 0))).isEqualTo("yesterday, 09:00") + } + + @Test + fun `a due later this week reads as its weekday`() { + assertThat(relative(date.plusDays(4).atTime(18, 30))).isEqualTo("Friday, 18:30") + } + + @Test + fun `a due a week out or further back keeps its date`() { + assertThat(relative(date.plusDays(7).atTime(9, 0))).isEqualTo("Jul 27, 2026, 09:00") + assertThat(relative(date.minusDays(2).atTime(9, 0))).isEqualTo("Jul 18, 2026, 09:00") + } + + @Test + fun `an all-day due tomorrow reads as tomorrow in any zone`() { + val text = formatReminderDue( + allDayInstantOf(date.plusDays(1)).toEpochMilliseconds(), true, newYork, Locale.US, + relative = words, today = date, + ) + assertThat(text).isEqualTo("tomorrow") + } + + @Test + fun `every stored reminder of a task is armed`() { + val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin)) + val task = testTask(id = 4, due = due) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = mapOf(4L to listOf(TaskReminder(0), TaskReminder(60))), + settings = Settings(defaultReminderMinutes = listOf(15)), + now = millis(date.atTime(0, 0), berlin), + zone = berlin, + ) + assertThat(plan).containsExactly( + ScheduledReminder(4, millis(date.atTime(18, 0), berlin)), + ScheduledReminder(4, millis(date.atTime(17, 0), berlin)), + ) + } + + @Test + fun `a task without its own reminders gets every default one`() { + val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin)) + val task = testTask(id = 5, due = due) + val plan = ReminderPlanner.plan( + tasks = listOf(task), + perTask = emptyMap(), + settings = Settings(defaultReminderMinutes = listOf(5, 30)), + now = millis(date.atTime(0, 0), berlin), + zone = berlin, + ) + assertThat(plan).containsExactly( + ScheduledReminder(5, millis(date.atTime(17, 55), berlin)), + ScheduledReminder(5, millis(date.atTime(17, 30), berlin)), + ) + } + + @Test + fun `a recurring occurrence carries its anchor`() { + val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin)) + val task = testTask(id = 5, due = due).copy(isRecurring = true, occurrenceStart = due) + val plan = ReminderPlanner.plan(listOf(task), emptyMap(), Settings(), millis(date.atTime(0, 0), berlin), berlin) + assertThat(plan.single().occurrenceStart).isEqualTo(due.toEpochMilliseconds()) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt new file mode 100644 index 0000000..8038433 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/AccountStateStoreTest.kt @@ -0,0 +1,56 @@ +package de.jeanlucmakiola.agendula.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +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.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** The sign-in prompt is posted once per stop, not on every refused run. */ +class AccountStateStoreTest { + + @TempDir lateinit var directory: File + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { File(directory, "state.preferences_pb") } + } + + private val store by lazy { AccountStateStore(dataStore) } + + @AfterEach fun tearDown() = scope.cancel() + + @Test + fun `a stopped account is announced once`() = runTest { + store.setNeedsSignIn(1, true) + + assertThat(store.markSignInNotified(1)).isTrue() + assertThat(store.markSignInNotified(1)).isFalse() + } + + @Test + fun `signing in again makes the next stop news again`() = runTest { + store.setNeedsSignIn(1, true) + store.markSignInNotified(1) + store.setNeedsSignIn(1, false) + store.setNeedsSignIn(1, true) + + assertThat(store.markSignInNotified(1)).isTrue() + } + + @Test + fun `accounts are announced independently`() = runTest { + store.markSignInNotified(1) + + assertThat(store.markSignInNotified(2)).isTrue() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt new file mode 100644 index 0000000..68adee6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionChangeTest.kt @@ -0,0 +1,70 @@ +package de.jeanlucmakiola.agendula.data.sync + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.caldav.TaskCollection +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Test + +/** Choosing which lists sync after setup. */ +class CollectionChangeTest { + + private val work = collection("work") + private val home = collection("home") + private val shared = collection("shared") + + @Test + fun `a newly ticked collection is attached, an already synced one is not`() { + val change = AccountRepository.collectionChange( + offered = listOf(work, home), + selected = setOf(work.url, home.url), + synced = listOf(list(1, work)), + ) + + assertThat(change.attach).containsExactly(home) + assertThat(change.drop).isEmpty() + } + + @Test + fun `an unticked synced list is dropped`() { + val change = AccountRepository.collectionChange( + offered = listOf(work, home), + selected = setOf(work.url), + synced = listOf(list(1, work), list(2, home)), + ) + + assertThat(change.attach).isEmpty() + assertThat(change.drop.map { it.id }).containsExactly(2L) + } + + @Test + fun `a synced list the server did not offer this time is left alone`() { + // A flaky listing or a share revoked a moment ago is not the user + // unticking it. + val change = AccountRepository.collectionChange( + offered = listOf(work), + selected = setOf(work.url), + synced = listOf(list(1, work), list(3, shared)), + ) + + assertThat(change.drop).isEmpty() + } + + private fun collection(name: String) = TaskCollection( + url = "https://cloud.example.com/dav/$name/".toHttpUrl(), + displayName = name, + color = null, + readOnly = false, + isShared = false, + supportsSyncCollection = true, + maxResourceSize = null, + ) + + private fun list(id: Long, collection: TaskCollection) = TaskListEntity( + id = id, + name = collection.displayName!!, + color = 0, + accountId = 1, + href = collection.url.toString(), + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt new file mode 100644 index 0000000..2625130 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSupportStoreTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.agendula.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.caldav.CollectionSupport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** The cache in front of OPTIONS, which exists so a picker can draw at once. */ +class CollectionSupportStoreTest { + + @TempDir lateinit var directory: File + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { File(directory, "support.preferences_pb") } + } + + private val store by lazy { CollectionSupportStore(dataStore) } + + @AfterEach fun tearDown() = scope.cancel() + + @Test + fun `an account nobody has asked about offers nothing`() = runTest { + // Unknown reads as "no": the cost of a missing affordance is one tap to + // get it back, and the cost of the other error is a 405 at the end of a + // form the user has already filled in. + assertThat(store.get(1)).isEqualTo(CollectionSupport.NONE) + } + + @Test + fun `a refreshed answer is the one that comes back`() = runTest { + val answered = store.refresh(1) { CollectionSupport(mkCalendar = false, extendedMkCol = true) } + + assertThat(answered.extendedMkCol).isTrue() + assertThat(store.get(1)).isEqualTo(answered) + } + + @Test + fun `a server that loses the capability says so on the next ask`() = runTest { + store.refresh(1) { CollectionSupport(mkCalendar = true, extendedMkCol = false) } + store.refresh(1) { CollectionSupport.NONE } + + // The whole reason this is a cache rather than the answer: a config + // change on the server has to be able to take the affordance away. + assertThat(store.get(1).canCreate).isFalse() + } + + @Test + fun `one account's answer leaves another's alone`() = runTest { + store.refresh(1) { CollectionSupport(mkCalendar = true, extendedMkCol = false) } + store.refresh(2) { CollectionSupport(mkCalendar = false, extendedMkCol = true) } + store.forget(1) + + assertThat(store.get(1)).isEqualTo(CollectionSupport.NONE) + assertThat(store.get(2).extendedMkCol).isTrue() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt new file mode 100644 index 0000000..ee5a5b7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/CollectionSyncerTest.kt @@ -0,0 +1,990 @@ +package de.jeanlucmakiola.agendula.data.sync + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskMove +import de.jeanlucmakiola.caldav.FetchFailure +import de.jeanlucmakiola.caldav.FetchResult +import de.jeanlucmakiola.caldav.PushSupport +import de.jeanlucmakiola.caldav.PutOutcome +import de.jeanlucmakiola.caldav.RemoteResource +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class CollectionSyncerTest { + + private val store = FakeStore() + private val remote = FakeRemote() + private val quarantine = mutableMapOf() + private val list = TaskListEntity( + id = 1, + name = "Tasks", + color = 0, + accountId = 7, + href = "http://server/dav/tasks/", + ) + + private fun sync(fullDue: Boolean = true) = + CollectionSyncer(store) { NOW }.sync(list, remote, quarantine, fullDue) + + // ------------------------------------------------------------------ push + + @Test fun `push support read from the collection reaches the report`() { + remote.push = PushSupport(topic = "t1", vapidPublicKey = "key") + + val report = sync() + + assertThat(report.collectionRead).isTrue() + assertThat(report.pushSupport).isEqualTo(PushSupport(topic = "t1", vapidPublicKey = "key")) + } + + @Test fun `an unreadable collection says nothing about push`() { + // Not "no support": a failed PROPFIND must not tear down a working + // subscription. + remote.push = PushSupport(topic = "t1", vapidPublicKey = null) + remote.stateFailure = "503" + + val report = sync() + + assertThat(report.collectionRead).isFalse() + assertThat(report.pushSupport).isNull() + } + + // -------------------------------------------------------------- download + + @Test fun `a new remote task is downloaded and is not dirty`() { + remote.put("one.ics", vtodo("a", "Buy milk")) + + val report = sync() + + val row = store.rows.single() + assertThat(row.uid).isEqualTo("a") + assertThat(row.title).isEqualTo("Buy milk") + assertThat(row.href).isEqualTo("http://server/dav/tasks/one.ics") + assertThat(row.etag).isEqualTo("e-one.ics") + // ⚠️ Explicit, not defaulted. A downstream write that leaves this set + // uploads what was just downloaded, and that is how a sync loop starts. + assertThat(row.isDirty).isFalse() + assertThat(report.downloaded).isEqualTo(1) + } + + @Test fun `an unchanged etag is not downloaded again`() { + remote.put("one.ics", vtodo("a", "Buy milk")) + sync() + remote.log.clear() + + sync() + + assertThat(remote.log).containsExactly("LIST") + } + + @Test fun `a changed etag is downloaded`() { + remote.put("one.ics", vtodo("a", "Buy milk")) + sync() + remote.put("one.ics", vtodo("a", "Buy oat milk"), eTag = "e-2") + remote.log.clear() + + sync() + + assertThat(store.rows.single().title).isEqualTo("Buy oat milk") + assertThat(remote.log).contains("FETCH one.ics") + } + + @Test fun `a weak remote etag is never stored`() { + remote.put("one.ics", vtodo("a", "Buy milk")) + remote.resources.values.single().eTag = + de.jeanlucmakiola.caldav.ETag("weak", weak = true) + + sync() + + // Storing it would make the next conditional write look conditional + // while silently not being one. + assertThat(store.rows.single().etag).isNull() + } + + @Test fun `local-only columns survive a download`() { + store.rows += task(id = 5, uid = "a", href = "http://server/dav/tasks/one.ics") + .copy(etag = "old", sortOrder = 42, color = 0xFF00FF00.toInt()) + remote.put("one.ics", vtodo("a", "Renamed"), eTag = "new") + + sync() + + val row = store.rows.single() + assertThat(row.title).isEqualTo("Renamed") + // The server has no opinion about either, so taking the mapper's defaults + // would silently reset the user's ordering and colour on every sync. + assertThat(row.sortOrder).isEqualTo(42) + assertThat(row.color).isEqualTo(0xFF00FF00.toInt()) + assertThat(row.id).isEqualTo(5) + } + + @Test fun `RELATED-TO is resolved to a row id once both rows exist`() { + // The child arrives first, so an eager resolution would lose the link. + remote.put("child.ics", vtodo("child", "Sub", extra = "RELATED-TO;RELTYPE=PARENT:parent")) + remote.put("parent.ics", vtodo("parent", "Top")) + + sync() + + val parent = store.rows.single { it.uid == "parent" } + val child = store.rows.single { it.uid == "child" } + assertThat(child.parentId).isEqualTo(parent.id) + } + + @Test fun `an unreadable body quarantines just that resource`() { + remote.put("bad.ics", "this is not iCalendar") + remote.put("good.ics", vtodo("a", "Fine")) + + val report = sync() + + assertThat(store.rows.map { it.uid }).containsExactly("a") + assertThat(report.quarantined.single().href).endsWith("bad.ics") + assertThat(quarantine.values.single()).isEqualTo(1) + } + + // ---------------------------------------------------------------- upload + + @Test fun `a new local task is created and its href recorded`() { + store.rows += task(id = 1, uid = "a", title = "Write it down").copy(isDirty = true) + + val report = sync() + + assertThat(remote.resources.keys.single()).endsWith("/a.ics") + val row = store.rows.single() + assertThat(row.href).isEqualTo("http://server/dav/tasks/a.ics") + assertThat(row.etag).isEqualTo("e-a.ics") + assertThat(row.isDirty).isFalse() + assertThat(report.uploaded).isEqualTo(1) + } + + @Test fun `an edited task is updated with If-Match`() { + remote.put("one.ics", vtodo("a", "Old")) + store.rows += task(id = 1, uid = "a", title = "New") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDirty = true) + + sync() + + assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics") + assertThat(remote.resources.values.single().body).contains("SUMMARY:New") + } + + @Test fun `a failed validator fetch defers the update instead of writing blind`() { + remote.put("one.ics", vtodo("a", "Server")) + // No local etag: what StoredNeedsRefetch, and any server that serves none, + // leave behind. + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", isDirty = true) + var failNext = true + remote.onFetch = { + if (failNext) { + failNext = false + Result.failure(IllegalStateException("timeout")) + } else { + null + } + } + + val report = sync() + + // Not knowing the validator is not the same as the server having none. + assertThat(remote.log.none { it.startsWith("UPDATE") }).isTrue() + assertThat(remote.resources.values.single().body).contains("SUMMARY:Server") + assertThat(report.unconditionalWrites).isEqualTo(0) + val row = store.rows.single() + assertThat(row.title).isEqualTo("Mine") + assertThat(row.isDirty).isTrue() + assertThat(quarantine).isEmpty() + assertThat(report.quarantined.single().failures).isEqualTo(0) + assertThat(report.quarantined.single().reason).contains("validator fetch failed") + + remote.onFetch = null + sync() + + assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics") + assertThat(remote.resources.values.single().body).contains("SUMMARY:Mine") + assertThat(store.rows.single().isDirty).isFalse() + assertThat(quarantine).isEmpty() + } + + @Test fun `a server with no validator to offer is still written to`() { + remote.put("one.ics", vtodo("a", "Server"), eTag = null) + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", isDirty = true) + + val report = sync() + + // The fetch succeeded and there was simply no validator — the case + // unconditionalWrites exists to count. Deferring here instead would stop + // syncing to every etag-less server. + assertThat(remote.log).contains("UPDATE one.ics if-match=null") + assertThat(report.unconditionalWrites).isEqualTo(1) + assertThat(remote.resources.values.single().body).contains("SUMMARY:Mine") + } + + @Test fun `a PUT that returns no usable etag triggers a refetch`() { + store.rows += task(id = 1, uid = "a", title = "New").copy(isDirty = true) + remote.onPut = { href -> PutOutcome.StoredNeedsRefetch(href) } + + sync() + + // Not an error: the resource is on the server, but without a usable + // validator and possibly not as the bytes we sent. + assertThat(remote.log).contains("FETCH a.ics") + assertThat(store.rows.single().etag).isNull() + } + + @Test fun `a rejected upload quarantines and leaves the row dirty`() { + store.rows += task(id = 1, uid = "a", title = "Bad").copy(isDirty = true) + remote.onPut = { PutOutcome.Rejected(415, "Unsupported Media Type") } + + val report = sync() + + assertThat(report.quarantined.single().reason).contains("415") + // Retrying identical bytes cannot help, but the edit is not thrown away. + assertThat(store.rows.single().isDirty).isTrue() + } + + @Test fun `a quarantined resource is skipped once it hits the threshold`() { + remote.put("one.ics", vtodo("a", "Server")) + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDirty = true) + remote.onPut = { PutOutcome.Rejected(400, "Bad Request") } + + repeat(QuarantineStore.THRESHOLD) { sync() } + remote.log.clear() + sync() + + // ⚠️ A single 400 on one resource has halted all calendar sync in DAVx5 + // for weeks. After the threshold this one is left alone and the rest of + // the collection still runs. + assertThat(remote.log).containsExactly("LIST") + } + + @Test fun `an invalid body never leaves the device`() { + store.rows += task(id = 1, uid = "a", title = "Backwards").copy( + isDirty = true, + dtstart = Instant.parse("2026-03-01T10:00:00Z"), + due = Instant.parse("2026-03-01T09:00:00Z"), + ) + + val report = sync() + + assertThat(remote.log).containsExactly("LIST") + assertThat(report.quarantined.single().reason).contains("DUE precedes DTSTART") + } + + // -------------------------------------------------------------- conflict + + @Test fun `a 412 on update discards the local edit and reports it`() { + remote.put("one.ics", vtodo("a", "Server won"), eTag = "newer") + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", etag = "stale", isDirty = true) + + val report = sync() + + // Decision 2: server wins. The report is the other half of that policy — + // without it this is indistinguishable from data loss. + assertThat(report.discardedEdits.single().cause) + .isEqualTo(DiscardedEdit.Cause.SERVER_NEWER) + assertThat(report.discardedEdits.single().title).isEqualTo("Mine") + assertThat(store.rows.single().title).isEqualTo("Server won") + assertThat(store.rows.single().isDirty).isFalse() + } + + @Test fun `a lost 412 conflict keeps the edit when the replacement never arrives`() { + remote.put("one.ics", vtodo("a", "Server won"), eTag = "newer") + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", etag = "stale", isDirty = true) + // The 412 lands, then the replacement download dies. + remote.resources["http://server/dav/tasks/one.ics"]!!.body = "not iCalendar" + + val report = sync() + + // Nothing was replaced, so nothing was discarded — and the edit is still + // there to try again with. + assertThat(report.discardedEdits).isEmpty() + assertThat(store.rows.single().title).isEqualTo("Mine") + assertThat(store.rows.single().isDirty).isTrue() + } + + @Test fun `an edit to a task deleted on the server loses`() { + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/gone.ics", etag = "e", isDirty = true) + + val report = sync() + + assertThat(store.rows).isEmpty() + assertThat(report.discardedEdits.single().cause) + .isEqualTo(DiscardedEdit.Cause.DELETED_ON_SERVER) + } + + @Test fun `a delete that loses to a server edit is undone`() { + remote.put("one.ics", vtodo("a", "Server changed it"), eTag = "newer") + store.rows += task(id = 1, uid = "a", title = "Mine").copy( + href = "http://server/dav/tasks/one.ics", + etag = "stale", + isDeleted = true, + isDirty = true, + ) + + val report = sync() + + assertThat(report.discardedEdits.single().cause) + .isEqualTo(DiscardedEdit.Cause.DELETE_LOST) + val row = store.rows.single() + assertThat(row.isDeleted).isFalse() + assertThat(row.title).isEqualTo("Server changed it") + } + + // -------------------------------------------------------------- deletion + + @Test fun `a tombstone with an href is deleted on the server`() { + remote.put("one.ics", vtodo("a", "Gone")) + store.rows += task(id = 1, uid = "a") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDeleted = true) + + val report = sync() + + assertThat(remote.resources).isEmpty() + assertThat(store.rows).isEmpty() + assertThat(report.deletedRemotely).isEqualTo(1) + } + + @Test fun `a task deleted before it was ever uploaded is never DELETEd`() { + store.rows += task(id = 1, uid = "a").copy(isDeleted = true, isDirty = true) + + sync() + + // A DELETE here would 404 on every sync, forever. + assertThat(remote.log).containsExactly("LIST") + assertThat(store.rows).isEmpty() + } + + @Test fun `a deleted occurrence is dropped from the store once the PUT lands`() { + remote.put("one.ics", vtodo("a", "Weekly")) + store.rows += task(id = 1, uid = "a", title = "Weekly") + .copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + rrule = "FREQ=WEEKLY", + ) + store.rows += task(id = 2, uid = "a", title = "Weekly") + .copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + masterId = 1, + recurrenceId = NOW, + isDeleted = true, + isDirty = true, + ) + + sync() + + // The occurrence left the body, so the PUT is its deletion. Marking the + // tombstone synced instead strands is_deleted = 1 with is_dirty = 0, + // which no phase can reach and which the unique index on + // (list_id, uid, recurrence_id) turns into a permanent failure to re-add. + assertThat(remote.log.any { it.startsWith("UPDATE one.ics") }).isTrue() + assertThat(remote.log.none { it.startsWith("DELETE") }).isTrue() + assertThat(store.rows.none { it.isDeleted }).isTrue() + assertThat(store.rows.map { it.id }).containsExactly(1L) + // FakeStore models neither the unique index on (list_id, uid, + // recurrence_id) nor the master_id cascade, so the permanent-failure-to- + // re-add consequence is reasoned about, not asserted here. + assertThat(store.rows.single().isDirty).isFalse() + } + + @Test fun `a tombstoned master with a live override keeps its row`() { + remote.put("one.ics", vtodo("a", "Series")) + store.rows += task(id = 1, uid = "a", title = "Series").copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + isDeleted = true, + isDirty = true, + ) + store.rows += task(id = 2, uid = "a", title = "Series").copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + masterId = 1, + recurrenceId = NOW, + ) + + sync() + + // ⚠️ The shape an older version of markDeleted left behind: the master + // tombstoned, its overrides live. Read as rows.all it was "partly + // deleted", so it went to the upload phase, no DELETE was ever sent, and + // it ended is_deleted = 1, is_dirty = 0 with a matching ETag — beyond + // every phase's reach, gone here and still there for everyone else. The + // master's tombstone is the resource's, so the next sync finishes what + // the user asked for. + assertThat(remote.log).contains("DELETE one.ics if-match=e-one.ics") + assertThat(store.rows).isEmpty() + } + + @Test fun `the sweep does not remove what this run just created`() { + store.rows += task(id = 1, uid = "a", title = "Fresh").copy(isDirty = true) + + sync() + + // The listing was taken before the upload, so the new href is not in it. + assertThat(store.rows).hasSize(1) + assertThat(remote.resources).hasSize(1) + } + + @Test fun `an empty listing never sweeps`() { + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e") + store.rows += task(id = 2, uid = "b", title = "Also mine") + .copy(href = "http://server/dav/tasks/two.ics", etag = "e") + + val report = sync() + + // ⚠️ A server that mishandles the VTODO comp-filter answers with an empty + // *successful* multistatus, which is indistinguishable from an empty + // collection. Sweeping on that evidence hard-deletes every task at once. + assertThat(store.rows).hasSize(2) + assertThat(report.deletedLocally).isEqualTo(0) + assertThat(report.failure).contains("listed no tasks") + } + + @Test fun `a resource missing from a non-empty listing is swept`() { + remote.put("one.ics", vtodo("a", "Still there")) + store.rows += task(id = 1, uid = "a") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics") + store.rows += task(id = 2, uid = "b") + .copy(href = "http://server/dav/tasks/two.ics", etag = "e") + + val report = sync() + + assertThat(store.rows.map { it.uid }).containsExactly("a") + assertThat(report.deletedLocally).isEqualTo(1) + } + + @Test fun `a task recreated under a new href is repointed, not swept`() { + // Another client deleted one.ics and recreated the same UID as two.ics. + remote.put("two.ics", vtodo("a", "Buy milk")) + store.rows += task(id = 5, uid = "a", title = "Buy milk") + .copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + sortOrder = 42, + color = 0xFF0000, + ) + + val report = sync() + + // The download re-points row 5; a sweep reading the pre-download list + // would purge it by id and lose the local-only columns for good. + val row = store.rows.single() + assertThat(row.id).isEqualTo(5) + assertThat(row.href).isEqualTo("http://server/dav/tasks/two.ics") + assertThat(row.sortOrder).isEqualTo(42) + assertThat(row.color).isEqualTo(0xFF0000) + assertThat(report.deletedLocally).isEqualTo(0) + assertThat(report.downloaded).isEqualTo(1) + } + + @Test fun `the post-download sweep still removes a stale row`() { + remote.put("one.ics", vtodo("a", "New")) + store.rows += task(id = 1, uid = "b") + .copy(href = "http://server/dav/tasks/gone.ics", etag = "e") + + val report = sync() + + // Reading locals after the download widens what the sweep sees; the row + // just inserted must survive it, the stale one must not. + assertThat(store.rows.map { it.uid }).containsExactly("a") + assertThat(report.deletedLocally).isEqualTo(1) + } + + @Test fun `a resource the server refuses is counted, and the batch survives`() { + remote.put("one.ics", vtodo("a", "Fine")) + remote.put("two.ics", vtodo("b", "Refused")) + remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 403) } + + val report = sync() + + // A refusal on one member says nothing about the others. + assertThat(store.rows.map { it.uid }).containsExactly("a") + assertThat(quarantine.values.single()).isEqualTo(1) + assertThat(report.quarantined.single().failures).isEqualTo(1) + assertThat(report.quarantined.single().reason).contains("403") + } + + @Test fun `a resource the server says is gone is not counted`() { + remote.put("one.ics", vtodo("a", "Fine")) + remote.put("two.ics", vtodo("b", "Gone")) + remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 404) } + + val report = sync() + + // Counting it would quarantine the resource out of the sweep that is + // about to clean it up once the listing drops it. + assertThat(quarantine).isEmpty() + assertThat(report.quarantined.single().failures).isEqualTo(0) + } + + @Test fun `a resource the server fails on is not counted`() { + remote.put("one.ics", vtodo("a", "Fine")) + remote.put("two.ics", vtodo("b", "Broken")) + remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 500) } + + val report = sync() + + // A multiget carries no body of ours, so there is nothing here a server + // could be permanently right to reject. + assertThat(quarantine).isEmpty() + assertThat(report.quarantined.single().failures).isEqualTo(0) + } + + @Test fun `a missing href is not counted when the server also sent strays`() { + remote.put("one.ics", vtodo("a", "Fine")) + remote.onFetch = { hrefs -> + Result.success( + FetchResult( + resources = emptyList(), + missing = hrefs, + failed = emptyList(), + // The shape an href we failed to recognise takes: the same + // path, in both buckets. + unsolicited = listOf("http://elsewhere/dav/tasks/one.ics".toHttpUrl()), + ), + ) + } + + val report = sync() + + // ⚠️ Counting here is irreversible — at THRESHOLD the href is stripped + // before the fetch, so nothing can ever clear it again. + assertThat(quarantine).isEmpty() + assertThat(report.quarantined.single().failures).isEqualTo(0) + } + + @Test fun `a stray for another path does not excuse a missing one`() { + remote.put("one.ics", vtodo("a", "Fine")) + remote.onFetch = { hrefs -> + Result.success( + FetchResult( + resources = emptyList(), + missing = hrefs, + failed = emptyList(), + // Servers volunteer siblings routinely. Excusing every + // missing href on that basis would mean nothing is ever + // counted, and the loop never breaks. + unsolicited = listOf("http://server/dav/tasks/somebody-else.ics".toHttpUrl()), + ), + ) + } + + val report = sync() + + assertThat(quarantine.values.single()).isEqualTo(1) + assertThat(report.quarantined.single().failures).isEqualTo(1) + } + + @Test fun `a missing href is still counted when nothing was unmatched`() { + remote.put("one.ics", vtodo("a", "Fine")) + remote.onFetch = { hrefs -> + Result.success( + FetchResult( + resources = emptyList(), + missing = hrefs, + failed = emptyList(), + unsolicited = emptyList(), + ), + ) + } + + val report = sync() + + // A plain omission repeats identically every run and nothing else breaks + // the loop, which is what the counter is for. + assertThat(quarantine.values.single()).isEqualTo(1) + assertThat(report.quarantined.single().failures).isEqualTo(1) + } + + @Test fun `a resource the server keeps refusing stops being requested`() { + remote.put("two.ics", vtodo("b", "Refused")) + remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 403) } + + repeat(QuarantineStore.THRESHOLD) { sync() } + remote.log.clear() + sync(fullDue = false) + + // The whole point. It is in the listing, so the sweep leaves it alone, + // and it never reaches apply, so without a count it is re-requested on + // every sync for ever. The daily reconciliation still probes it once — + // otherwise nothing on this side could ever clear the count. + assertThat(remote.log).containsExactly("LIST") + } + + @Test fun `a create the server keeps refusing is eventually quarantined`() { + store.rows += task(id = 1, uid = "a", title = "Never acceptable").copy(isDirty = true) + remote.onPut = { PutOutcome.Rejected(415, "Unsupported Media Type") } + + repeat(QuarantineStore.THRESHOLD) { sync() } + remote.log.clear() + sync() + + // A resource with no href yet still has to be countable, or it is re-PUT + // on every sync forever. + assertThat(remote.log).containsExactly("LIST") + assertThat(quarantine.keys.single()).isEqualTo("${list.id}|uid:a") + } + + @Test fun `a successful create clears the counter it accrued as a uid`() { + store.rows += task(id = 1, uid = "a", title = "Flaky").copy(isDirty = true) + remote.onPut = { PutOutcome.Failed("network") } + sync() + assertThat(quarantine).isNotEmpty() + + remote.onPut = null + sync() + + // Both keys, or the uid-keyed one leaks into the store forever. + assertThat(quarantine).isEmpty() + } + + @Test fun `a RELATED-TO removed on the server unparents the task`() { + remote.put("parent.ics", vtodo("parent", "Top")) + remote.put("child.ics", vtodo("child", "Sub", extra = "RELATED-TO;RELTYPE=PARENT:parent")) + sync() + assertThat(store.rows.single { it.uid == "child" }.parentId).isNotNull() + + remote.put("child.ics", vtodo("child", "Sub"), eTag = "e-2") + sync() + + // Carrying the old parent_id forward would re-upload, on the next local + // edit, the relationship the user deleted elsewhere. + assertThat(store.rows.single { it.uid == "child" }.parentId).isNull() + } + + // ---------------------------------------------------------- name clashes + + @Test fun `a name taken by another task gets a fresh one`() { + remote.put("a.ics", vtodo("somebody-else", "Not ours")) + store.rows += task(id = 1, uid = "a", title = "Ours").copy(isDirty = true) + + sync() + + val ours = store.rows.single { it.uid == "a" } + assertThat(ours.href).isNotEqualTo("http://server/dav/tasks/a.ics") + assertThat(ours.href).endsWith(".ics") + assertThat(ours.isDirty).isFalse() + } + + @Test fun `a name taken by the same task is adopted`() { + // A previous run's PUT whose answer we never saw. + remote.put("a.ics", vtodo("a", "Uploaded last time")) + store.rows += task(id = 1, uid = "a", title = "Ours").copy(isDirty = true) + + sync() + + assertThat(remote.resources).hasSize(1) + assertThat(store.rows.single().href).isEqualTo("http://server/dav/tasks/a.ics") + } + + @Test fun `a create whose verification fetch fails is not duplicated`() { + // Our own PUT from a run whose answer never arrived, and an edit the user + // made afterwards that only exists here. + remote.put("a.ics", vtodo("a", "Uploaded last time")) + store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT") + .copy(isDirty = true) + // Only the verification fetch fails. A blanket failure would take the + // download phase's multiget with it and pass for the wrong reason. + var failNext = true + remote.onFetch = { + if (failNext) { + failNext = false + Result.failure(IllegalStateException("timeout")) + } else { + null + } + } + + val report = sync() + + // Renaming on a failed fetch would put one UID in two resources, which + // RFC 4791 §4.1 forbids and which no later sync can clean up. + assertThat(remote.resources).hasSize(1) + assertThat(remote.log.count { it.startsWith("CREATE") }).isEqualTo(1) + // Deferring the write is only worth anything if the read defers too: the + // row has no href, so downloadPhase cannot match it and would otherwise + // overwrite the edit by UID with nothing in discardedEdits. + val row = store.rows.single() + assertThat(row.title).isEqualTo("Edited after the lost PUT") + assertThat(row.isDirty).isTrue() + assertThat(row.href).isNull() + // Deferred, not charged: nothing can refund a UID-keyed count once the + // resource is quarantined out of uploadPhase. + assertThat(quarantine).isEmpty() + assertThat(report.quarantined.single().reason).contains("verification failed") + assertThat(report.quarantined.single().failures).isEqualTo(0) + + // And the next run adopts the resource. + remote.onFetch = null + sync() + + assertThat(remote.resources).hasSize(1) + val adopted = store.rows.single() + assertThat(adopted.href).isEqualTo("http://server/dav/tasks/a.ics") + assertThat(adopted.title).isEqualTo("Edited after the lost PUT") + assertThat(quarantine).isEmpty() + } + + @Test fun `an adoption whose validator fetch fails keeps the local edit`() { + // Create 412s, the first fetch proves the resource is ours, and the + // validator fetch inside the adoption then fails. + remote.put("a.ics", vtodo("a", "Uploaded last time")) + store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT") + .copy(isDirty = true) + var fetches = 0 + remote.onFetch = { + fetches += 1 + if (fetches == 2) Result.failure(IllegalStateException("timeout")) else null + } + + sync() + + // The row still has no href here, so downloadPhase cannot match it and + // only `deferred` keeps apply from overwriting the edit by UID. This is + // the call site that makes updateResource's `deferred` load-bearing. + assertThat(remote.resources).hasSize(1) + val row = store.rows.single() + assertThat(row.title).isEqualTo("Edited after the lost PUT") + assertThat(row.isDirty).isTrue() + assertThat(row.href).isNull() + } + + @Test fun `a refused verification fetch does not rename the create`() { + remote.put("a.ics", vtodo("a", "Uploaded last time")) + store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT") + .copy(isDirty = true) + // A per-resource refusal is a *successful* multiget. Without inspecting + // `failed`, the empty resources list reads as somebody else's resource. + remote.onFetch = { hrefs -> + Result.success( + FetchResult( + resources = emptyList(), + missing = emptyList(), + failed = hrefs.map { FetchFailure(it, 403) }, + unsolicited = emptyList(), + ), + ) + } + + sync() + + assertThat(remote.resources).hasSize(1) + assertThat(remote.log.count { it.startsWith("CREATE") }).isEqualTo(1) + val row = store.rows.single() + assertThat(row.title).isEqualTo("Edited after the lost PUT") + assertThat(row.isDirty).isTrue() + } + + @Test fun `a refused validator fetch does not write unconditionally`() { + remote.put("one.ics", vtodo("a", "Server")) + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", isDirty = true) + remote.onFetch = { hrefs -> + Result.success( + FetchResult( + resources = emptyList(), + missing = emptyList(), + failed = hrefs.map { FetchFailure(it, 500) }, + unsolicited = emptyList(), + ), + ) + } + + val report = sync() + + assertThat(remote.log.none { it.startsWith("UPDATE") }).isTrue() + assertThat(report.unconditionalWrites).isEqualTo(0) + assertThat(remote.resources.values.single().body).contains("SUMMARY:Server") + assertThat(store.rows.single().isDirty).isTrue() + } + + // ------------------------------------------------------------ protection + + @Test fun `a read-only collection is never written to`() { + remote.readOnly = true + store.rows += task(id = 1, uid = "a", title = "Mine").copy(isDirty = true) + + val report = sync() + + assertThat(remote.log).containsExactly("LIST") + assertThat(report.quarantined.single().reason).contains("read-only") + // ⚠️ ACL churn is silent, so the refreshed flag is written back. + assertThat(store.readOnlyWrites.single()).isEqualTo(list.id to true) + } + + @Test fun `a confidential task in a shared collection is not written back`() { + remote.shared = true + remote.put("one.ics", vtodo("a", "Secret")) + store.rows += task(id = 1, uid = "a", title = "Secret").copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + classification = 2, + isDirty = true, + ) + + val report = sync() + + // ⚠️ Nextcloud's CalendarObject::get() serves a whitelist-reduced copy of + // a confidential object from a share while leaving the ETag untouched. + // Writing that back destroys the owner's task, and the ETag matches. + assertThat(remote.log).containsExactly("LIST") + assertThat(report.quarantined.single().reason).contains("reduced copy") + } + + @Test fun `a confidential task the user owns is written normally`() { + remote.shared = false + remote.put("one.ics", vtodo("a", "Secret")) + store.rows += task(id = 1, uid = "a", title = "Secret").copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-one.ics", + classification = 2, + isDirty = true, + ) + + sync() + + assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics") + } + + // -------------------------------------------------------------- failures + + @Test fun `a collection that cannot be listed reports rather than throws`() { + remote.listFailure = "boom" + val report = sync() + assertThat(report.failure).contains("boom") + } + + @Test fun `a collection that is no longer readable reports rather than throws`() { + remote.stateFailure = "revoked" + val report = sync() + assertThat(report.failure).contains("revoked") + } + + @Test fun `a rename into a name another task took keeps both rows`() { + store.rows += task(id = 1, uid = "a", title = "Moved") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-old") + // Fetched in insertion order, so the rename is applied first and leaves + // the index entry for `one.ics` behind. + remote.put("two.ics", vtodo("a", "Moved")) + remote.put("one.ics", vtodo("b", "A different task at the old name")) + + sync() + + // ⚠️ `one.ics` now holds somebody else, and row 1 no longer lives there + // — reading it out of a stale index entry deletes the task the rename + // just repaired. + assertThat(store.rows.map { it.uid }).containsExactly("a", "b") + } + + @Test fun `a full reconciliation probes a resource the download gave up on`() { + remote.put("one.ics", vtodo("a", "Buy milk")) + quarantine[QuarantineStore.key(list.id, "http://server/dav/tasks/one.ics")] = + QuarantineStore.THRESHOLD + + sync() + + // ⚠️ The download side has no refund — at THRESHOLD the href is stripped + // before the fetch, so nothing can ever clear the count again. One probe + // per periodic reconciliation is the expiry. + assertThat(store.rows.single().uid).isEqualTo("a") + assertThat(quarantine).isEmpty() + } + + // ----------------------------------------------------------------- setup + + /** A multiget in which `two.ics` alone comes back refused. */ + private fun refuseTwoIcs(hrefs: List, code: Int): Result { + val refused = hrefs.filter { it.pathSegments.last() == "two.ics" } + val rest = hrefs - refused.toSet() + val served = remote.resources.filterKeys { key -> rest.any { it.toString() == key } } + return Result.success( + FetchResult( + resources = served.map { (href, stored) -> + RemoteResource( + href = href.toHttpUrl(), + eTag = stored.eTag, + iCalendar = stored.body, + ) + }, + missing = emptyList(), + failed = refused.map { FetchFailure(it, code) }, + unsolicited = emptyList(), + ), + ) + } + + // ------------------------------------------------------------------ move + + @Test fun `a task moved to another collection leaves the old one and is created in the new`() { + remote.put("one.ics", vtodo("a", "Milk")) + sync() + val row = store.rows.single() + val other = TaskListEntity(id = 2, name = "Other", color = 0, accountId = 7, href = "http://server/dav/other/") + val otherRemote = FakeRemote("http://server/dav/other/".toHttpUrl()) + + val plan = TaskMove.plan(row.copy(listId = other.id), row, emptyList(), true, null) + plan.update.forEach(store::update) + plan.tombstone?.let(store::insert) + + val syncer = CollectionSyncer(store) { NOW } + syncer.sync(list, remote, quarantine) + syncer.sync(other, otherRemote, quarantine) + + // ⚠️ Keeping the href would have PUT the edit back into the old + // collection, and the new one would never have heard of it. + assertThat(remote.log).contains("DELETE one.ics if-match=e-one.ics") + assertThat(remote.resources).isEmpty() + assertThat(otherRemote.resources).hasSize(1) + val moved = store.rows.single() + assertThat(moved.id).isEqualTo(row.id) + assertThat(moved.listId).isEqualTo(other.id) + assertThat(moved.href).startsWith("http://server/dav/other/") + assertThat(moved.isDirty).isFalse() + } + + @Test fun `a move that lost to a server edit brings the server's copy back`() { + remote.put("one.ics", vtodo("a", "Milk")) + sync() + val row = store.rows.single() + val plan = TaskMove.plan(row.copy(listId = 2), row, emptyList(), true, null) + plan.update.forEach(store::update) + plan.tombstone?.let(store::insert) + remote.put("one.ics", vtodo("a", "Oat milk"), eTag = "e-2") + + val report = sync() + + // The DELETE is conditional, so a newer copy on the server survives and + // is downloaded again rather than destroyed. + assertThat(remote.resources).hasSize(1) + assertThat(store.rowsIn(list.id).single().title).isEqualTo("Oat milk") + assertThat(report.discardedEdits.single().cause).isEqualTo(DiscardedEdit.Cause.DELETE_LOST) + } + + private fun task( + id: Long, + uid: String, + title: String? = null, + href: String? = null, + ) = TaskEntity(id = id, listId = list.id, uid = uid, title = title, href = href) + + private fun vtodo(uid: String, summary: String, extra: String? = null) = buildString { + append("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\n") + append("UID:$uid\r\nSUMMARY:$summary\r\n") + extra?.let { append("$it\r\n") } + append("END:VTODO\r\nEND:VCALENDAR\r\n") + } + + private companion object { + val NOW: Instant = Instant.parse("2026-03-01T12:00:00Z") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt new file mode 100644 index 0000000..1ab9e05 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/IncrementalSyncTest.kt @@ -0,0 +1,376 @@ +package de.jeanlucmakiola.agendula.data.sync + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity +import de.jeanlucmakiola.caldav.ChangeSet +import de.jeanlucmakiola.caldav.ETag +import de.jeanlucmakiola.caldav.RemoteRef +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +/** RFC 6578, from the engine's side. */ +class IncrementalSyncTest { + + private val store = FakeStore() + private val remote = FakeRemote() + private val quarantine = mutableMapOf() + private var list = TaskListEntity( + id = 1, + name = "Tasks", + color = 0, + accountId = 7, + href = "http://server/dav/tasks/", + syncToken = "urn:x:1", + ) + + init { + remote.supportsSyncCollection = true + remote.syncToken = "urn:x:from-propfind" + } + + private fun sync(fullDue: Boolean = false) = + CollectionSyncer(store) { NOW }.sync(list, remote, quarantine, fullDue) + + // ------------------------------------------------------------ the cursor + + @Test fun `the token is stored only after its bodies are applied`() { + remote.put("one.ics", vtodo("a", "New")) + remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2") + + sync() + + assertThat(store.rows.single().title).isEqualTo("New") + assertThat(store.tokenWrites).containsExactly("urn:x:2") + // The full path was not needed, so no listing was taken. + assertThat(remote.log).doesNotContain("LIST") + } + + @Test fun `a truncated page is resumed and each page commits its own token`() { + remote.put("one.ics", vtodo("a", "First")) + remote.put("two.ics", vtodo("b", "Second")) + remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2", truncated = true) + remote.changePages += page(changed = listOf("two.ics"), token = "urn:x:3") + + sync() + + assertThat(store.rows.map { it.uid }).containsExactly("a", "b") + // ⚠️ Per page, in order. A token written ahead of its bodies is a + // permanent hole in the collection the moment the process dies. + assertThat(store.tokenWrites).containsExactly("urn:x:2", "urn:x:3").inOrder() + } + + @Test fun `a server that truncates without advancing its token is not looped on`() { + remote.changePages += page(changed = emptyList(), token = "urn:x:1", truncated = true) + + val report = sync() + + // The RFC never requires the token to advance. The full path then + // reconciled the collection, so this is a note rather than a failure. + assertThat(report.incrementalNote).contains("without advancing") + assertThat(report.failure).isNull() + assertThat(remote.log.count { it.startsWith("CHANGES") }).isEqualTo(1) + } + + @Test fun `a page with no token at all falls back to a full reconciliation`() { + remote.put("one.ics", vtodo("a", "New")) + remote.changePages += page(changed = listOf("one.ics"), token = null) + + val report = sync() + + assertThat(store.rows.single().uid).isEqualTo("a") + assertThat(report.reconciledInFull).isTrue() + assertThat(remote.log).contains("LIST") + } + + // ------------------------------------------------------- invalidation + + @Test fun `an invalidated token is cleared and the run reconciles in full`() { + remote.put("one.ics", vtodo("a", "From the listing")) + remote.changePages += ChangeSet.TokenInvalid + + val report = sync() + + // Cleared first, so a crash before the full run cannot leave behind a + // token we already know the server rejects. + assertThat(store.tokenWrites.first()).isNull() + assertThat(report.reconciledInFull).isTrue() + assertThat(store.rows.single().uid).isEqualTo("a") + // Adopted from the PROPFIND that preceded the listing, never after it. + assertThat(store.tokenWrites.last()).isEqualTo("urn:x:from-propfind") + } + + @Test fun `an unsupported report falls back without clearing the token`() { + remote.put("one.ics", vtodo("a", "From the listing")) + remote.changePages += ChangeSet.Unsupported + + val report = sync() + + assertThat(report.reconciledInFull).isTrue() + assertThat(store.tokenWrites).doesNotContain(null) + } + + // ---------------------------------------------------------- membership + + @Test fun `a removal for an href we never had is a no-op`() { + remote.changePages += page(removed = listOf("stranger.ics"), token = "urn:x:2") + + val report = sync() + + // Created and deleted between two syncs: reported as removed without ever + // having been reported as added. + assertThat(report.failure).isNull() + assertThat(report.deletedLocally).isEqualTo(0) + } + + @Test fun `a removal we do know about deletes the rows`() { + store.rows += task(id = 1, uid = "a") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e") + remote.changePages += page(removed = listOf("one.ics"), token = "urn:x:2") + + val report = sync() + + assertThat(store.rows).isEmpty() + assertThat(report.deletedLocally).isEqualTo(1) + } + + @Test fun `delete-then-recreate at the same href replaces the old task`() { + store.rows += task(id = 1, uid = "old") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-old") + remote.put("one.ics", vtodo("new", "A different task"), eTag = "e-new") + remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2") + + sync() + + // ⚠️ Reported as a *change*, not as a removal plus an addition — so the + // UID in the body is the only thing that says the old task is gone. + assertThat(store.rows.map { it.uid }).containsExactly("new") + } + + @Test fun `a mass removal is refused and reconciled against a listing instead`() { + (1..20).forEach { n -> + store.rows += task(id = n.toLong(), uid = "u$n") + .copy(href = "http://server/dav/tasks/$n.ics", etag = "e") + remote.put("$n.ics", vtodo("u$n", "Task $n")) + } + remote.changePages += page(removed = (1..20).map { "$it.ics" }, token = "urn:x:2") + + val report = sync() + + // ⚠️ ACL churn arrives looking exactly like this. Acting on it deletes + // the user's data on the strength of a change log. + assertThat(report.incrementalNote).contains("removed 20 of 20") + assertThat(report.reconciledInFull).isTrue() + assertThat(store.rows).hasSize(20) + } + + @Test fun `a delete-and-recreate in one page keeps the row's local columns`() { + store.rows += task(id = 1, uid = "a", title = "Old") + .copy( + href = "http://server/dav/tasks/one.ics", + etag = "e-old", + sortOrder = 5, + color = 0x00FF00, + ) + remote.put("two.ics", vtodo("a", "Same task, new file"), eTag = "e-new") + remote.changePages += page( + changed = listOf("two.ics"), + removed = listOf("one.ics"), + token = "urn:x:2", + eTag = "e-new", + ) + + val report = sync() + + // ⚠️ Same UID, new filename. Purging `one.ics` before `two.ics` is + // fetched re-inserts the task with sortOrder 0 and no colour, and + // reports a deletion nobody asked for. + val row = store.rows.single() + assertThat(row.id).isEqualTo(1) + assertThat(row.href).isEqualTo("http://server/dav/tasks/two.ics") + assertThat(row.sortOrder).isEqualTo(5) + assertThat(row.color).isEqualTo(0x00FF00) + assertThat(report.deletedLocally).isEqualTo(0) + } + + @Test fun `an un-uploaded local edit is never downloaded over`() { + remote.readOnly = true + store.rows += task(id = 1, uid = "a", title = "Mine, unsent") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-old", isDirty = true) + remote.put("one.ics", vtodo("a", "Owner's version"), eTag = "e-new") + remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2") + + sync() + + // The upload was refused because the share was demoted, so the row is + // still dirty. Downloading over it destroys the edit with nothing in the + // report to say so. + assertThat(store.rows.single().title).isEqualTo("Mine, unsent") + assertThat(store.rows.single().isDirty).isTrue() + } + + @Test fun `a stale removal never deletes what this run just wrote`() { + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDirty = true) + remote.put("one.ics", vtodo("a", "Server copy")) + // The change log predates our own upload in this same run. + remote.changePages += page(removed = listOf("one.ics"), token = "urn:x:2") + + val report = sync() + + assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics") + assertThat(store.rows).hasSize(1) + assertThat(report.deletedLocally).isEqualTo(0) + } + + @Test fun `a removal that loses a local edit says so`() { + store.rows += task(id = 1, uid = "a", title = "Mine") + .copy(href = "http://server/dav/tasks/gone.ics", etag = "e", isDirty = true) + remote.onPut = { de.jeanlucmakiola.caldav.PutOutcome.Rejected(400, "no") } + remote.changePages += page(removed = listOf("gone.ics"), token = "urn:x:2") + + val report = sync() + + assertThat(store.rows).isEmpty() + assertThat(report.discardedEdits.single().cause) + .isEqualTo(DiscardedEdit.Cause.DELETED_ON_SERVER) + } + + @Test fun `a fallback that succeeds is not reported as a failure`() { + remote.put("one.ics", vtodo("a", "New")) + remote.changePages += ChangeSet.Failed("HTTP 400") + + val report = sync() + + // The collection is reconciled and the user has nothing to act on, so + // this must not surface as "last sync didn't finish". + assertThat(report.failure).isNull() + assertThat(report.incrementalNote).contains("400") + assertThat(report.reconciledInFull).isTrue() + assertThat(store.tokenWrites.last()).isEqualTo("urn:x:from-propfind") + } + + @Test fun `a refetch the change log never mentions still happens`() { + store.rows += task(id = 1, uid = "a", title = "Mine").copy(isDirty = true) + remote.onPut = { href -> de.jeanlucmakiola.caldav.PutOutcome.StoredNeedsRefetch(href) } + remote.put("a.ics", vtodo("a", "Server's canonical form")) + // A server need not echo our own write back to us. + remote.changePages += page(token = "urn:x:2") + + sync() + + // Otherwise the row keeps a null ETag and never gets the canonical body. + assertThat(store.rows.single().title).isEqualTo("Server's canonical form") + } + + @Test fun `a failed download never advances the cursor`() { + remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2") + remote.fetchFailure = "connection reset" + + sync() + + // ⚠️ A cursor committed past bodies that never landed is a permanent hole + // in the collection, invisible until the next full reconciliation. + assertThat(store.tokenWrites).doesNotContain("urn:x:2") + } + + @Test fun `a listed resource the server will not return is counted`() { + remote.changePages += page(changed = listOf("ghost.ics"), token = "urn:x:2") + + val report = sync() + + // In the listing, so the sweep leaves it; never applied, so nothing else + // would ever count it. + assertThat(report.quarantined.single().reason).contains("listed but not returned") + } + + // ------------------------------------------------------------- cadence + + @Test fun `a due full reconciliation ignores the token entirely`() { + remote.put("one.ics", vtodo("a", "New")) + + val report = sync(fullDue = true) + + // The pruned-change-log failure has no signal at all, so the full path is + // a permanent safety net rather than a fallback. + assertThat(remote.log).doesNotContain("CHANGES urn:x:1") + assertThat(report.reconciledInFull).isTrue() + } + + @Test fun `a collection that does not advertise the report is never asked`() { + remote.supportsSyncCollection = false + remote.put("one.ics", vtodo("a", "New")) + + sync() + + assertThat(remote.log.none { it.startsWith("CHANGES") }).isTrue() + } + + @Test fun `an unchanged etag in the change log is not downloaded`() { + store.rows += task(id = 1, uid = "a", title = "Known") + .copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics") + remote.put("one.ics", vtodo("a", "Known")) + remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2", eTag = "e-one.ics") + + sync() + + assertThat(remote.log.none { it.startsWith("FETCH") }).isTrue() + } + + // --------------------------------------------------------------- setup + + @Test fun `a deferred create is not overwritten by the change log`() { + // A previous run's PUT landed but its answer was lost; the user has since + // edited the task, so the row is dirty with no href. + remote.put("a.ics", vtodo("a", "On the server")) + store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT") + .copy(isDirty = true) + remote.changePages += page(changed = listOf("a.ics"), token = "urn:x:2") + var failNext = true + remote.onFetch = { + if (failNext) { + failNext = false + Result.failure(IllegalStateException("timeout")) + } else { + null + } + } + + sync() + + // downloadChanged matches by href too, so a row with none is past its + // dirty-row guard: without honouring `deferred` here, apply matches by + // UID and the edit is gone with nothing in discardedEdits. + val row = store.rows.single() + assertThat(row.title).isEqualTo("Edited after the lost PUT") + assertThat(row.isDirty).isTrue() + assertThat(row.href).isNull() + } + + private fun page( + changed: List = emptyList(), + removed: List = emptyList(), + token: String?, + truncated: Boolean = false, + eTag: String = "e-changed", + ) = ChangeSet.Page( + changed = changed.map { + RemoteRef("http://server/dav/tasks/$it".toHttpUrl(), ETag(eTag, weak = false)) + }, + removed = removed.map { "http://server/dav/tasks/$it".toHttpUrl() }, + token = token, + truncated = truncated, + ) + + private fun task(id: Long, uid: String, title: String? = null) = + TaskEntity(id = id, listId = list.id, uid = uid, title = title) + + private fun vtodo(uid: String, summary: String) = + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:$uid\r\n" + + "SUMMARY:$summary\r\nEND:VTODO\r\nEND:VCALENDAR\r\n" + + private companion object { + val NOW: Instant = Instant.parse("2026-03-01T12:00:00Z") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt new file mode 100644 index 0000000..d75445c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/QuarantineRetryTest.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.agendula.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +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.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.time.Instant + +/** "Retry" on a quarantined task: one resource released, nothing else touched. */ +class QuarantineRetryTest { + + @TempDir lateinit var directory: File + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { File(directory, "sync.preferences_pb") } + } + + private val quarantine by lazy { QuarantineStore(dataStore) } + private val notices by lazy { SyncNoticeStore(dataStore) } + + @AfterEach fun tearDown() = scope.cancel() + + @Test + fun `releasing a resource clears only its count`() = runTest { + quarantine.merge( + updates = mapOf( + QuarantineStore.key(1, BAD) to 3, + QuarantineStore.key(1, OTHER) to 3, + QuarantineStore.key(9, BAD) to 3, + ), + cleared = emptySet(), + ) + + quarantine.release(setOf(1, 2), BAD) + + assertThat(quarantine.counts()).containsExactly( + QuarantineStore.key(1, OTHER), 3, + // Another account's list is not this retry's business. + QuarantineStore.key(9, BAD), 3, + ) + } + + @Test + fun `a retried quarantine notice goes, a discarded edit stays`() = runTest { + notices.record( + accountId = 1, + at = Instant.fromEpochMilliseconds(1), + reports = listOf( + SyncReport( + listId = 1, + listName = "Tasks", + discardedEdits = listOf(DiscardedEdit("u", "Milk", DiscardedEdit.Cause.SERVER_NEWER)), + quarantined = listOf( + QuarantinedResource(BAD, "415", QuarantineStore.THRESHOLD), + QuarantinedResource(OTHER, "415", QuarantineStore.THRESHOLD), + ), + ), + ), + ) + + notices.forgetQuarantined(1, BAD) + + val left = notices.observeAll().first() + assertThat(left.map { it.key }).containsExactly("u", OTHER) + } + + private companion object { + const val BAD = "http://server/dav/tasks/bad.ics" + const val OTHER = "http://server/dav/tasks/other.ics" + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt new file mode 100644 index 0000000..ac3d491 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFailureTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.agendula.data.sync + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.sync.SyncFailure.Kind +import org.junit.jupiter.api.Test + +/** What the accounts screen says about `last_sync_error`, which is written for logs. */ +class SyncFailureTest { + + @Test + fun `a rejected credential asks for the password`() { + assertThat(kind("collection unavailable: at.bitfire.dav4jvm.exception.UnauthorizedException: HTTP 401 Unauthorized")) + .isEqualTo(Kind.SIGN_IN) + assertThat(kind("the server rejected the credentials")).isEqualTo(Kind.SIGN_IN) + } + + @Test + fun `a network failure is unreachable`() { + assertThat(kind("collection unavailable: java.net.UnknownHostException: Unable to resolve host \"cloud\"")) + .isEqualTo(Kind.UNREACHABLE) + assertThat(kind("listing failed: java.net.SocketTimeoutException: timeout")).isEqualTo(Kind.UNREACHABLE) + assertThat(kind("download failed: java.net.ConnectException: Failed to connect")).isEqualTo(Kind.UNREACHABLE) + } + + @Test + fun `a TLS failure is about the certificate`() { + assertThat( + kind("collection unavailable: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException"), + ).isEqualTo(Kind.CERTIFICATE) + } + + @Test + fun `a server error carries its code`() { + val failure = SyncFailure.of("listing failed: at.bitfire.dav4jvm.exception.HttpException: HTTP 500 Internal Server Error") + + assertThat(failure.kind).isEqualTo(Kind.SERVER) + assertThat(failure.httpCode).isEqualTo(500) + } + + @Test + fun `a missing piece of the account is misconfiguration`() { + assertThat(kind("account has no principal URL")).isEqualTo(Kind.MISCONFIGURED) + assertThat(kind("list has no collection URL")).isEqualTo(Kind.MISCONFIGURED) + } + + @Test + fun `anything else is not guessed at`() { + assertThat(kind("the server listed no tasks while 4 are known here — nothing was deleted")) + .isEqualTo(Kind.OTHER) + } + + private fun kind(error: String) = SyncFailure.of(error).kind +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt new file mode 100644 index 0000000..c59b3af --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncFakes.kt @@ -0,0 +1,197 @@ +package de.jeanlucmakiola.agendula.data.sync + +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.caldav.CalendarCollection +import de.jeanlucmakiola.caldav.ChangeSet +import de.jeanlucmakiola.caldav.DeleteOutcome +import de.jeanlucmakiola.caldav.ETag +import de.jeanlucmakiola.caldav.FetchResult +import de.jeanlucmakiola.caldav.PushSupport +import de.jeanlucmakiola.caldav.PutOutcome +import de.jeanlucmakiola.caldav.RemoteCalendar +import de.jeanlucmakiola.caldav.RemoteRef +import de.jeanlucmakiola.caldav.RemoteResource +import de.jeanlucmakiola.caldav.TaskCollection +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl + +/** An in-memory [SyncStore], standing in for Room. */ +class FakeStore(rows: List = emptyList()) : SyncStore { + + val rows = rows.toMutableList() + val readOnlyWrites = mutableListOf>() + + /** In order, so a test can prove the token was stored *after* its bodies. */ + val tokenWrites = mutableListOf() + private var nextId = 1L + + override fun rowsIn(listId: Long) = this.rows.filter { it.listId == listId } + + override fun insert(row: TaskEntity): Long { + // Computed against the rows as they stand, not at construction: tests + // seed `rows` directly after building this, and an id fixed up front + // collides with the first seeded row — which then makes a delete of one + // take the other with it. + nextId = maxOf(nextId, (rows.maxOfOrNull { it.id } ?: 0L) + 1) + val id = nextId++ + rows += row.copy(id = id) + return id + } + + override fun update(row: TaskEntity) { + val index = rows.indexOfFirst { it.id == row.id } + if (index >= 0) rows[index] = row + } + + override fun deleteAll(taskIds: List) { + rows.removeAll { it.id in taskIds } + } + + override fun markSynced(taskIds: List, href: String?, eTag: String?) { + taskIds.forEach { id -> + val index = rows.indexOfFirst { it.id == id } + if (index >= 0) { + rows[index] = rows[index].copy(href = href, etag = eTag, isDirty = false) + } + } + } + + override fun setParent(taskId: Long, parentId: Long?) { + val index = rows.indexOfFirst { it.id == taskId } + if (index >= 0) rows[index] = rows[index].copy(parentId = parentId) + } + + override fun masterByUid(listId: Long, uid: String) = + rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null } + + override fun row(taskId: Long) = rows.firstOrNull { it.id == taskId } + + override fun setListReadOnly(listId: Long, readOnly: Boolean) { + readOnlyWrites += listId to readOnly + } + + override fun setSyncToken(listId: Long, token: String?) { + tokenWrites += token + } +} + +/** An in-memory CalDAV collection that answers like a server rather than a mock. */ +class FakeRemote(override val url: HttpUrl = "http://server/dav/tasks/".toHttpUrl()) : + RemoteCalendar { + + data class Stored(var eTag: ETag?, var body: String) + + val resources = linkedMapOf() + val log = mutableListOf() + + var readOnly = false + var shared = false + var supportsSyncCollection = false + var syncToken: String? = null + var push: PushSupport? = null + + /** Pages the next `changes()` calls will return, in order. */ + val changePages = ArrayDeque() + var stateFailure: String? = null + var listFailure: String? = null + var fetchFailure: String? = null + + /** Returns non-null to pre-empt the default behaviour for that href. */ + var onPut: ((HttpUrl) -> PutOutcome?)? = null + var onDelete: ((HttpUrl) -> DeleteOutcome?)? = null + + /** + * Returns non-null to pre-empt the default behaviour for that fetch. + * + * Unlike [fetchFailure], which breaks every fetch in the run, this can fail + * one and leave the rest working — the difference between "the server is + * down" and "this one request timed out". + */ + var onFetch: ((List) -> Result?)? = null + + fun put(name: String, body: String, eTag: String? = "e-$name") { + resources[url.newBuilder().addPathSegment(name).build().toString()] = + Stored(eTag?.let { ETag(it, weak = false) }, body) + } + + override fun state(): Result { + stateFailure?.let { return Result.failure(IllegalStateException(it)) } + return Result.success( + CalendarCollection.State( + collection = TaskCollection( + url = url, + displayName = "Tasks", + color = null, + readOnly = readOnly, + isShared = shared, + supportsSyncCollection = supportsSyncCollection, + maxResourceSize = null, + push = push, + ), + ctag = "ctag", + syncToken = syncToken, + ), + ) + } + + override fun changes(token: String?): ChangeSet { + log += "CHANGES $token" + return changePages.removeFirstOrNull() + ?: ChangeSet.Failed("the test queued no page for token $token") + } + + override fun list(): Result> { + listFailure?.let { return Result.failure(IllegalStateException(it)) } + log += "LIST" + return Result.success( + resources.map { (href, stored) -> RemoteRef(href.toHttpUrl(), stored.eTag) }, + ) + } + + override fun fetch(hrefs: List): Result { + log += "FETCH ${hrefs.joinToString(",") { it.pathSegments.last() }}" + onFetch?.invoke(hrefs)?.let { return it } + fetchFailure?.let { return Result.failure(IllegalStateException(it)) } + val found = hrefs.mapNotNull { href -> + resources[href.toString()]?.let { RemoteResource(href, it.eTag, it.body) } + } + return Result.success( + FetchResult( + resources = found, + missing = hrefs.filterNot { it.toString() in resources }, + failed = emptyList(), + unsolicited = emptyList(), + ), + ) + } + + override fun create(name: String, iCalendar: String): PutOutcome { + val href = url.newBuilder().addPathSegment(name).build() + log += "CREATE $name" + onPut?.invoke(href)?.let { return it } + if (href.toString() in resources) return PutOutcome.NameTaken(href) + val eTag = ETag("e-$name", weak = false) + resources[href.toString()] = Stored(eTag, iCalendar) + return PutOutcome.Stored(href, eTag) + } + + override fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome { + log += "UPDATE ${href.pathSegments.last()} if-match=$eTag" + onPut?.invoke(href)?.let { return it } + val stored = resources[href.toString()] ?: return PutOutcome.Vanished + if (eTag != null && stored.eTag?.value != eTag) return PutOutcome.ServerNewer + val fresh = ETag("e-${resources.size}-${iCalendar.hashCode()}", weak = false) + stored.eTag = fresh + stored.body = iCalendar + return PutOutcome.Stored(href, fresh) + } + + override fun delete(href: HttpUrl, eTag: String?): DeleteOutcome { + log += "DELETE ${href.pathSegments.last()} if-match=$eTag" + onDelete?.invoke(href)?.let { return it } + val stored = resources[href.toString()] ?: return DeleteOutcome.Deleted + if (eTag != null && stored.eTag?.value != eTag) return DeleteOutcome.ServerNewer + resources.remove(href.toString()) + return DeleteOutcome.Deleted + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt new file mode 100644 index 0000000..8cb577f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/SyncNoticeStoreTest.kt @@ -0,0 +1,190 @@ +package de.jeanlucmakiola.agendula.data.sync + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +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.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.time.Instant + +/** + * The two halves of "tell the user what the sync did". + * + * They are stored differently on purpose — one is news, the other a standing + * condition — and getting that backwards either loses the news to the next quiet + * sync or re-announces the same broken task every four hours for ever. + */ +class SyncNoticeStoreTest { + + @TempDir lateinit var directory: File + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { File(directory, "notices.preferences_pb") } + } + + private val store by lazy { SyncNoticeStore(dataStore) } + + @AfterEach fun tearDown() = scope.cancel() + + @Test + fun `a discarded edit is kept when a later sync finds nothing`() = runTest { + store.record(ACCOUNT, at(1), listOf(report(discarded = listOf(edit("Milk"))))) + store.record(ACCOUNT, at(2), listOf(report())) + + // ⚠️ The edit is gone from the device either way. A clean sync an hour + // later does not make that untrue, and replacing the set would erase the + // one thing worth saying before anyone saw it. + val kept = store.observeAll().first() + assertThat(kept.map { it.subject }).containsExactly("Milk") + } + + @Test + fun `a resource that starts syncing again clears its own notice`() = runTest { + store.record(ACCOUNT, at(1), listOf(report(quarantined = listOf(stuck("a.ics"))))) + store.record(ACCOUNT, at(2), listOf(report())) + + // A standing condition, not news: it is re-reported for as long as it + // holds, so the absence of a report *is* the recovery. + assertThat(store.observeAll().first()).isEmpty() + } + + @Test + fun `a resource still failing is not announced twice`() = runTest { + val first = store.record(ACCOUNT, at(1), listOf(report(quarantined = listOf(stuck("a.ics"))))) + val second = store.record(ACCOUNT, at(2), listOf(report(quarantined = listOf(stuck("a.ics"))))) + + assertThat(first).hasSize(1) + // Announcing it every four hours is how a user learns to ignore the one + // that matters. It stays on the account screen throughout. + assertThat(second).isEmpty() + assertThat(store.observeAll().first()).hasSize(1) + } + + @Test + fun `a resource below the threshold says nothing`() = runTest { + val fresh = store.record( + ACCOUNT, + at(1), + listOf(report(quarantined = listOf(stuck("a.ics", failures = 1)))), + ) + + // Still being retried. "One of your tasks has stopped syncing" would be + // untrue of a single 502 from a proxy mid-restart. + assertThat(fresh).isEmpty() + assertThat(store.observeAll().first()).isEmpty() + } + + @Test + fun `two untitled edits in one run are both reported`() = runTest { + store.record( + ACCOUNT, + at(1), + listOf(report(discarded = listOf(edit(null, "uid-a"), edit(null, "uid-b")))), + ) + + // ⚠️ These are stored as a Set. Two discards from one run share + // an account, a list, a cause and a timestamp, so without the UID in the + // key they encoded identically and one silently vanished — the + // notification counted two and the screen listed one. + assertThat(store.observeAll().first()).hasSize(2) + } + + @Test + fun `two edits with the same title are both reported`() = runTest { + store.record( + ACCOUNT, + at(1), + listOf(report(discarded = listOf(edit("Milk", "uid-a"), edit("Milk", "uid-b")))), + ) + + assertThat(store.observeAll().first()).hasSize(2) + } + + @Test + fun `a quarantined task is named by its title where one is known`() = runTest { + val href = "https://server/dav/tasks/a1f9c3e2.ics" + store.record( + ACCOUNT, + at(1), + listOf(report(quarantined = listOf(stuck("a1f9c3e2.ics")))), + titles = mapOf(href to "Renew the passport"), + ) + + // "A task has stopped syncing" over a row reading `a1f9c3e2.ics` names + // nothing the user can act on — the same objection this file makes to + // showing a UID. + assertThat(store.observeAll().first().single().subject).isEqualTo("Renew the passport") + } + + @Test + fun `a quarantined resource we never stored falls back to its filename`() = runTest { + store.record(ACCOUNT, at(1), listOf(report(quarantined = listOf(stuck("a1f9c3e2.ics"))))) + + // Nothing local to name it by, so the filename is genuinely all there is. + assertThat(store.observeAll().first().single().subject).isEqualTo("a1f9c3e2.ics") + } + + @Test + fun `titles and list names survive whatever characters they contain`() = runTest { + store.record( + ACCOUNT, + at(1), + listOf( + report( + listName = "Work | Home", + discarded = listOf(edit("Pay the bill | urgent")), + ), + ), + ) + + // A list is named by its owner and a task titled by its author, so both + // can hold the separator this store writes with. + val notice = store.observeAll().first().single() + assertThat(notice.subject).isEqualTo("Pay the bill | urgent") + assertThat(notice.listName).isEqualTo("Work | Home") + } + + @Test + fun `one account's notices leave another's alone`() = runTest { + store.record(ACCOUNT, at(1), listOf(report(discarded = listOf(edit("Milk"))))) + store.record(OTHER, at(1), listOf(report(discarded = listOf(edit("Bread"))))) + store.dismiss(ACCOUNT) + + assertThat(store.observeAll().first().map { it.subject }).containsExactly("Bread") + } + + private fun report( + listName: String = "Tasks", + discarded: List = emptyList(), + quarantined: List = emptyList(), + ) = SyncReport( + listId = 1, + listName = listName, + discardedEdits = discarded, + quarantined = quarantined, + ) + + private fun edit(title: String?, uid: String = "uid-$title") = + DiscardedEdit(uid = uid, title = title, cause = DiscardedEdit.Cause.SERVER_NEWER) + + private fun stuck(href: String, failures: Int = QuarantineStore.THRESHOLD) = + QuarantinedResource(href = "https://server/dav/tasks/$href", reason = "415", failures = failures) + + private fun at(seconds: Long) = Instant.fromEpochMilliseconds(seconds * 1_000) + + private companion object { + const val ACCOUNT = 1L + const val OTHER = 2L + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt new file mode 100644 index 0000000..6678fce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/sync/push/PushStoreTest.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.agendula.data.sync.push + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.caldav.PushSupport +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import kotlin.time.Instant + +class PushStoreTest { + + @TempDir lateinit var directory: File + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + + private val dataStore: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { File(directory, "push.preferences_pb") } + } + + private val store by lazy { PushStore(dataStore) } + + @AfterEach fun tearDown() = scope.cancel() + + @Test + fun `support and subscription survive a round trip, separators and all`() = runTest { + // Server-chosen strings: nothing stops a topic or a URL carrying the + // separator the entries are joined with. + val support = PushSupport(topic = "a|b c", vapidPublicKey = "BA1H_x-y") + store.recordSupport(mapOf(1L to support)) + store.recordSubscription( + listId = 1, + subscription = "https://dav.example.com/subs/1?x=|", + endpoint = "https://up.example.net/abc", + expires = Instant.fromEpochSeconds(1_800_000_000), + ) + + assertThat(store.all()[1]).isEqualTo( + PushStore.ListPush( + listId = 1, + support = support, + subscription = "https://dav.example.com/subs/1?x=|", + endpoint = "https://up.example.net/abc", + expires = Instant.fromEpochSeconds(1_800_000_000), + ), + ) + } + + @Test + fun `only a change in support is reported as one`() = runTest { + val support = PushSupport(topic = "t", vapidPublicKey = null) + + assertThat(store.recordSupport(mapOf(1L to support))).isTrue() + assertThat(store.recordSupport(mapOf(1L to support))).isFalse() + // A rotated VAPID key is a change: it needs a new registration. + assertThat(store.recordSupport(mapOf(1L to support.copy(vapidPublicKey = "new")))).isTrue() + // Nothing known, nothing offered: no change either. + assertThat(store.recordSupport(mapOf(2L to null))).isFalse() + } + + @Test + fun `losing support keeps the subscription until it is removed`() = runTest { + store.recordSupport(mapOf(1L to PushSupport(topic = "t", vapidPublicKey = null))) + store.recordSubscription(1, "https://dav/subs/1", "https://up/1", Instant.fromEpochSeconds(1)) + + store.recordSupport(mapOf(1L to null)) + + // Still here, so the registrar can find it and tell the server. + assertThat(store.all()[1]?.subscription).isEqualTo("https://dav/subs/1") + + store.clearSubscription(1) + assertThat(store.all()).doesNotContainKey(1L) + } + + @Test + fun `forgetting a list leaves the others alone`() = runTest { + store.recordSupport( + mapOf( + 1L to PushSupport(topic = "one", vapidPublicKey = null), + 2L to PushSupport(topic = "two", vapidPublicKey = null), + ), + ) + + store.forget(setOf(1L)) + + assertThat(store.all().keys).containsExactly(2L) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt new file mode 100644 index 0000000..c73eb3f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ProviderResolverTest.kt @@ -0,0 +1,198 @@ +package de.jeanlucmakiola.agendula.data.tasks + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * The storage-mode decision, which is the part of [ProviderResolver] with real + * consequences: pick wrong for a returning user and the app opens on an empty + * store where their tasks used to be. + */ +class ProviderResolverTest { + + /** + * A [ProviderEnvironment] with no Android in it. + * + * @param installed authority -> declaring package, i.e. what is on the device. + * @param granted permissions this app currently holds. + */ + private class FakeEnvironment( + val installed: Map = emptyMap(), + val granted: Set = emptySet(), + ) : ProviderEnvironment { + override fun packageDeclaring(authority: String): String? = installed[authority] + override fun isGranted(permission: String): Boolean = permission in granted + override fun appLabel(packageName: String): String? = packageName + } + + private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } + + private fun resolver( + installed: Map = emptyMap(), + granted: Set = emptySet(), + mode: StorageMode? = null, + ) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode } + + private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks") + private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission) + + @Nested + inner class OwnStore { + + @Test + fun `is readable without anything installed or granted`() { + // Guards a real regression: the reminder engine used to gate on + // resolve() != null, which is exactly what OWN returns, so every + // reminder was cleared the moment our own store became the default. + assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue() + } + + @Test + fun `resolves to no provider at all`() { + // Room has no authority and no ContentResolver, so there is nothing + // here to resolve — which is the point. Callers that need to tell this + // apart from "External, none installed" ask mode(). + val resolver = resolver(mode = StorageMode.OWN) + assertThat(resolver.resolve()).isNull() + assertThat(resolver.mode()).isEqualTo(StorageMode.OWN) + } + } + + @Nested + inner class AutoMode { + + @Test + fun `a fresh install with nothing else present gets our own store`() { + assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN) + } + + @Test + fun `an upgrading user who already granted OpenTasks stays on it`() { + // Holding a dangerous permission means a previous version asked and they + // agreed — the signature of an existing Posture A user. Sending them to + // our empty bundled store would read as data loss. + val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `OpenTasks merely installed is not enough`() { + // Someone who has OpenTasks for unrelated reasons, and never granted us + // anything, has no data with us there. Our own store is right for them. + assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN) + } + + @Test + fun `a half-granted external provider does not count`() { + val resolver = resolver( + installed = openTasksInstalled, + granted = setOf(openTasks.readPermission), + ) + assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN) + } + } + + @Nested + inner class ExplicitChoice { + + @Test + fun `overrides the automatic answer in both directions`() { + val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted) + + val forcedOwn = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.OWN } + assertThat(forcedOwn.mode()).isEqualTo(StorageMode.OWN) + assertThat(forcedOwn.resolve()).isNull() + + val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL } + assertThat(forcedExternal.mode()).isEqualTo(StorageMode.EXTERNAL) + assertThat(forcedExternal.resolve()).isNull() + } + + @Test + fun `external with no provider installed resolves to nothing`() { + // Drives the "install a tasks provider" gate rather than silently + // falling back to our own store behind the user's back. + assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull() + } + + @Test + fun `external is unreadable until a provider is installed and granted`() { + assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse() + assertThat( + resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(), + ).isFalse() + assertThat( + resolver( + installed = openTasksInstalled, + granted = openTasksGranted, + mode = StorageMode.EXTERNAL, + ).canReadStore(), + ).isTrue() + } + + @Test + fun `external still requires the runtime permission`() { + val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL) + val provider = resolver.resolve() + assertThat(provider).isNotNull() + assertThat(resolver.hasPermission(provider!!)).isFalse() + } + } + + @Nested + inner class ExternalCandidates { + + @Test + fun `prefer OpenTasks over tasks_org when both are installed`() { + val resolver = resolver( + installed = mapOf( + "org.dmfs.tasks" to "org.dmfs.tasks", + "org.tasks.opentasks" to "org.tasks", + ), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks") + } + + @Test + fun `fall through to tasks_org when OpenTasks is absent`() { + val resolver = resolver( + installed = mapOf("org.tasks.opentasks" to "org.tasks"), + mode = StorageMode.EXTERNAL, + ) + assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks") + } + + @Test + fun `a mode change notifies listeners once, and only on a real change`() { + // What store observers hang off: a live flow is bound to one store, so + // it has to be told when the store underneath it is swapped. + val resolver = resolver(mode = StorageMode.OWN) + var fired = 0 + val handle = resolver.onModeChanged { fired++ } + + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(0) + + resolver.storageMode = StorageMode.EXTERNAL + assertThat(fired).isEqualTo(1) + + handle.close() + resolver.storageMode = StorageMode.OWN + assertThat(fired).isEqualTo(1) + } + + @Test + fun `never name an authority of ours`() { + // EXTERNAL must mean "somebody else's store", and Agendula publishes no + // provider at all any more. + assertThat( + ProviderResolver.EXTERNAL_CANDIDATES.none { + it.authority.startsWith("de.jeanlucmakiola") + }, + ).isTrue() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt index 7d70aa9..781b02a 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskMapperTest.kt @@ -36,7 +36,6 @@ class TaskMapperTest { val task = TaskMapper.task(reader) - assertThat(task.id).isEqualTo(42L) assertThat(task.taskId).isEqualTo(7L) assertThat(task.listId).isEqualTo(3L) assertThat(task.title).isEqualTo("Buy milk") @@ -50,6 +49,65 @@ class TaskMapperTest { assertThat(task.isSubtask).isTrue() } + @Test + fun `an occurrence is identified by its recurrence-id anchor`() { + fun occurrence(columns: Map) = + TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY"))) + + // instance_original_time is the provider's own RECURRENCE-ID and wins. + val anchored = occurrence( + mapOf( + Instances.TASK_ID to 7L, + Instances.INSTANCE_ORIGINAL_TIME to 500L, + Instances.INSTANCE_START to 900L, + ), + ) + assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L) + assertThat(anchored.occurrenceKey).isEqualTo("7@500") + + // Older provider schemas omit it; the occurrence's start reconstructs it. + val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L)) + assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L) + + // A series carrying only DUE anchors on the due date instead. + val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L)) + assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L) + } + + @Test + fun `a non-recurring task has no occurrence anchor and keys by task id`() { + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)), + ) + assertThat(task.occurrenceStart).isNull() + assertThat(task.occurrenceKey).isEqualTo("4") + } + + @Test + fun `recurrence is detected from rrule when is_recurring is absent`() { + // tasks.org's bundled provider is DB 22 and has no `is_recurring` column; + // reading it alone would report the series as one-off and send its edits + // to the master row, re-anchoring the whole thing. + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")), + ) + assertThat(task.isRecurring).isTrue() + } + + @Test + fun `recurrence is detected from rdate alone`() { + val task = TaskMapper.task( + MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")), + ) + assertThat(task.isRecurring).isTrue() + } + + @Test + fun `a plain task is not recurring`() { + val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off"))) + assertThat(task.isRecurring).isFalse() + } + @Test fun `falls back to instance id when task_id missing, and list color when no task color`() { val task = TaskMapper.task( diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt index faa5034..4ec590e 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/TaskWriteMapperTest.kt @@ -85,6 +85,57 @@ class TaskWriteMapperTest { assertThat(values[Tasks.TZ]).isNull() } + @Test + fun `all-day timestamps are pinned to UTC midnight`() { + // 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2). + // The provider resolves all-day dates against UTC, so storing this as-is + // would land the task on the 20th for anyone reading it back. + val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L) + val values = TaskWriteMapper.taskValues( + TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true), + tzId = "Europe/Berlin", + ) + + val dayMs = 24L * 60 * 60 * 1000 + assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L) + assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L) + } + + @Test + fun `timed timestamps are written untouched`() { + val at = Instant.fromEpochMilliseconds(1_784_412_345_678L) + val values = TaskWriteMapper.taskValues( + TaskForm(title = "Standup", listId = 1L, start = at, due = at), + tzId = "Europe/Berlin", + ) + assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L) + assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L) + } + + @Test + fun `duration is always cleared so it cannot collide with due`() { + // The provider validates the *merged* row and throws "Only one of DUE or + // DURATION must be supplied" if the stored row still carries a duration. + val values = TaskWriteMapper.taskValues( + TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)), + tzId = "UTC", + ) + assertThat(values.containsKey(Tasks.DURATION)).isTrue() + assertThat(values[Tasks.DURATION]).isNull() + } + + @Test + fun `instance values drop list and parent, which an override cannot express`() { + val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L)) + val values = TaskWriteMapper.instanceValues(form, tzId = "UTC") + + assertThat(values.containsKey(Tasks.LIST_ID)).isFalse() + assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse() + // …but still carries the edit itself. + assertThat(values[Tasks.TITLE]).isEqualTo("x") + assertThat(values[Tasks.DUE]).isEqualTo(1_000L) + } + @Test fun `completion sets status, percent and timestamp, un-completion clears them`() { val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L) @@ -97,6 +148,22 @@ class TaskWriteMapperTest { assertThat(undone[Tasks.COMPLETED]).isNull() } + @Test + fun `alarm carries every column the provider's validator demands`() { + val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30) + + assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L) + assertThat(values[TasksContract.Properties.MIMETYPE]) + .isEqualTo("vnd.android.cursor.item/alarm") + assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30) + // REFERENCE must be present and non-negative, ALARM_TYPE present and + // non-zero (0 is excluded from the provider's has_alarms count). + assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE) + assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE) + // property_id must be absent or the insert is rejected. + assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse() + } + @Test fun `local list uses the LOCAL account`() { val values = TaskWriteMapper.localListValues("Inbox", 0x123) diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/CalendarResourceTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/CalendarResourceTest.kt new file mode 100644 index 0000000..2db7b61 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/CalendarResourceTest.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.ical.ICalParam +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalProperty +import org.junit.jupiter.api.Test + +class CalendarResourceTest { + + @Test fun `a zoned task carries the VTIMEZONE it references`() { + val todo = todo(ICalParam("TZID", "Europe/Berlin")) + + val text = CalendarResource.serialize(listOf(todo)) + + // ⚠️ RFC 5545 §3.2.19: a TZID without a leading solidus must reference a + // VTIMEZONE in the same object. Omitting it makes the resource malformed, + // and `Prefer: handling=strict` turns malformed into rejected. + assertThat(text).contains("BEGIN:VTIMEZONE") + assertThat(text).contains("TZID:Europe/Berlin") + // Definition before reference, for a server that parses as it streams. + assertThat(text.indexOf("BEGIN:VTIMEZONE")).isLessThan(text.indexOf("BEGIN:VTODO")) + } + + @Test fun `a UTC task carries no timezone at all`() { + val text = CalendarResource.serialize(listOf(todo())) + assertThat(text).doesNotContain("VTIMEZONE") + } + + @Test fun `one definition serves every task that shares a zone`() { + val text = CalendarResource.serialize( + listOf(todo(ICalParam("TZID", "Europe/Berlin")), todo(ICalParam("TZID", "Europe/Berlin"))), + ) + assertThat(text.split("BEGIN:VTIMEZONE")).hasSize(2) + } + + @Test fun `the wrapper is a parseable VCALENDAR`() { + val text = CalendarResource.serialize(listOf(todo())) + val parsed = CalendarResource.parse(text) + + assertThat(parsed).hasSize(1) + assertThat(parsed.single().property("VERSION")!!.value).isEqualTo("2.0") + assertThat(parsed.single().property("PRODID")!!.value) + .isEqualTo(CalendarResource.PRODUCT_ID) + assertThat(CalendarResource.todosIn(parsed)).hasSize(1) + } + + @Test fun `a body that is not a calendar yields no calendars`() { + assertThat(CalendarResource.parse("BEGIN:VCARD\r\nEND:VCARD\r\n")).isEmpty() + } + + @Test fun `an unparseable scalar is never emitted twice`() { + // ⚠️ Every one of these reaches the residue verbatim because it could not + // be parsed, and `write` authors its column form unconditionally. Without + // suppression the resource carries both — and `Prefer: handling=strict` + // means sabre will not quietly repair the duplicate. + val source = javaClass.classLoader!! + .getResourceAsStream("vtodo/malformed-scalars.ics")!! + .readBytes().decodeToString() + val vtodo = CalendarResource.todosIn(CalendarResource.parse(source)).single() + + val written = CalendarResource.serialize( + listOf(VTodoMapper.write(VTodoMapper.read(vtodo).entity)), + ) + + listOf("SEQUENCE", "PRIORITY", "PERCENT-COMPLETE", "CLASS", "STATUS").forEach { name -> + assertThat(Regex("(?m)^$name[;:]").findAll(written).count()) + .isEqualTo(1) + } + // The residue's copy is the one that survives, verbatim. + assertThat(written).contains("SEQUENCE:x") + assertThat(written).contains("PRIORITY:11") + } + + private fun todo(vararg params: ICalParam) = ICalComponent( + name = "VTODO", + properties = listOf( + ICalProperty("UID", emptyList(), "a"), + ICalProperty("DTSTART", params.toList(), "20260301T090000"), + ), + components = emptyList(), + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/ICalCanonical.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/ICalCanonical.kt new file mode 100644 index 0000000..fa35da2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/ICalCanonical.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalValues + +/** + * Reduces a component tree to the multiset of facts a faithful round-trip must + * preserve, so two serialisations can be compared for *meaning* rather than for + * bytes. + * + * Byte-stability is unachievable, and + * asserting it produces a corpus which gets normalised until it tests nothing. + * Six things legitimately differ across a round-trip — `PRODID` must change, + * fold position carries no information, parameter quoting is optional, property + * order within a component is unconstrained, `DTSTAMP` is regenerated, and + * `VTIMEZONE` is re-emitted — so those are enumerated here and **nothing else + * may differ**. In particular the unfolded value octets of every property are + * compared exactly. + */ +object ICalCanonical { + + /** Properties whose value is allowed to differ across a round-trip. */ + private val ALLOWED_TO_DIFFER = setOf("PRODID", "DTSTAMP", "LAST-MODIFIED", "SEQUENCE") + + /** Re-emitted from its own rules rather than preserved verbatim. */ + private val OPAQUE_COMPONENTS = setOf("VTIMEZONE") + + /** + * The one value-level equivalence, and it is an equivalence rather than a + * concession: RFC 5545 section 3.8.1.11 gives a to-do no default `STATUS`, + * and an absent one is universally read as needing action. Normalising + * between the two directions loses nothing, so both are dropped before + * comparison. A `STATUS` that is *not* `NEEDS-ACTION` is compared normally, + * so losing a completion still fails. + */ + private fun isNeutralStatus(name: String, value: String) = + name == "STATUS" && value == "NEEDS-ACTION" + + data class Fact( + val path: String, + val name: String, + val params: Map>, + val value: String, + ) + + fun facts(component: ICalComponent, path: String = ""): List { + if (component.name in OPAQUE_COMPONENTS) return emptyList() + val here = if (path.isEmpty()) component.name else "$path/${component.name}" + val own = component.properties + .filterNot { it.name.uppercase() in ALLOWED_TO_DIFFER } + .filterNot { isNeutralStatus(it.name.uppercase(), it.value.trim()) } + .map { property -> + Fact( + path = here, + name = property.name.uppercase(), + params = property.params + .associate { it.name.uppercase() to it.values } + .toSortedMap() + .toMap(), + // The comparison unit: the unfolded, + // **unescaped** value. `\N` and `\n` are the same escape, and a + // bare comma in a TEXT value is malformed input that we repair + // on write — both are equivalences, not losses. Anything that + // actually changes the value still fails. + value = ICalValues.unescapeText(property.value), + ) + } + return own + component.components.flatMap { facts(it, here) } + } + + /** Facts present in [before] but missing from [after] — i.e. what was lost. */ + fun lost(before: ICalComponent, after: ICalComponent): List { + val remaining = facts(after).toMutableList() + return facts(before).filterNot { remaining.remove(it) } + } + + /** Facts [after] gained that [before] never had. */ + fun invented(before: ICalComponent, after: ICalComponent): List = lost(after, before) +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/ResourceValidatorTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/ResourceValidatorTest.kt new file mode 100644 index 0000000..8db277c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/ResourceValidatorTest.kt @@ -0,0 +1,218 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import org.junit.jupiter.api.Test + +/** + * Every case here is a real sabre rejection reachable from the UI, not a + * defensive check. A 415 is permanent: the row stays dirty and the next sync + * sends the same bytes forever. + */ +class ResourceValidatorTest { + + @Test fun `an ordinary task passes`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + SUMMARY:Buy milk + END:VTODO + """))).isNull() + } + + @Test fun `DUE before DTSTART is refused`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART:20260301T100000Z + DUE:20260301T090000Z + END:VTODO + """))).contains("DUE precedes DTSTART") + } + + @Test fun `equal DUE and DTSTART is allowed`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART:20260301T100000Z + DUE:20260301T100000Z + END:VTODO + """))).isNull() + } + + @Test fun `a value-type mismatch is refused`() { + // Reachable: set an all-day due date on a task that already has a timed + // start. + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART:20260301T100000Z + DUE;VALUE=DATE:20260302 + END:VTODO + """))).contains("disagree on value type") + } + + @Test fun `two DATE values are not a mismatch`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART;VALUE=DATE:20260301 + DUE;VALUE=DATE:20260302 + END:VTODO + """))).isNull() + } + + @Test fun `METHOD makes it a scheduling message`() { + assertThat(reject(""" + BEGIN:VCALENDAR + VERSION:2.0 + METHOD:REQUEST + BEGIN:VTODO + UID:a + END:VTODO + END:VCALENDAR + """)).contains("METHOD") + } + + @Test fun `DUE with DURATION is refused`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART:20260301T100000Z + DUE:20260301T110000Z + DURATION:PT1H + END:VTODO + """))).contains("DUE and DURATION") + } + + @Test fun `two UIDs in one resource are refused`() { + // RFC 4791 §4.1 — this is the constraint that makes forking a conflicting + // edit into the same resource impossible. + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + END:VTODO + BEGIN:VTODO + UID:b + END:VTODO + """))).contains("different UIDs") + } + + @Test fun `overrides sharing a UID are fine`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + RRULE:FREQ=DAILY + END:VTODO + BEGIN:VTODO + UID:a + RECURRENCE-ID:20260302T090000Z + END:VTODO + """))).isNull() + } + + @Test fun `a mixed component type is refused`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + END:VTODO + BEGIN:VEVENT + UID:a + END:VEVENT + """))).contains("VEVENT") + } + + @Test fun `a VTIMEZONE alongside the task is not foreign`() { + assertThat(reject(vcalendar(""" + BEGIN:VTIMEZONE + TZID:Europe/Berlin + END:VTIMEZONE + BEGIN:VTODO + UID:a + END:VTODO + """))).isNull() + } + + @Test fun `a missing UID is refused`() { + assertThat(reject(vcalendar(""" + BEGIN:VTODO + SUMMARY:no identity + END:VTODO + """))).contains("no UID") + } + + @Test fun `an implicit DATE does not falsely disagree`() { + // Two value-type tests that disagree block a legitimate PUT: the mapper + // reads a bare eight-digit value as a DATE, and so must this. + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART:20260301 + DUE;VALUE=DATE:20260302 + END:VTODO + """))).isNull() + } + + @Test fun `an end-relative reminder with nothing to anchor it is refused`() { + // Reachable from the UI: clear the due date on a task that has an + // end-relative reminder. + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + BEGIN:VALARM + TRIGGER;RELATED=END:-PT15M + END:VALARM + END:VTODO + """))).contains("RELATED=END") + } + + @Test fun `a TZID with no definition is refused`() { + // A Windows zone name from another client survives in the residue, and + // `java.time` cannot regenerate a VTIMEZONE for it. + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART;TZID=W. Europe Standard Time:20260301T090000 + END:VTODO + """))).contains("unknown time zone") + } + + @Test fun `a solidus-prefixed TZID needs no local definition`() { + // Thunderbird writes this on every zoned task. §3.2.19 exempts the + // solidus form, and rejecting it would quarantine those tasks forever. + assertThat(reject(vcalendar(""" + BEGIN:VTODO + UID:a + DTSTART;TZID=/mozilla.org/20050126_1/Europe/Berlin:20260301T090000 + END:VTODO + """))).isNull() + } + + @Test fun `a TZID with its definition present is fine`() { + assertThat(reject(vcalendar(""" + BEGIN:VTIMEZONE + TZID:Europe/Berlin + END:VTIMEZONE + BEGIN:VTODO + UID:a + DTSTART;TZID=Europe/Berlin:20260301T090000 + END:VTODO + """))).isNull() + } + + @Test fun `a calendar with no task is refused`() { + assertThat(reject(vcalendar(""))).contains("no VTODO") + } + + private fun reject(text: String): String? = + ResourceValidator.validate(ICalParser.parse(crlf(text)))?.reason + + private fun vcalendar(body: String) = + "BEGIN:VCALENDAR\nVERSION:2.0\n$body\nEND:VCALENDAR" + + /** Indentation-insensitive, so the fixtures can be written inline. */ + private fun crlf(text: String) = text.lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\r\n") + "\r\n" +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt new file mode 100644 index 0000000..9f08ba0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoMapperTest.kt @@ -0,0 +1,386 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class VTodoMapperTest { + + private fun vtodo(vararg lines: String): ICalComponent = + ICalParser.parse((listOf("BEGIN:VTODO", "UID:t-1") + lines + "END:VTODO").joinToString("\r\n")) + + @Nested + inner class Reading { + + @Test + fun `PRIORITY is stored raw, because bucketing on the way in loses it`() { + // Priority.HIGH folds 1-4 into one bucket, so rewriting a server's + // PRIORITY:3 as 1 would be a silent edit on the next write-back. + assertThat(VTodoMapper.read(vtodo("PRIORITY:3")).entity.priority).isEqualTo(3) + } + + @Test + fun `an out-of-range PRIORITY falls back to none`() { + assertThat(VTodoMapper.read(vtodo("PRIORITY:42")).entity.priority).isEqualTo(PRIORITY_NONE) + } + + @Test + fun `an unrecognised STATUS is not flattened to NEEDS-ACTION`() { + val mapped = VTodoMapper.read(vtodo("STATUS:X-DEFERRED")) + assertThat(mapped.entity.status).isEqualTo(TaskStatus.NEEDS_ACTION) + // It survives in the residue instead of being rewritten. + assertThat(mapped.entity.unknownProperties).contains("STATUS:X-DEFERRED") + } + + @Test + fun `RELATED-TO points up - the referencing component is the subordinate one`() { + val mapped = VTodoMapper.read(vtodo("RELATED-TO;RELTYPE=PARENT:parent-uid")) + assertThat(mapped.parentUid).isEqualTo("parent-uid") + } + + @Test + fun `RELTYPE defaults to PARENT when absent`() { + assertThat(VTodoMapper.read(vtodo("RELATED-TO:parent-uid")).parentUid).isEqualTo("parent-uid") + } + + @Test + fun `a RELATED-TO with another RELTYPE is somebody else's relationship`() { + val mapped = VTodoMapper.read(vtodo("RELATED-TO;RELTYPE=SIBLING:other-uid")) + assertThat(mapped.parentUid).isNull() + assertThat(mapped.entity.unknownProperties).contains("RELTYPE=SIBLING") + } + + @Test + fun `a parent link is reported but left in the residue`() { + // TaskEntity holds only a local parent_id, so a parent that is not in + // this store would leave nothing to write back. Keeping the property + // means the relationship survives a parent we have not fetched. + val mapped = VTodoMapper.read( + vtodo("RELATED-TO:parent-uid", "RELATED-TO;RELTYPE=SIBLING:other-uid"), + ) + assertThat(mapped.parentUid).isEqualTo("parent-uid") + assertThat(mapped.entity.unknownProperties).contains("parent-uid") + assertThat(mapped.entity.unknownProperties).contains("other-uid") + } + + @Test + fun `a malformed scalar stays in the residue instead of being clamped`() { + val mapped = VTodoMapper.read( + vtodo("PRIORITY:11", "PERCENT-COMPLETE:150", "SEQUENCE:x"), + ) + assertThat(mapped.entity.priority).isEqualTo(PRIORITY_NONE) + assertThat(mapped.entity.percentComplete).isNull() + assertThat(mapped.entity.sequence).isEqualTo(0) + val residue = mapped.entity.unknownProperties.orEmpty() + assertThat(residue).contains("PRIORITY:11") + assertThat(residue).contains("PERCENT-COMPLETE:150") + assertThat(residue).contains("SEQUENCE:x") + } + + @Test + fun `a mixed value-type pair keeps the odd one out in the residue`() { + // One is_all_day flag cannot author a DATE start and a timed due, and + // flattening both to dates destroys the 09:00. + val mapped = VTodoMapper.read( + vtodo("DTSTART;VALUE=DATE:20260901", "DUE;TZID=Europe/Berlin:20260901T090000"), + ) + assertThat(mapped.entity.isAllDay).isFalse() + assertThat(mapped.entity.unknownProperties).contains("DTSTART;VALUE=DATE:20260901") + } + + @Test + fun `a second timezone stays in the residue rather than being collapsed`() { + val mapped = VTodoMapper.read( + vtodo( + "DTSTART;TZID=Europe/Berlin:20260901T080000", + "DUE;TZID=America/New_York:20260901T140000", + ), + ) + assertThat(mapped.entity.timezone).isEqualTo("Europe/Berlin") + assertThat(mapped.entity.unknownProperties).contains("America/New_York") + } + + @Test + fun `DTSTAMP is dropped, LAST-MODIFIED is kept`() { + val mapped = VTodoMapper.read( + vtodo("DTSTAMP:20260901T120000Z", "LAST-MODIFIED:20260801T090000Z"), + ) + assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("DTSTAMP") + assertThat(mapped.entity.lastModified).isNotNull() + } + + @Test + fun `a missing UID is reported rather than invented`() { + val bare = ICalParser.parse("BEGIN:VTODO\r\nSUMMARY:no uid\r\nEND:VTODO") + assertThat(VTodoMapper.read(bare).uidWasMissing).isTrue() + } + + @Test + fun `a DUE date with no DTSTART still reads as all-day`() { + val mapped = VTodoMapper.read(vtodo("DUE;VALUE=DATE:20260901")) + assertThat(mapped.entity.isAllDay).isTrue() + assertThat(mapped.entity.due).isNotNull() + } + @Test + fun `every EXDATE line reaches the column the expander reads`() { + // ⚠️ Cardinality-many, and Apple Calendar writes one line per + // excluded occurrence. Reading only the first left the rest out of + // expansion, so a deleted occurrence came back in the list and in + // its reminders — while round-tripping to the server perfectly out + // of the residue, which is what made it invisible. + val mapped = VTodoMapper.read( + vtodo( + "DTSTART:20260101T090000Z", + "RRULE:FREQ=DAILY", + "EXDATE:20260102T090000Z", + "EXDATE:20260103T090000Z", + ), + ) + assertThat(mapped.entity.exdate) + .isEqualTo("20260102T090000Z,20260103T090000Z") + } + + @Test + fun `a parameterised EXDATE keeps its parameters by staying in the residue`() { + // Claiming it would drop the TZID for good: the property never + // reaches the residue and write re-emits it naked, so the exclusion + // stops matching the instance it excluded. + val mapped = VTodoMapper.read( + vtodo( + "DTSTART;TZID=Europe/Berlin:20260101T090000", + "RRULE:FREQ=DAILY", + "EXDATE;TZID=Europe/Berlin:20260102T090000", + ), + ) + assertThat(mapped.entity.exdate).isEqualTo("20260102T090000") + assertThat(mapped.entity.unknownProperties) + .contains("EXDATE;TZID=Europe/Berlin:20260102T090000") + } + } + + @Nested + inner class Writing { + + @Test + fun `an unchanged parameterised EXDATE is written once, from the residue`() { + val mapped = VTodoMapper.read( + vtodo( + "DTSTART;TZID=Europe/Berlin:20260101T090000", + "RRULE:FREQ=DAILY", + "EXDATE;TZID=Europe/Berlin:20260102T090000", + ), + ) + + val written = VTodoMapper.write(mapped.entity) + + val exdates = written.properties("EXDATE") + assertThat(exdates).hasSize(1) + assertThat(exdates.single().param("TZID")).isEqualTo("Europe/Berlin") + } + + @Test + fun `a changed EXDATE is authored with the parameters the residue gives up`() { + val mapped = VTodoMapper.read( + vtodo( + "DTSTART;TZID=Europe/Berlin:20260101T090000", + "RRULE:FREQ=DAILY", + "EXDATE;TZID=Europe/Berlin:20260102T090000", + ), + ) + // What deleting a second occurrence leaves behind. + val excepted = mapped.entity.copy( + exdate = "${mapped.entity.exdate},20260103T090000", + ) + + val written = VTodoMapper.write(excepted) + + val exdate = written.properties("EXDATE").single() + assertThat(exdate.value).isEqualTo("20260102T090000,20260103T090000") + assertThat(exdate.param("TZID")).isEqualTo("Europe/Berlin") + } + + @Test + fun `a date-shaped EXDATE we author carries VALUE=DATE`() { + // §3.3.5's default is DATE-TIME, so a bare eight-digit value is + // malformed — a permanent 415 under Prefer: handling=strict. + val entity = entity().copy(isAllDay = true, exdate = "20260102") + val exdate = VTodoMapper.write(entity).properties("EXDATE").single() + assertThat(exdate.param("VALUE")).isEqualTo("DATE") + } + + @Test + fun `SEQUENCE is preserved verbatim and never bumped`() { + // It is the Organizer's revision counter (RFC 5545 section 3.8.7.4); + // a client that increments it confuses scheduling-aware peers. + val entity = entity(sequence = 7) + assertThat(VTodoMapper.write(entity).property("SEQUENCE")?.value).isEqualTo("7") + } + + @Test + fun `COMPLETED is written in UTC, with no TZID and no DATE form`() { + val entity = entity( + completedAt = Instant.parse("2026-09-01T19:00:00Z"), + isAllDay = true, + timezone = "Europe/Berlin", + ) + val completed = VTodoMapper.write(entity).property("COMPLETED") + assertThat(completed?.value).isEqualTo("20260901T190000Z") + assertThat(completed?.params).isEmpty() + } + + @Test + fun `an all-day task writes VALUE=DATE`() { + val entity = entity(due = Instant.parse("2026-09-01T00:00:00Z"), isAllDay = true) + val due = VTodoMapper.write(entity).property("DUE") + assertThat(due?.param("VALUE")).isEqualTo("DATE") + assertThat(due?.value).isEqualTo("20260901") + } + + @Test + fun `an unedited residue property suppresses the column it came from`() { + val entity = entity( + // What read() would have stored: the best-effort reading of the + // unknown-zone value it could not reproduce. + due = Instant.parse("2026-09-10T17:00:00Z"), + unknownProperties = "DUE;TZID=Custom/HQ:20260910T170000\r\n", + ) + val written = VTodoMapper.write(entity) + assertThat(written.properties("DUE")).hasSize(1) + assertThat(written.property("DUE")?.param("TZID")).isEqualTo("Custom/HQ") + } + + @Test + fun `an edited column evicts the stale residue copy`() { + // Without eviction, changing the due date of a task imported with an + // unreproducible stamp would silently do nothing on the server. + val entity = entity( + due = Instant.parse("2026-09-20T17:00:00Z"), + unknownProperties = "DUE;TZID=Custom/HQ:20260910T170000\r\n", + ) + val written = VTodoMapper.write(entity) + assertThat(written.properties("DUE")).hasSize(1) + assertThat(written.property("DUE")?.value).isEqualTo("20260920T170000Z") + } + + @Test + fun `an unreadable STATUS in the residue is not doubled by the default`() { + // STATUS has cardinality one; authoring NEEDS-ACTION next to the + // residue copy produces a resource with two of them. + val entity = entity(unknownProperties = "STATUS:X-DEFERRED\r\n") + val written = VTodoMapper.write(entity) + assertThat(written.properties("STATUS")).hasSize(1) + assertThat(written.property("STATUS")?.value).isEqualTo("X-DEFERRED") + } + + @Test + fun `a locally set status evicts the unreadable one`() { + val entity = entity(unknownProperties = "STATUS:X-DEFERRED\r\n") + .copy(status = TaskStatus.COMPLETED) + val written = VTodoMapper.write(entity) + assertThat(written.properties("STATUS")).hasSize(1) + assertThat(written.property("STATUS")?.value).isEqualTo("COMPLETED") + } + + @Test + fun `a timezone the device cannot resolve never becomes TZID on a UTC value`() { + // RFC 5545 section 3.3.5 forbids TZID on a `...Z` value, and sabre + // answers 415 for it. + val entity = entity(due = Instant.parse("2026-09-05T05:30:00Z"), timezone = "Custom/Nope") + val due = VTodoMapper.write(entity).property("DUE") + assertThat(due?.params).isEmpty() + assertThat(due?.value).isEqualTo("20260905T053000Z") + } + + @Test + fun `PRIORITY 0 is omitted, because 0 means undefined`() { + assertThat(VTodoMapper.write(entity()).property("PRIORITY")).isNull() + } + } + + @Nested + inner class Validation { + + @Test + fun `DUE before DTSTART is caught here, not as a 415 from sabre`() { + val problems = VTodoMapper.validate( + vtodo("DTSTART:20260910T100000Z", "DUE:20260901T100000Z"), + ) + assertThat(problems).contains("DUE precedes DTSTART") + } + + @Test + fun `DTSTART and DUE must agree on value type`() { + val problems = VTodoMapper.validate( + vtodo("DTSTART;VALUE=DATE:20260901", "DUE:20260910T100000Z"), + ) + assertThat(problems).contains("DTSTART and DUE disagree on value type") + } + + @Test + fun `an end-relative alarm with no DUE is invalid, and reachable from the UI`() { + // Clearing the due date on a task that has an end-relative reminder is + // an ordinary UI action that produces a permanently rejected resource. + val withAlarm = ICalParser.parse( + """ + BEGIN:VTODO + UID:t-1 + SUMMARY:x + BEGIN:VALARM + ACTION:DISPLAY + TRIGGER;RELATED=END:-PT15M + END:VALARM + END:VTODO + """.trimIndent(), + ) + assertThat(VTodoMapper.validate(withAlarm)) + .contains("TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION") + } + + @Test + fun `METHOD is rejected, because sabre answers 415 for it`() { + assertThat(VTodoMapper.validate(vtodo("METHOD:REQUEST"))) + .contains("METHOD is not allowed on a stored resource") + } + + @Test + fun `an implicit DATE is recognised as one, so value types do not falsely disagree`() { + // A bare 8-digit value with no T is a DATE whether or not VALUE=DATE + // says so. Two different date tests would block a legitimate PUT. + assertThat( + VTodoMapper.validate(vtodo("DTSTART:20260101", "DUE;VALUE=DATE:20260102")), + ).isEmpty() + } + + @Test + fun `a well-formed task has no problems`() { + assertThat( + VTodoMapper.validate(vtodo("DTSTART:20260901T100000Z", "DUE:20260910T100000Z")), + ).isEmpty() + } + } + + private fun entity( + sequence: Int = 0, + completedAt: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, + timezone: String? = null, + unknownProperties: String? = null, + ) = TaskEntity( + listId = 1L, + uid = "t-1", + title = "A task", + sequence = sequence, + completedAt = completedAt, + due = due, + isAllDay = isAllDay, + timezone = timezone, + unknownProperties = unknownProperties, + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt new file mode 100644 index 0000000..48b0895 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/ical/VTodoRoundTripTest.kt @@ -0,0 +1,161 @@ +package de.jeanlucmakiola.agendula.data.tasks.ical + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.ical.ICalComponent +import de.jeanlucmakiola.agendula.domain.ical.ICalParser +import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestFactory +import kotlin.time.Instant + +/** + * The corpus in `app/src/test/resources/vtodo/` is the mapper's specification. + * + * The requirement being encoded is RFC 5545 §3.1: *"Applications MUST preserve + * the value data for x-name and iana-token values that they don't recognize."* + * Failing it destroys other people's data invisibly — invisible in our own UI + * precisely because we are the client that does not understand the property. + */ +class VTodoRoundTripTest { + + private val fixtures = listOf( + "nextcloud-shared-confidential", + "recurring-master-overrides", + "valarm-unknown-nested", + "unknown-component", + "unknown-tzid", + "uid-special-chars", + "rrule-due-no-dtstart", + "moz-alarm-props", + "apple-sort-order", + "floating-time", + "completion-model-a", + "completion-model-b", + "completion-model-c", + "completion-model-d", + "quoted-param-colon", + "folded-utf8", + "mixed-value-types", + "malformed-scalars", + "two-timezones", + ) + + @TestFactory + fun `every fixture round-trips without losing a property`(): List = + fixtures.map { name -> + DynamicTest.dynamicTest(name) { + val todos = vtodosOf(name) + assertThat(todos).isNotEmpty() + todos.forEach { original -> + val roundTripped = roundTrip(original) + assertThat(ICalCanonical.lost(original, roundTripped)).isEmpty() + assertThat(ICalCanonical.invented(original, roundTripped)).isEmpty() + } + } + } + + @Test + fun `an unknown property nested inside a VALARM survives`() { + val original = vtodosOf("valarm-unknown-nested").single() + val roundTripped = roundTrip(original) + + val alarm = roundTripped.components("VALARM").single() + assertThat(alarm.property("X-WR-ALARMUID")?.value) + .isEqualTo("8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11") + // RFC 9074's alarm properties are what other clients now write; a flat + // property-per-row model would have dropped this one with the nesting. + assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z") + } + + @Test + fun `an entirely unknown sub-component survives`() { + val roundTripped = roundTrip(vtodosOf("unknown-component").single()) + val vendor = roundTripped.components("X-VENDOR-METADATA").single() + assertThat(vendor.property("X-VENDOR-KEY")?.value).isEqualTo("project-alpha") + } + + @Test + fun `a TZID the device does not know is re-emitted verbatim, not guessed`() { + val original = vtodosOf("unknown-tzid").single() + val mapped = VTodoMapper.read(original) + val roundTripped = roundTrip(original) + + val due = roundTripped.property("DUE") + assertThat(due?.param("TZID")).isEqualTo("Custom/Company-HQ") + assertThat(due?.value).isEqualTo("20260910T170000") + // Exactly one DUE: the residue owns it, so the mapper must not also + // author one from its column. + assertThat(roundTripped.properties("DUE")).hasSize(1) + // The column still gets a best-effort instant so the UI has something. + assertThat(mapped.entity.due).isNotNull() + } + + @Test + fun `a floating time is re-emitted verbatim, not pinned to a zone`() { + val roundTripped = roundTrip(vtodosOf("floating-time").single()) + assertThat(roundTripped.property("DTSTART")?.value).isEqualTo("20260905T070000") + assertThat(roundTripped.property("DTSTART")?.params).isEmpty() + assertThat(roundTripped.properties("DTSTART")).hasSize(1) + } + + @Test + fun `X-MOZ alarm bookkeeping survives, because dropping it causes alarm storms`() { + val roundTripped = roundTrip(vtodosOf("moz-alarm-props").single()) + assertThat(roundTripped.property("X-MOZ-LASTACK")?.value).isEqualTo("20260904T090000Z") + assertThat(roundTripped.property("X-MOZ-SNOOZE-TIME")?.value).isEqualTo("20260905T093000Z") + } + + @Test + fun `X-APPLE-SORT-ORDER is preserved and never interpreted`() { + val original = vtodosOf("apple-sort-order").single() + val mapped = VTodoMapper.read(original) + assertThat(roundTrip(original).property("X-APPLE-SORT-ORDER")?.value).isEqualTo("734829163") + // Ours is local ordering and has nothing to do with Apple's. + assertThat(mapped.entity.sortOrder).isEqualTo(0) + } + + @Test + fun `a quoted parameter containing a colon and a comma survives`() { + val roundTripped = roundTrip(vtodosOf("quoted-param-colon").single()) + val attendee = roundTripped.property("ATTENDEE") + assertThat(attendee?.param("CN")).isEqualTo("Smith, J:r") + assertThat(attendee?.value).isEqualTo("mailto:jr@example.com") + // And it is re-quoted on the way out, or nothing can read it back. + assertThat(ICalSerializer.render(attendee!!)).contains("CN=\"Smith, J:r\"") + } + + @Test + fun `residue past the size cap is dropped loudly rather than truncated`() { + val huge = "X-BULK:" + "a".repeat(VTodoMapper.MAX_RESIDUE_BYTES + 1) + val vtodo = ICalParser.parse( + "BEGIN:VTODO\r\nUID:big-001\r\nSUMMARY:Big\r\n$huge\r\nEND:VTODO\r\n", + ) + val mapped = VTodoMapper.read(vtodo) + + assertThat(mapped.droppedResidue).isTrue() + assertThat(mapped.entity.unknownProperties).isNull() + } + + private fun roundTrip(vtodo: ICalComponent): ICalComponent { + val mapped = VTodoMapper.read(vtodo) + val written = VTodoMapper.write( + entity = mapped.entity, + parentUid = mapped.parentUid, + now = Instant.fromEpochMilliseconds(0), + ) + // Serialise and re-parse rather than comparing the tree directly: folding + // and parameter quoting are where a serialiser loses data, and skipping + // the text would skip exactly that. + return ICalParser.parse( + "BEGIN:VCALENDAR\r\n" + ICalSerializer.serialize(written) + "END:VCALENDAR\r\n", + ).components("VTODO").single() + } + + private fun vtodosOf(name: String): List { + val text = checkNotNull(javaClass.getResourceAsStream("/vtodo/$name.ics")) { + "missing fixture $name.ics" + }.bufferedReader().readText() + return ICalParser.parse(text).components("VTODO") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt new file mode 100644 index 0000000..2ef8067 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.priorityFromICal +import de.jeanlucmakiola.agendula.domain.toICal +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ConvertersTest { + + @Test + fun `round-trips an instant through epoch millis`() { + val value = Instant.fromEpochMilliseconds(1_700_000_000_123) + + val stored = Converters.instantToMillis(value) + + assertThat(stored).isEqualTo(1_700_000_000_123) + assertThat(Converters.instantFromMillis(stored)).isEqualTo(value) + } + + @Test + fun `maps null time both ways`() { + assertThat(Converters.instantToMillis(null)).isNull() + assertThat(Converters.instantFromMillis(null)).isNull() + } + + @Test + fun `round-trips every status through the domain encoding`() { + TaskStatus.entries.forEach { status -> + assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status) + } + } + + @Test + fun `priority is stored raw, so an off-bucket value survives`() { + // There is no priority converter on purpose: PRIORITY:3 is a legitimate + // value a server can send, and Priority buckets 1..4 into HIGH. Bucketing + // on the way in would rewrite it as 1 and lose it on the next round-trip. + assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH) + assertThat(Priority.HIGH.toICal()).isEqualTo(1) + } + + @Test + fun `round-trips an alarm reference and falls back on an unknown one`() { + AlarmReference.entries.forEach { reference -> + assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference))) + .isEqualTo(reference) + } + assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt new file mode 100644 index 0000000..ea54286 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class RoomTaskMapperTest { + + @Test + fun `a task in a read-only share says so`() { + val row = TaskRow( + task = TaskEntity(id = 1, listId = 2, uid = "u"), + listName = "Shared", + listColor = 0, + accountDisplayName = "me@host", + listReadOnly = true, + ) + + assertThat(RoomTaskMapper.task(row).isReadOnly).isTrue() + } + + @Test + fun `a read-only list does not accept writes`() { + val list = TaskListRow( + list = TaskListEntity(id = 2, name = "Shared", color = 0, accountId = 1, isReadOnly = true), + accountDisplayName = "me@host", + ) + + assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterSeriesTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterSeriesTest.kt new file mode 100644 index 0000000..cdcc154 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterSeriesTest.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.TaskForm +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class TaskFormWriterSeriesTest { + + private val now = Instant.parse("2026-09-23T10:00:00Z") + private val anchor = Instant.parse("2026-09-01T09:00:00Z") + private val master = TaskEntity( + id = 7, + listId = 1, + uid = "series", + title = "Water plants", + dtstart = anchor, + due = anchor + 1.hours, + rrule = "FREQ=DAILY;COUNT=30", + timezone = "Europe/Berlin", + ) + + @Test + fun `ending a series before an occurrence swaps COUNT for UNTIL`() { + val ended = TaskFormWriter.endedBefore(master, anchor + 10.days, now, "Europe/Berlin") + + assertThat(ended.rrule).isEqualTo("FREQ=DAILY;UNTIL=20260911T085959Z") + assertThat(ended.isDirty).isTrue() + } + + @Test + fun `an all-day series ends on the day before, as a DATE`() { + val allDay = master.copy(isAllDay = true, dtstart = Instant.parse("2026-09-01T00:00:00Z"), due = null) + + val ended = TaskFormWriter.endedBefore(allDay, Instant.parse("2026-09-11T00:00:00Z"), now, "Europe/Berlin") + + assertThat(ended.rrule).isEqualTo("FREQ=DAILY;UNTIL=20260910") + } + + @Test + fun `the continuing series only counts what is left`() { + assertThat(TaskFormWriter.remainingCount("FREQ=DAILY;COUNT=30", 10)).isEqualTo("FREQ=DAILY;COUNT=20") + assertThat(TaskFormWriter.remainingCount("FREQ=WEEKLY", 10)).isEqualTo("FREQ=WEEKLY") + } + + @Test + fun `moving one occurrence for the whole series moves the anchor by as much`() { + val occurrenceStart = anchor + 5.days + val form = TaskForm( + title = "Water all plants", + listId = 1, + start = occurrenceStart + 2.hours, + due = occurrenceStart + 3.hours, + rrule = master.rrule, + ) + + val edited = TaskFormWriter.seriesEdit(master, occurrenceStart, occurrenceStart + 1.hours, form, now, "Europe/Berlin") + + assertThat(edited.title).isEqualTo("Water all plants") + assertThat(edited.dtstart).isEqualTo(anchor + 2.hours) + assertThat(edited.due).isEqualTo(anchor + 3.hours) + assertThat(edited.rrule).isEqualTo(master.rrule) + } + + @Test + fun `dropping the rule ends the series and its exceptions`() { + val withExceptions = master.copy(exdate = "20260905T090000Z") + + val plain = TaskFormWriter.withRule(withExceptions, null) + + assertThat(plain.rrule).isNull() + assertThat(plain.exdate).isNull() + } + + @Test + fun `an override never takes a rule`() { + val override = master.copy(id = 9, masterId = 7, rrule = null) + + assertThat(TaskFormWriter.withRule(override, "FREQ=DAILY").rrule).isNull() + } + + @Test + fun `a floating series ends on a floating UNTIL and drops later RDATEs`() { + val floating = master.copy(timezone = null, rdate = "20260905T110000,20260920T110000") + + val ended = TaskFormWriter.endedBefore(floating, Instant.parse("2026-09-11T07:00:00Z"), now, "Europe/Berlin") + + assertThat(ended.rrule).isEqualTo("FREQ=DAILY;UNTIL=20260911T085959") + assertThat(ended.rdate).isEqualTo("20260905T110000") + } + + @Test + fun `a series moved in time takes its exceptions along`() { + val withException = master.copy(exdate = "20260905T090000Z") + val occurrenceStart = anchor + 1.days + val form = TaskForm( + title = master.title!!, + listId = 1, + start = occurrenceStart + 1.hours, + due = occurrenceStart + 2.hours, + rrule = master.rrule, + ) + + val edited = TaskFormWriter.seriesEdit(withException, occurrenceStart, occurrenceStart + 1.hours, form, now, "Europe/Berlin") + + assertThat(edited.exdate).isEqualTo("20260905T100000Z") + } + + @Test + fun `editing the series through a completed occurrence leaves the series open`() { + val occurrenceStart = anchor + 1.days + val form = TaskForm(title = "x", listId = 1, start = occurrenceStart, due = occurrenceStart + 1.hours, percentComplete = 100, rrule = master.rrule) + + val edited = TaskFormWriter.seriesEdit(master, occurrenceStart, occurrenceStart + 1.hours, form, now, "Europe/Berlin") + + assertThat(edited.status).isEqualTo(master.status) + assertThat(edited.completedAt).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt new file mode 100644 index 0000000..7a1ed8b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskFormWriterTest.kt @@ -0,0 +1,218 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskForm +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +private val NOW = Instant.fromEpochMilliseconds(1_768_467_600_000) +private const val ZONE = "Europe/Berlin" + +private fun task( + status: TaskStatus = TaskStatus.NEEDS_ACTION, + percentComplete: Int? = null, + completedAt: Instant? = null, +) = TaskEntity( + id = 1, + listId = 1, + uid = "uid-1", + status = status, + percentComplete = percentComplete, + completedAt = completedAt, +) + +private fun form( + title: String = "task", + percentComplete: Int? = null, + start: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, +) = TaskForm( + title = title, + listId = 1, + percentComplete = percentComplete, + start = start, + due = due, + isAllDay = isAllDay, +) + +class TaskFormWriterTest { + + @Test + fun `mints the task with its uid and creation time`() { + val entity = TaskFormWriter.newTask(form(title = " Buy milk "), "uid-9", NOW, ZONE) + + assertThat(entity.uid).isEqualTo("uid-9") + assertThat(entity.title).isEqualTo("Buy milk") + assertThat(entity.createdAt).isEqualTo(NOW) + assertThat(entity.lastModified).isEqualTo(NOW) + assertThat(entity.isDirty).isTrue() + } + + @Test + fun `progress and status move together in both directions`() { + assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status) + .isEqualTo(TaskStatus.COMPLETED) + assertThat(TaskFormWriter.apply(task(), form(percentComplete = 40), NOW, ZONE).status) + .isEqualTo(TaskStatus.IN_PROCESS) + assertThat(TaskFormWriter.apply(task(), form(percentComplete = 0), NOW, ZONE).status) + .isEqualTo(TaskStatus.NEEDS_ACTION) + } + + @Test + fun `dropping below 100 percent reopens the task`() { + // The provider auto-completed at 100% but would not reopen below it, which + // stranded a task "done at 75%". TaskWriteMapper works around that for + // External mode; on our own store the rule is simply symmetric. + val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW) + + val reopened = TaskFormWriter.apply(completed, form(percentComplete = 75), NOW, ZONE) + + assertThat(reopened.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(reopened.completedAt).isNull() + } + + @Test + fun `a form with no percent leaves the completion state alone`() { + val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW) + + val saved = TaskFormWriter.apply(completed, form(title = "renamed"), NOW, ZONE) + + assertThat(saved.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(saved.completedAt).isEqualTo(NOW) + } + + @Test + fun `re-saving a finished task keeps its original completion time`() { + val earlier = Instant.fromEpochMilliseconds(1_000_000) + val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier) + + val saved = TaskFormWriter.apply(completed, form(percentComplete = 100), NOW, ZONE) + + assertThat(saved.completedAt).isEqualTo(earlier) + } + + @Test + fun `all-day times are pinned to UTC midnight`() { + // Date-only in iCalendar. Storing a local-midnight instant would land on the + // previous day for anyone west of UTC. + val midMorning = Instant.fromEpochMilliseconds(1_768_467_600_000) + + val saved = TaskFormWriter.apply( + task(), + form(start = midMorning, due = midMorning, isAllDay = true), + NOW, + ZONE, + ) + + assertThat(saved.dtstart!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0) + assertThat(saved.due!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0) + assertThat(saved.timezone).isNull() + } + + @Test + fun `a timed task records the zone, an undated one does not`() { + val timed = TaskFormWriter.apply(task(), form(due = NOW), NOW, ZONE) + assertThat(timed.timezone).isEqualTo(ZONE) + + val undated = TaskFormWriter.apply(task(), form(), NOW, ZONE) + assertThat(undated.timezone).isNull() + } + + @Test + fun `writing a due date clears any duration`() { + // RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive. + val withDuration = task().copy(duration = "PT1H") + + assertThat(TaskFormWriter.apply(withDuration, form(due = NOW), NOW, ZONE).duration).isNull() + } + + @Test + fun `the complete toggle sets and clears the whole triple`() { + val done = TaskFormWriter.completed(task(), completed = true, now = NOW) + assertThat(done.status).isEqualTo(TaskStatus.COMPLETED) + assertThat(done.percentComplete).isEqualTo(100) + assertThat(done.completedAt).isEqualTo(NOW) + + val reopened = TaskFormWriter.completed(done, completed = false, now = NOW) + assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION) + assertThat(reopened.percentComplete).isNull() + assertThat(reopened.completedAt).isNull() + } + + @Test + fun `priority is written as the raw iCalendar integer`() { + assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.HIGH), NOW, ZONE).priority) + .isEqualTo(1) + assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.NONE), NOW, ZONE).priority) + .isEqualTo(0) + } + + @Test + fun `deleting one occurrence writes the exception onto the master`() { + val master = task().copy(rrule = "FREQ=DAILY", timezone = ZONE) + val occurrence = Instant.parse("2026-03-01T09:00:00Z") + + val excepted = TaskFormWriter.excepting(master, occurrence, NOW, ZONE, dirty = true) + + // ⚠️ Dropping the override row un-overrides the occurrence; the RRULE + // then regenerates it. Only an EXDATE takes it out of the set. + assertThat(excepted.exdate).isEqualTo("20260301T090000Z") + assertThat(excepted.isDirty).isTrue() + assertThat(excepted.lastModified).isEqualTo(NOW) + } + + @Test + fun `a device-only list gets the exception without being made dirty`() { + val master = task().copy(rrule = "FREQ=DAILY") + val excepted = TaskFormWriter.excepting( + master, + Instant.parse("2026-03-01T09:00:00Z"), + NOW, + ZONE, + dirty = false, + ) + + // The occurrence still has to be hidden — there is no tombstone doing it. + assertThat(excepted.exdate).isNotNull() + assertThat(excepted.isDirty).isFalse() + } + + @Test + fun `an exception is written in the shape the list already has`() { + val allDay = task().copy(isAllDay = true, exdate = "20260228") + val occurrence = Instant.parse("2026-03-01T00:00:00Z") + + // ⚠️ lib-recur parses the whole EXDATE list or none of it, so a UTC + // date-time appended to a run of DATEs drops every exception the series + // had, this one included. + assertThat(TaskFormWriter.excepting(allDay, occurrence, NOW, ZONE, dirty = true).exdate) + .isEqualTo("20260228,20260301") + + val floating = task().copy(timezone = ZONE, exdate = "20260228T100000") + assertThat( + TaskFormWriter.excepting( + floating, + Instant.parse("2026-03-01T09:00:00Z"), + NOW, + ZONE, + dirty = true, + ).exdate, + ).isEqualTo("20260228T100000,20260301T100000") + } + + @Test + fun `an occurrence already excepted is not added twice`() { + val master = task().copy(exdate = "20260301T090000Z") + val same = TaskFormWriter.excepting( + master, + Instant.parse("2026-03-01T09:00:00Z"), + NOW, + ZONE, + dirty = true, + ) + assertThat(same).isSameInstanceAs(master) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskMoveTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskMoveTest.kt new file mode 100644 index 0000000..9a46306 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskMoveTest.kt @@ -0,0 +1,127 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class TaskMoveTest { + + private val synced = TaskEntity( + id = 10, + listId = SOURCE, + uid = "u", + title = "Milk", + href = "https://s/a/u.ics", + etag = "e1", + parentId = 3, + ) + + @Test + fun `a synced task leaves a tombstone and arrives with no href`() { + val plan = TaskMove.plan( + edited = synced.copy(listId = TARGET), + previous = synced, + overrides = emptyList(), + sourceSynced = true, + targetTombstone = null, + ) + + val moved = plan.update.single() + assertThat(moved.id).isEqualTo(10) + assertThat(moved.listId).isEqualTo(TARGET) + assertThat(moved.href).isNull() + assertThat(moved.etag).isNull() + assertThat(moved.isDirty).isTrue() + + // The old collection is owed a conditional DELETE of the old resource. + val tombstone = plan.tombstone!! + assertThat(tombstone.id).isEqualTo(0) + assertThat(tombstone.listId).isEqualTo(SOURCE) + assertThat(tombstone.href).isEqualTo("https://s/a/u.ics") + assertThat(tombstone.etag).isEqualTo("e1") + assertThat(tombstone.isDeleted).isTrue() + assertThat(tombstone.isDirty).isTrue() + assertThat(tombstone.parentId).isNull() + } + + @Test + fun `overrides move with their master and tombstoned ones are dropped`() { + val live = override(11, deleted = false) + val buried = override(12, deleted = true) + + val plan = TaskMove.plan( + edited = synced.copy(listId = TARGET), + previous = synced, + overrides = listOf(live, buried), + sourceSynced = true, + targetTombstone = null, + ) + + val movedOverride = plan.update.single { it.id == 11L } + assertThat(movedOverride.listId).isEqualTo(TARGET) + assertThat(movedOverride.href).isNull() + assertThat(movedOverride.isDirty).isTrue() + assertThat(plan.delete).containsExactly(12L) + } + + @Test + fun `a task that never reached the server owes nothing`() { + val local = synced.copy(href = null, etag = null) + + val plan = TaskMove.plan(local.copy(listId = TARGET), local, emptyList(), true, null) + + assertThat(plan.tombstone).isNull() + } + + @Test + fun `leaving a device-only list owes nothing`() { + // A list detached from a removed account keeps its hrefs, but no server + // will ever be asked to delete anything for it. + val plan = TaskMove.plan(synced.copy(listId = TARGET), synced, emptyList(), false, null) + + assertThat(plan.tombstone).isNull() + assertThat(plan.update.single().href).isNull() + } + + @Test + fun `moving back before a sync takes the old resource back`() { + val tombstone = synced.copy(id = 20, listId = TARGET, isDeleted = true, isDirty = true) + val elsewhere = synced.copy(listId = SOURCE, href = null, etag = null) + + val plan = TaskMove.plan( + edited = elsewhere.copy(listId = TARGET), + previous = elsewhere, + overrides = emptyList(), + sourceSynced = true, + targetTombstone = tombstone, + ) + + val moved = plan.update.single() + assertThat(moved.href).isEqualTo("https://s/a/u.ics") + assertThat(moved.etag).isEqualTo("e1") + assertThat(plan.delete).containsExactly(20L) + assertThat(plan.tombstone).isNull() + } + + @Test + fun `a live row with the same UID is not a tombstone to revive`() { + val live = synced.copy(id = 20, listId = TARGET) + + val plan = TaskMove.plan(synced.copy(listId = TARGET), synced, emptyList(), true, live) + + assertThat(plan.delete).isEmpty() + assertThat(plan.update.single().href).isNull() + } + + private fun override(id: Long, deleted: Boolean) = synced.copy( + id = id, + masterId = synced.id, + recurrenceId = Instant.parse("2026-03-0${id - 9}T12:00:00Z"), + isDeleted = deleted, + ) + + private companion object { + const val SOURCE = 1L + const val TARGET = 2L + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt new file mode 100644 index 0000000..70819b4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/transfer/IcsImportTest.kt @@ -0,0 +1,206 @@ +package de.jeanlucmakiola.agendula.data.tasks.transfer + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class IcsImportTest { + + private class FakeImportStore(seed: List = emptyList()) : IcsImportStore { + val rows = seed.toMutableList() + val lists = mutableMapOf() + private var nextId = 100L + + override fun transaction(block: () -> T): T = block() + + override fun createList(name: String, color: Int): Long { + val id = 50L + lists.size + lists[id] = name + return id + } + + override fun hasUid(listId: Long, uid: String) = rows.any { it.listId == listId && it.uid == uid } + + override fun insert(row: TaskEntity): Long { + val id = nextId++ + rows += row.copy(id = id) + return id + } + + override fun masterByUid(listId: Long, uid: String) = + rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null } + + override fun setParent(taskId: Long, parentId: Long?) { + val index = rows.indexOfFirst { it.id == taskId } + rows[index] = rows[index].copy(parentId = parentId) + } + + fun byUid(uid: String) = rows.filter { it.uid == uid } + } + + private fun calendar(vararg components: String, name: String? = null) = buildString { + append("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n") + if (name != null) append("X-WR-CALNAME:$name\r\n") + components.forEach { append(it) } + append("END:VCALENDAR\r\n") + } + + private fun vtodo(uid: String?, summary: String, vararg extra: String) = buildString { + append("BEGIN:VTODO\r\n") + if (uid != null) append("UID:$uid\r\n") + append("SUMMARY:$summary\r\n") + extra.forEach { append(it).append("\r\n") } + append("END:VTODO\r\n") + } + + private val series = vtodo( + "series", "Water plants", + "DTSTART:20260601T080000Z", + "DUE:20260601T090000Z", + "RRULE:FREQ=WEEKLY;BYDAY=MO,TH", + "X-APPLE-SORT-ORDER:7", + "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Water\r\nTRIGGER:-PT15M\r\nEND:VALARM", + ) + private val override = vtodo( + "series", "Water plants (balcony)", + "RECURRENCE-ID:20260604T080000Z", + "DTSTART:20260604T100000Z", + "DUE:20260604T110000Z", + ) + private val event = "BEGIN:VEVENT\r\nUID:ev\r\nSUMMARY:Meeting\r\nDTSTART:20260601T080000Z\r\nEND:VEVENT\r\n" + + @Test + fun `a series lands with its override, rule, alarm and unknown properties`() { + val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series, override, event)))) + val store = FakeImportStore() + + val result = IcsImport.write(parsed, ImportTarget.Existing(1L), store) + + assertThat(result.imported).isEqualTo(1) + assertThat(parsed.skippedComponents).isEqualTo(1) + val rows = store.byUid("series") + assertThat(rows).hasSize(2) + val master = rows.single { it.recurrenceId == null } + val child = rows.single { it.recurrenceId != null } + assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH") + assertThat(master.unknownProperties).contains("X-APPLE-SORT-ORDER:7") + assertThat(master.unknownProperties).contains("BEGIN:VALARM") + assertThat(child.masterId).isEqualTo(master.id) + assertThat(child.title).isEqualTo("Water plants (balcony)") + assertThat(rows.all { it.listId == 1L && it.isDirty && it.href == null }).isTrue() + } + + @Test + fun `a parent later in the file is still linked`() { + val child = vtodo("child", "Buy soil", "RELATED-TO;RELTYPE=PARENT:parent") + val parent = vtodo("parent", "Repot") + val store = FakeImportStore() + + IcsImport.write(IcsImport.parse(listOf(IcsSource(null, calendar(child, parent)))), ImportTarget.Existing(1L), store) + + val parentRow = store.byUid("parent").single() + assertThat(store.byUid("child").single().parentId).isEqualTo(parentRow.id) + } + + @Test + fun `a parent already in the target list is linked too`() { + val existing = TaskEntity(id = 7L, listId = 1L, uid = "parent", title = "Repot") + val store = FakeImportStore(listOf(existing)) + val child = vtodo("child", "Buy soil", "RELATED-TO:parent") + + IcsImport.write(IcsImport.parse(listOf(IcsSource(null, calendar(child)))), ImportTarget.Existing(1L), store) + + assertThat(store.byUid("child").single().parentId).isEqualTo(7L) + } + + @Test + fun `UIDs the target list already holds are skipped`() { + val store = FakeImportStore(listOf(TaskEntity(id = 1L, listId = 1L, uid = "a", title = "Old"))) + val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo("a", "New"), vtodo("b", "Other"))))) + + val result = IcsImport.write(parsed, ImportTarget.Existing(1L), store) + + assertThat(result.imported).isEqualTo(1) + assertThat(result.skippedExisting).isEqualTo(1) + assertThat(store.byUid("a").single().title).isEqualTo("Old") + } + + @Test + fun `the same UID in another list is not a duplicate`() { + val store = FakeImportStore(listOf(TaskEntity(id = 1L, listId = 2L, uid = "a"))) + val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo("a", "New"))))) + + assertThat(IcsImport.write(parsed, ImportTarget.Existing(1L), store).imported).isEqualTo(1) + } + + @Test + fun `a component without a UID gets one of its own`() { + val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo(null, "One"), vtodo(null, "Two"))))) + val store = FakeImportStore() + + IcsImport.write(parsed, ImportTarget.Existing(1L), store) + + assertThat(store.rows.map { it.uid }.distinct()).hasSize(2) + assertThat(store.rows.none { it.uid.isBlank() }).isTrue() + } + + @Test + fun `a new list is created and named by the caller`() { + val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo("a", "Task"))))) + val store = FakeImportStore() + + val result = IcsImport.write(parsed, ImportTarget.NewList(" Garden ", 0), store) + + assertThat(store.lists[result.listId]).isEqualTo("Garden") + assertThat(store.rows.single().listId).isEqualTo(result.listId) + } + + @Test + fun `a zip splits into its ics entries and repeats land once`() { + val zip = ByteArrayOutputStream().also { bytes -> + ZipOutputStream(bytes).use { out -> + out.putNextEntry(ZipEntry("Home-1.ics")) + out.write(calendar(vtodo("a", "A"), name = "Home").toByteArray()) + out.putNextEntry(ZipEntry("Work-2.ics")) + out.write(calendar(vtodo("a", "A"), vtodo("b", "B"), name = "Work").toByteArray()) + out.putNextEntry(ZipEntry("notes.txt")) + out.write("ignored".toByteArray()) + } + }.toByteArray() + + val sources = IcsImport.sourcesOf("agendula-tasks.zip", zip) + val parsed = IcsImport.parse(sources, "agendula-tasks.zip") + + assertThat(sources.map { it.name }).containsExactly("Home-1.ics", "Work-2.ics") + assertThat(parsed.tasks.map { it.uid }).containsExactly("a", "b") + assertThat(parsed.duplicatesInFile).isEqualTo(1) + // The calendars disagree on a name, so the bundle's own name stands in. + assertThat(parsed.suggestedListName).isEqualTo("agendula-tasks") + } + + @Test + fun `the calendar name wins over the file name`() { + val parsed = IcsImport.parse( + listOf(IcsSource("export.ics", calendar(vtodo("a", "A"), name = "Groceries"))), + ) + assertThat(parsed.suggestedListName).isEqualTo("Groceries") + } + + @Test + fun `a plain file with a byte-order mark still parses`() { + val bytes = ("" + calendar(vtodo("a", "A"))).toByteArray() + val parsed = IcsImport.parse(IcsImport.sourcesOf("a.ics", bytes)) + assertThat(parsed.tasks).hasSize(1) + assertThat(parsed.suggestedListName).isEqualTo("a") + } + + @Test + fun `garbage counts as unreadable rather than throwing`() { + val parsed = IcsImport.parse(listOf(IcsSource("x.ics", "not a calendar"))) + assertThat(parsed.tasks).isEmpty() + assertThat(parsed.unreadableDocuments).isEqualTo(1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt new file mode 100644 index 0000000..957192f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/AllDayTimeTest.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import kotlin.time.Instant + +class AllDayTimeTest { + + private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July + private val newYork = ZoneId.of("America/New_York") // UTC-4 in July + private val julyTwentieth = LocalDate.of(2026, 7, 20) + + @Test + fun `an all-day instant is UTC midnight of its date`() { + val instant = allDayInstantOf(julyTwentieth) + assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L) + assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth) + } + + @Test + fun `an all-day date reads the same everywhere, unlike a timed one`() { + val allDay = allDayInstantOf(julyTwentieth) + // The whole point: zone must not change which day an all-day value denotes. + assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth) + assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth) + // Read as a timed value in New York it would slip to the 19th — the bug. + assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1)) + } + + @Test + fun `toggling all-day off keeps the day and lands on local midnight`() { + val allDay = allDayInstantOf(julyTwentieth) + val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin) + + assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth) + val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin) + assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT) + } + + @Test + fun `toggling all-day on keeps the day the user was looking at`() { + // 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day. + val lateEvening = Instant.fromEpochMilliseconds( + julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(), + ) + val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin) + + assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth) + } + + @Test + fun `round-tripping the toggle is stable`() { + val original = allDayInstantOf(julyTwentieth) + val there = original.rebasedForAllDay(allDay = false, zone = newYork) + val back = there.rebasedForAllDay(allDay = true, zone = newYork) + assertThat(back).isEqualTo(original) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/HtmlTextTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/HtmlTextTest.kt new file mode 100644 index 0000000..fe86efd --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/HtmlTextTest.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class HtmlTextTest { + + @Test + fun `plain text with angle brackets is not html`() { + assertThat("a < b and c > d".looksLikeHtml()).isFalse() + assertThat("x <3".readableDescription()).isEqualTo("x <3") + assertThat("if xc".looksLikeHtml()).isFalse() + assertThat("Mail ".looksLikeHtml()).isFalse() + } + + @Test + fun `text that only resembles a tag survives inside real html`() { + assertThat("

Mail

".htmlToPlainText()).isEqualTo("Mail ") + } + + @Test + fun `paragraphs and breaks become lines`() { + val html = "

First line
second

\n

Next & last

" + assertThat(html.htmlToPlainText()).isEqualTo("First line\nsecond\nNext & last") + } + + @Test + fun `list items become bullets`() { + val html = "
    \n
  • Milk
  • \n
  • Eggs
  • \n
" + assertThat(html.htmlToPlainText()).isEqualTo("• Milk\n• Eggs") + } + + @Test + fun `a link keeps its address`() { + assertThat("""See the docs""".htmlToPlainText()) + .isEqualTo("See the docs (https://example.org)") + assertThat("""https://example.org""".htmlToPlainText()) + .isEqualTo("https://example.org") + assertThat("""a@b.de""".htmlToPlainText()).isEqualTo("a@b.de") + } + + @Test + fun `inline markup keeps the source line breaks`() { + assertThat("one bold\ntwo".htmlToPlainText()).isEqualTo("one bold\ntwo") + } + + @Test + fun `entities decode and bogus ones stay`() { + assertThat("<tag> ä é � &bogus;".htmlToPlainText()) + .isEqualTo(" ä é � &bogus;") + } + + @Test + fun `head and style are dropped`() { + val html = "

Hi

" + assertThat(html.htmlToPlainText()).isEqualTo("Hi") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/SeriesCollapseTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/SeriesCollapseTest.kt new file mode 100644 index 0000000..043442a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/SeriesCollapseTest.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.agendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class SeriesCollapseTest { + + private fun occurrence( + day: Int, + distance: Int, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + rowId: Long = 7, + ): Task { + val at = Instant.fromEpochMilliseconds(day * 86_400_000L) + return testTask(id = rowId, status = status, due = at).copy( + isRecurring = rowId == 7L, + occurrenceStart = at, + distanceFromCurrent = distance, + seriesId = 7, + ) + } + + @Test + fun `a daily series shows one open occurrence, not a year of them`() { + val series = (-5..5).map { occurrence(day = 100 + it, distance = it) } + + val visible = SeriesCollapse.visible(series) + + assertThat(visible.map { it.distanceFromCurrent }).containsExactly(-1) + } + + @Test + fun `yesterday done leaves today as the one to do`() { + val series = listOf( + occurrence(day = 99, distance = -1, status = TaskStatus.COMPLETED, rowId = 20), + occurrence(day = 100, distance = 0), + occurrence(day = 101, distance = 1), + ) + + val visible = SeriesCollapse.visible(series) + + assertThat(visible.map { it.taskId to it.distanceFromCurrent }) + .containsExactly(20L to -1, 7L to 0) + } + + @Test + fun `a closed series shows once`() { + val series = (-2..2).map { occurrence(day = 100 + it, distance = it, status = TaskStatus.COMPLETED) } + + assertThat(SeriesCollapse.visible(series)).hasSize(1) + } + + @Test + fun `plain tasks pass through untouched`() { + val plain = testTask(id = 1) + + assertThat(SeriesCollapse.visible(listOf(plain))).containsExactly(plain) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt index 6392459..3b49e2d 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskFormTest.kt @@ -32,7 +32,7 @@ class TaskFormTest { @Test fun `a reminder requires a due date`() { - val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate() + val errors = TaskForm(title = "x", listId = 1, reminders = listOf(10)).validate() assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE) } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt index 3af991d..d1853d9 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TaskSortingTest.kt @@ -17,7 +17,7 @@ class TaskSortingTest { val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT) - assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder() + assertThat(sorted.map { it.taskId }).containsExactly(3L, 2L, 4L, 1L).inOrder() } @Test @@ -27,6 +27,6 @@ class TaskSortingTest { val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT) - assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder() + assertThat(sorted.map { it.taskId }).containsExactly(2L, 1L).inOrder() } } diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt index 11f58c6..6967c97 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/TestTasks.kt @@ -11,7 +11,6 @@ fun testTask( priority: Priority = Priority.NONE, due: Instant? = null, ): Task = Task( - id = id, taskId = id, listId = listId, title = title, @@ -32,6 +31,7 @@ fun testTask( accountName = null, parentId = null, isRecurring = false, + occurrenceStart = null, distanceFromCurrent = 0, created = null, lastModified = null, diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt new file mode 100644 index 0000000..86c6b8c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/export/ICalendarWriterTest.kt @@ -0,0 +1,313 @@ +package de.jeanlucmakiola.agendula.domain.export + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate +import kotlin.time.Instant + +/** + * The export format. Worth testing closely: an export is only as good as its + * ability to be read back, and nothing about a malformed `.ics` is obvious until + * someone actually needs the backup. + */ +class ICalendarWriterTest { + + private fun task( + taskId: Long = 1L, + uid: String? = null, + title: String = "Buy oat milk", + description: String? = null, + location: String? = null, + url: String? = null, + priority: Priority = Priority.NONE, + status: TaskStatus = TaskStatus.NEEDS_ACTION, + percentComplete: Int? = null, + start: Instant? = null, + due: Instant? = null, + isAllDay: Boolean = false, + completedAt: Instant? = null, + created: Instant? = null, + lastModified: Instant? = null, + rrule: String? = null, + rdate: String? = null, + parentId: Long? = null, + ) = ExportTask( + taskId, uid, title, description, location, url, priority, status, percentComplete, + start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId, + ) + + private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String = + ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList())) + + /** Unfolds the way an importer does, so assertions can read logical lines. */ + private fun String.unfolded(): String = replace("\r\n ", "") + + private fun linesOf(ics: String): List = ics.unfolded().split("\r\n").filter { it.isNotEmpty() } + + @Nested + inner class Structure { + + @Test + fun `wraps the todos in a calendar`() { + val lines = linesOf(write(task())) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO") + } + + @Test + fun `uses CRLF line endings`() { + // RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected + // by a strict importer while looking perfectly fine in an editor. + val ics = write(task()) + assertThat(ics).contains("\r\n") + assertThat(ics.replace("\r\n", "")).doesNotContain("\n") + } + + @Test + fun `carries the list name`() { + assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping") + } + + @Test + fun `every todo has a UID and a DTSTAMP`() { + // Both are mandatory; a VTODO missing either is invalid. + val lines = linesOf(write(task(), task(taskId = 2))) + assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2) + assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2) + } + } + + @Nested + inner class Identity { + + @Test + fun `prefers the synced UID`() { + assertThat(linesOf(write(task(uid = "abc-123@example.org")))) + .contains("UID:abc-123@example.org") + } + + @Test + fun `synthesises a stable UID for a local task`() { + // Local tasks never get a UID from the provider (only a sync adapter may + // assign one), and re-importing UID-less todos would duplicate rather + // than match them. + val first = ICalendarWriter.uidFor(task(taskId = 42)) + val second = ICalendarWriter.uidFor(task(taskId = 42)) + assertThat(first).isEqualTo(second) + assertThat(first).contains("42") + } + + @Test + fun `distinct tasks get distinct UIDs`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 1))) + .isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2))) + } + + @Test + fun `blank stored UID falls back to the synthesised one`() { + assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7") + } + } + + @Nested + inner class Dates { + + private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z + + @Test + fun `timed values are written in UTC`() { + assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z") + } + + @Test + fun `all-day values are date-only`() { + // A DATE-TIME here would drift by a day for anyone east or west of UTC — + // the exact bug the AllDayTime convention exists to prevent. + val due = allDayInstantOf(LocalDate.of(2026, 8, 2)) + val lines = linesOf(write(task(due = due, isAllDay = true))) + assertThat(lines).contains("DUE;VALUE=DATE:20260802") + } + + @Test + fun `completion is always a UTC date-time even when all-day`() { + val lines = linesOf( + write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)), + ) + assertThat(lines).contains("COMPLETED:20250802T120000Z") + } + + @Test + fun `absent dates emit no property at all`() { + val lines = linesOf(write(task())) + assertThat(lines.none { it.startsWith("DUE") }).isTrue() + assertThat(lines.none { it.startsWith("DTSTART") }).isTrue() + } + } + + @Nested + inner class Fields { + + @Test + fun `maps every status to its iCalendar name`() { + fun statusLine(status: TaskStatus) = + linesOf(write(task(status = status))).first { it.startsWith("STATUS:") } + + assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION") + assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS") + assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED") + assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED") + } + + @Test + fun `omits priority when there is none`() { + // PRIORITY:0 means "undefined" but reads as a real value to some + // importers; leaving it out is unambiguous. + assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue() + assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1") + } + + @Test + fun `clamps percent complete into range`() { + assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100") + assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0") + } + + @Test + fun `passes recurrence through unchanged`() { + val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE"))) + assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE") + } + + @Test + fun `does not escape a URL`() { + // URL is a URI, not TEXT. Escaping its commas would corrupt the address. + val lines = linesOf(write(task(url = "https://example.org/a,b;c"))) + assertThat(lines).contains("URL:https://example.org/a,b;c") + } + + @Test + fun `skips blank optional fields`() { + val lines = linesOf(write(task(description = " ", location = "", url = ""))) + assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue() + assertThat(lines.none { it.startsWith("LOCATION") }).isTrue() + assertThat(lines.none { it.startsWith("URL") }).isTrue() + } + } + + @Nested + inner class Subtasks { + + @Test + fun `links a child to its parent by UID`() { + val parent = task(taskId = 1, uid = "parent@example.org") + val child = task(taskId = 2, parentId = 1) + assertThat(linesOf(write(parent, child))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `resolves a parent that appears after the child`() { + // Nothing guarantees provider order, and a forward reference must still + // resolve or half the hierarchy silently disappears. + val child = task(taskId = 2, parentId = 1) + val parent = task(taskId = 1, uid = "parent@example.org") + assertThat(linesOf(write(child, parent))) + .contains("RELATED-TO;RELTYPE=PARENT:parent@example.org") + } + + @Test + fun `drops a link to a parent outside this list`() { + // A RELATED-TO pointing at a UID not in the file would dangle on import. + val orphan = task(taskId = 2, parentId = 999) + assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue() + } + } + + @Nested + inner class Escaping { + + @Test + fun `escapes the special characters`() { + assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c") + assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak") + assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere") + } + + @Test + fun `escapes backslashes first`() { + // Doing it later would re-escape the backslashes the other rules add, + // turning "a;b" into "a\\;b". + assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash") + assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b") + } + + @Test + fun `a multiline description stays one logical line`() { + val ics = write(task(description = "first\nsecond")) + assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond") + } + } + + @Nested + inner class Folding { + + @Test + fun `short lines are untouched`() { + assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short") + } + + @Test + fun `long lines are folded to 75 octets`() { + val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200)) + folded.split("\r\n").forEachIndexed { index, segment -> + val octets = segment.toByteArray(Charsets.UTF_8).size + assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space + } + } + + @Test + fun `folding round-trips`() { + val original = "DESCRIPTION:" + "long text ".repeat(40) + assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original) + } + + @Test + fun `never splits a multi-byte character`() { + // The limit is in octets but an emoji is four of them; splitting mid + // sequence would emit invalid UTF-8 and mangle the title. + val emoji = "SUMMARY:" + "🌼".repeat(40) + val folded = ICalendarWriter.fold(emoji) + assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji) + folded.split("\r\n").forEach { segment -> + // A broken surrogate pair round-trips through UTF-8 as U+FFFD. + assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8)) + .isEqualTo(segment) + } + } + + @Test + fun `a long title survives the full write`() { + val title = "Remember to ".repeat(20) + assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title") + } + } + + @Nested + inner class EmptyList { + + @Test + fun `still produces a valid calendar`() { + // An empty list is a real answer; a missing file is indistinguishable + // from a failed export. + val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList()))) + assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR") + assertThat(lines.last()).isEqualTo("END:VCALENDAR") + assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt new file mode 100644 index 0000000..9a39c59 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalParserTest.kt @@ -0,0 +1,154 @@ +package de.jeanlucmakiola.agendula.domain.ical + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * The lexer. Worth testing closely because every correctness guarantee above it + * assumes a content line was split where RFC 5545 says it splits — and the + * places it does not split are where other clients' data goes missing. + */ +class ICalParserTest { + + @Nested + inner class Unfolding { + + @Test + fun `unfolding removes the CRLF and the whitespace, not just the CRLF`() { + // RFC 5545 section 3.1: a fold is CRLF + one WSP and *both* go. A + // folder that keeps the space silently inserts one into every long + // value it ever wrote. + val lines = ICalParser.unfold("SUMMARY:Hello\r\n world\r\n") + assertThat(lines).containsExactly("SUMMARY:Helloworld") + } + + @Test + fun `a tab folds too`() { + assertThat(ICalParser.unfold("SUMMARY:Hello\r\n\tworld")).containsExactly("SUMMARY:Helloworld") + } + + @Test + fun `bare LF is accepted, because real files carry it`() { + assertThat(ICalParser.unfold("A:1\nB:2")).containsExactly("A:1", "B:2") + } + + @Test + fun `a leading BOM is stripped rather than glued onto the first name`() { + val component = ICalParser.parse("\uFEFFBEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n") + assertThat(component.name).isEqualTo("VTODO") + } + } + + @Nested + inner class ContentLines { + + @Test + fun `the value starts at the first colon outside a quoted parameter`() { + val property = ICalParser.parseLine("""ATTENDEE;CN="Smith, J:r":mailto:jr@example.com""") + assertThat(property?.name).isEqualTo("ATTENDEE") + assertThat(property?.param("CN")).isEqualTo("Smith, J:r") + assertThat(property?.value).isEqualTo("mailto:jr@example.com") + } + + @Test + fun `a multi-valued parameter splits only on unquoted commas`() { + val property = ICalParser.parseLine("""X-THING;MEMBER="a,b",c:v""") + assertThat(property?.params?.single()?.values).containsExactly("a,b", "c").inOrder() + } + + @Test + fun `a value may contain colons`() { + assertThat(ICalParser.parseLine("URL:https://example.com/a:b")?.value) + .isEqualTo("https://example.com/a:b") + } + + @Test + fun `the value is kept escaped, because that is what makes it reproducible`() { + assertThat(ICalParser.parseLine("""DESCRIPTION:a\, b\; c""")?.value) + .isEqualTo("""a\, b\; c""") + } + + @Test + fun `a parameter with no equals sign is kept rather than dropped`() { + val property = ICalParser.parseLine("X-THING;BROKEN:v") + assertThat(property?.params?.single()?.name).isEqualTo("BROKEN") + assertThat(property?.params?.single()?.values).isEmpty() + } + + @Test + fun `a line with no colon is not a content line`() { + assertThat(ICalParser.parseLine("GARBAGE")).isNull() + } + } + + @Nested + inner class Structure { + + @Test + fun `unknown components nest like any other`() { + val component = ICalParser.parse( + """ + BEGIN:VTODO + UID:x + BEGIN:X-VENDOR + X-KEY:v + END:X-VENDOR + END:VTODO + """.trimIndent(), + ) + assertThat(component.components("X-VENDOR").single().property("X-KEY")?.value).isEqualTo("v") + } + + @Test + fun `a stray END is ignored rather than fatal`() { + val component = ICalParser.parse("END:VALARM\nBEGIN:VTODO\nUID:x\nEND:VTODO\n") + assertThat(component.name).isEqualTo("VTODO") + assertThat(component.property("UID")?.value).isEqualTo("x") + } + + @Test + fun `component names are upper-cased so lookups are not case-dependent`() { + assertThat(ICalParser.parse("begin:vtodo\nuid:x\nend:vtodo\n").name).isEqualTo("VTODO") + } + + @Test + fun `text with no component at all is fatal`() { + runCatching { ICalParser.parse("SUMMARY:orphan\r\n") } + .onSuccess { error("expected ICalParseException") } + .onFailure { assertThat(it).isInstanceOf(ICalParseException::class.java) } + } + + @Test + fun `a missing END does not swallow the enclosing component`() { + // Closing by position would let END:VCALENDAR close the VTODO, leave + // VCALENDAR open, and end with no component at all — discarding a + // whole multiget response over one malformed task. + val roots = ICalParser.parseAll( + "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:x\r\nEND:VCALENDAR\r\n", + ) + val calendar = roots.single() + assertThat(calendar.name).isEqualTo("VCALENDAR") + assertThat(calendar.components("VTODO").single().property("UID")?.value).isEqualTo("x") + } + + @Test + fun `a component left open at end of input is still returned`() { + val calendar = ICalParser.parse("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:x\r\n") + assertThat(calendar.components("VTODO").single().property("UID")?.value).isEqualTo("x") + } + + @Test + fun `an unbalanced quote does not swallow the whole line`() { + val property = ICalParser.parseLine("""X-PROP;CN="unclosed:value""") + assertThat(property?.name).isEqualTo("X-PROP") + assertThat(property?.value).isEqualTo("value") + } + + @Test + fun `duplicate properties are both kept`() { + val component = ICalParser.parse("BEGIN:VTODO\nCATEGORIES:a\nCATEGORIES:a\nEND:VTODO\n") + assertThat(component.properties("CATEGORIES")).hasSize(2) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalSerializerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalSerializerTest.kt new file mode 100644 index 0000000..f0b4280 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/ICalSerializerTest.kt @@ -0,0 +1,64 @@ +package de.jeanlucmakiola.agendula.domain.ical + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ICalSerializerTest { + + @Test + fun `a line is folded at 75 octets, not 75 characters`() { + // Each 'ä' is two octets, so 40 of them is 80 — over the limit at 40 + // characters, which a character-counting folder would let through. + val property = ICalProperty("SUMMARY", value = "ä".repeat(40)) + val folded = ICalSerializer.fold(ICalSerializer.render(property)) + + assertThat(folded).contains("\r\n ") + folded.split("\r\n").forEach { + assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75) + } + } + + @Test + fun `folding never splits a multi-byte character`() { + val value = "🌱".repeat(30) + val folded = ICalSerializer.fold("SUMMARY:$value") + val unfolded = ICalParser.unfold(folded + "\r\n").single() + assertThat(unfolded).isEqualTo("SUMMARY:$value") + } + + @Test + fun `a parameter value is quoted only when the grammar forces it`() { + assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "plain")), "v"))) + .isEqualTo("X;P=plain:v") + assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "a:b")), "v"))) + .isEqualTo("""X;P="a:b":v""") + assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "a,b")), "v"))) + .isEqualTo("""X;P="a,b":v""") + } + + @Test + fun `an embedded double quote survives when the value need not be quoted`() { + // The parser only unquotes a matched leading/trailing pair, so stripping + // unconditionally would lose two characters from a value it never quoted. + assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", """a"b""")), "v"))) + .isEqualTo("""X;P=a"b:v""") + } + + @Test + fun `an embedded double quote is dropped only when quoting is forced`() { + assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", """a"b,c""")), "v"))) + .isEqualTo("""X;P="ab,c":v""") + } + + @Test + fun `components round-trip through serialise and parse`() { + val original = ICalComponent( + name = "VTODO", + properties = listOf(ICalProperty("UID", value = "x")), + components = listOf( + ICalComponent("VALARM", listOf(ICalProperty("ACTION", value = "DISPLAY"))), + ), + ) + assertThat(ICalParser.parse(ICalSerializer.serialize(original))).isEqualTo(original) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/VTimeZonesTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/VTimeZonesTest.kt new file mode 100644 index 0000000..77748b0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/ical/VTimeZonesTest.kt @@ -0,0 +1,99 @@ +package de.jeanlucmakiola.agendula.domain.ical + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class VTimeZonesTest { + + @Test fun `a DST zone gets both observances`() { + val zone = VTimeZones.forZone("Europe/Berlin")!! + + assertThat(zone.property("TZID")!!.value).isEqualTo("Europe/Berlin") + assertThat(zone.components.map { it.name }) + .containsExactly("STANDARD", "DAYLIGHT") + + val daylight = zone.components("DAYLIGHT").single() + assertThat(daylight.property("TZOFFSETFROM")!!.value).isEqualTo("+0100") + assertThat(daylight.property("TZOFFSETTO")!!.value).isEqualTo("+0200") + // The EU rule is "the last Sunday", which is what java.time encodes as + // dayOfMonthIndicator = -1. + assertThat(daylight.property("RRULE")!!.value) + .isEqualTo("FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU") + } + + @Test fun `a nth-weekday rule becomes an ordinal BYDAY`() { + val zone = VTimeZones.forZone("America/New_York")!! + // US DST starts on the second Sunday in March, which java.time encodes as + // "on or after the 8th". + assertThat(zone.components("DAYLIGHT").single().property("RRULE")!!.value) + .isEqualTo("FREQ=YEARLY;BYMONTH=3;BYDAY=2SU") + } + + @Test fun `a fixed-offset zone gets one standard observance and no rule`() { + val zone = VTimeZones.forZone("Asia/Kolkata")!! + val standard = zone.components.single() + + assertThat(standard.name).isEqualTo("STANDARD") + assertThat(standard.property("TZOFFSETTO")!!.value).isEqualTo("+0530") + assertThat(standard.property("RRULE")).isNull() + } + + @Test fun `UTC is representable`() { + val zone = VTimeZones.forZone("UTC")!! + assertThat(zone.components.single().property("TZOFFSETTO")!!.value).isEqualTo("+0000") + } + + @Test fun `a negative offset keeps its sign`() { + val zone = VTimeZones.forZone("America/New_York")!! + assertThat(zone.components("STANDARD").single().property("TZOFFSETTO")!!.value) + .isEqualTo("-0500") + } + + @Test fun `an unknown zone is null rather than an exception`() { + assertThat(VTimeZones.forZone("Mars/Olympus_Mons")).isNull() + assertThat(VTimeZones.forZone("")).isNull() + } + + @Test fun `observance DTSTART is a real instance of its own rule`() { + val daylight = VTimeZones.forZone("Europe/Berlin")!!.components("DAYLIGHT").single() + val start = daylight.property("DTSTART")!!.value + // 1970-03-29 was a Sunday, and the last one in March. + assertThat(start).isEqualTo("19700329T020000") + } + + @Test fun `zones are collected from every TZID in the tree`() { + val todo = ICalComponent( + name = "VTODO", + properties = listOf( + ICalProperty("DTSTART", listOf(ICalParam("TZID", "Europe/Berlin")), "20260101T090000"), + ICalProperty("DUE", listOf(ICalParam("TZID", "Europe/Berlin")), "20260101T100000"), + ), + components = listOf( + ICalComponent( + name = "VALARM", + properties = listOf( + ICalProperty( + "TRIGGER", + listOf(ICalParam("TZID", "America/New_York")), + "20260101T080000", + ), + ), + components = emptyList(), + ), + ), + ) + + val zones = VTimeZones.forComponent(todo).map { it.property("TZID")!!.value } + // Deduplicated, and the nested one is not missed. + assertThat(zones).containsExactly("America/New_York", "Europe/Berlin").inOrder() + } + + @Test fun `a component with no TZID needs no definitions`() { + val todo = ICalComponent( + name = "VTODO", + properties = listOf(ICalProperty("DTSTART", emptyList(), "20260101T090000Z")), + components = emptyList(), + ) + assertThat(VTimeZones.forComponent(todo)).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt new file mode 100644 index 0000000..2e0e9b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/DistanceFromCurrentTest.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class DistanceFromCurrentTest { + + private fun at(text: String) = Instant.parse(text) + + private val occurrences = listOf( + at("2025-01-05T09:00:00Z"), + at("2025-01-06T09:00:00Z"), + at("2025-01-07T09:00:00Z"), + at("2025-01-08T09:00:00Z"), + ) + + @Test + fun `the current occurrence is the first one at or after now`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(index).isEqualTo(2) + } + + @Test + fun `an occurrence exactly at now is the current one`() { + val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z")) + assertThat(index).isEqualTo(1) + } + + @Test + fun `past occurrences count down and later ones count up`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z")) + assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder() + } + + @Test + fun `a series entirely in the future is current at its first occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z")) + assertThat(distances).containsExactly(0, 1, 2, 3).inOrder() + } + + @Test + fun `a series entirely in the past is current at its last occurrence`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z")) + assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder() + } + + @Test + fun `exactly one occurrence is ever the current one`() { + val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z")) + assertThat(distances.count { it == 0 }).isEqualTo(1) + } + + @Test + fun `an empty series has no distances`() { + assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty() + assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt new file mode 100644 index 0000000..5230b82 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceExpanderTest.kt @@ -0,0 +1,483 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.ZoneId +import kotlin.time.Instant + +/** + * The provider only ever materialised the single next occurrence, so there is no + * provider behaviour to compare multi-occurrence expansion against. These cases + * assert against RFC 5545 §3.8.5 directly. + */ +class RecurrenceExpanderTest { + + private val berlin = "Europe/Berlin" + private val newYork = ZoneId.of("America/New_York") + + private fun at(text: String) = Instant.parse(text) + + private fun spec( + rrule: String? = null, + rdate: String? = null, + exdate: String? = null, + anchor: String, + isAllDay: Boolean = false, + timeZone: String? = berlin, + ) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone) + + private fun window( + from: String = "2000-01-01T00:00:00Z", + until: String = "2100-01-01T00:00:00Z", + max: Int = 500, + ) = ExpansionWindow(at(from), at(until), max) + + private fun expand( + spec: RecurrenceSpec, + window: ExpansionWindow = window(), + floatingZone: ZoneId = newYork, + ) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() } + + // --- frequencies --------------------------------------------------------- + + @Test + fun `daily rule yields consecutive days at the same local time`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `interval skips the intervening days`() { + val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + "2025-01-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `weekly by day expands to the named weekdays`() { + // The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is + // the first instance of the set regardless (RFC 5545 §3.8.5.3), so the + // Tuesday leads and the pattern takes over from there. + val result = expand( + spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", // Tue, the anchor + "2025-01-08T08:00:00Z", // Wed + "2025-01-10T08:00:00Z", // Fri + "2025-01-13T08:00:00Z", // Mon + "2025-01-15T08:00:00Z", // Wed + ).inOrder() + } + + @Test + fun `monthly by day expands to the nth weekday of the month`() { + // 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the + // anchor still leads. + val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-14T08:00:00Z", + "2025-02-11T08:00:00Z", + ).inOrder() + } + + @Test + fun `monthly by month day skips months that lack the day`() { + val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-31T08:00:00Z", + "2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST + "2025-05-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule repeats on the anniversary`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2026-01-07T08:00:00Z", + "2027-01-07T08:00:00Z", + ).inOrder() + } + + @Test + fun `yearly rule on a leap day only recurs in leap years`() { + val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z")) + assertThat(result).containsExactly( + "2024-02-29T08:00:00Z", + "2028-02-29T08:00:00Z", + "2032-02-29T08:00:00Z", + ).inOrder() + } + + // --- limits -------------------------------------------------------------- + + @Test + fun `COUNT limits the series`() { + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z")) + assertThat(result).hasSize(2) + } + + @Test + fun `UNTIL includes an occurrence falling exactly on it`() { + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a floating UNTIL is read in the series zone`() { + // RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and + // lib-recur throws outright on the mismatch. Stored rules break the rule + // anyway, so 09:00 floating has to mean 09:00 in Berlin. + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a date-valued UNTIL covers the whole of its last day`() { + // ⚠️ A DATE-valued UNTIL is midnight, and the repair below rebuilt it + // from hours/minutes/seconds — all zero for a DATE — so a series anchored + // at 09:00 Berlin lost every occurrence on the final day. RFC 5545 + // §3.3.10 puts the bound at the end of that date. + val result = expand( + spec(rrule = "FREQ=DAILY;UNTIL=20250109", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day series with a date-valued UNTIL keeps its last day`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;UNTIL=20250109", + anchor = "2025-01-07T00:00:00Z", + isAllDay = true, + timeZone = null, + ), + ) + assertThat(result).hasSize(3) + } + + @Test + fun `an unbounded rule stops at the occurrence ceiling`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(max = 4), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + + @Test + fun `a sub-daily series spends most of the ceiling on occurrences from the pivot on`() { + // The real shape: an eight-hourly task, a year of window behind now and a + // 500-occurrence ceiling. Filling the budget from the window start would + // exhaust it ~166 days before now, so the task would never appear in Today + // or Upcoming at all. + val result = expand( + spec(rrule = "FREQ=HOURLY;INTERVAL=8", anchor = "2024-01-01T00:00:00Z"), + ExpansionWindow( + from = at("2024-06-01T00:00:00Z"), + until = at("2026-06-01T00:00:00Z"), + maxOccurrences = 500, + pivot = at("2025-06-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(500) + // A quarter of the budget looks back, and it keeps the *most recent* of + // the past — not the oldest, which is what filling from the window start + // would have kept. (ISO-8601 UTC sorts lexicographically.) + assertThat(result.count { it < "2025-06-01T00:00:00Z" }).isEqualTo(125) + assertThat(result.first()).isGreaterThan("2025-04-01T00:00:00Z") + assertThat(result.last()).isGreaterThan("2025-09-01T00:00:00Z") + } + + @Test + fun `the ceiling goes entirely to the future when nothing precedes the pivot`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + ExpansionWindow( + from = at("2025-01-01T00:00:00Z"), + until = at("2030-01-01T00:00:00Z"), + maxOccurrences = 4, + pivot = at("2025-01-01T00:00:00Z"), + ), + ) + assertThat(result).hasSize(4) + assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z") + } + + @Test + fun `an unbounded rule stops at the window end`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-10T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `the window end is exclusive`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2025-01-09T08:00:00Z"), + ) + assertThat(result).doesNotContain("2025-01-09T08:00:00Z") + } + + @Test + fun `occurrences before the window start are skipped`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-06-01T07:00:00Z", + "2025-06-02T07:00:00Z", + "2025-06-03T07:00:00Z", + ).inOrder() + } + + // --- DST and all-day ----------------------------------------------------- + + @Test + fun `a daily series keeps its local time across a DST boundary`() { + // Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from + // 08:00Z to 07:00Z while the wall-clock time the user set stays put. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z")) + assertThat(result).containsExactly( + "2025-03-28T08:00:00Z", + "2025-03-29T08:00:00Z", + "2025-03-30T07:00:00Z", + "2025-03-31T07:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day series pins every occurrence to UTC midnight`() { + // Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and + // the device zone are both irrelevant, and no DST shift reaches it. + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + anchor = "2025-03-29T22:45:00Z", + isAllDay = true, + timeZone = berlin, + ), + ) + assertThat(result).containsExactly( + "2025-03-29T00:00:00Z", + "2025-03-30T00:00:00Z", + "2025-03-31T00:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day weekly series stays on the same weekday`() { + val result = expand( + spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-22T00:00:00Z", + "2025-01-29T00:00:00Z", + ).inOrder() + } + + @Test + fun `a series with no zone expands in the floating zone`() { + val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null) + // 09:00 in New York, over the 2025-03-09 US DST switch. + assertThat(expand(spec, floatingZone = newYork)).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + // --- RDATE / EXDATE ------------------------------------------------------ + + @Test + fun `RDATE adds occurrences the rule does not produce`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=2", + rdate = "20250115T140000", + anchor = "2025-01-07T08:00:00Z", + ), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-15T13:00:00Z", // 14:00 Berlin + ).inOrder() + } + + @Test + fun `an RDATE before the anchor is part of the set`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z") + } + + @Test + fun `an RDATE repeating a rule instance is not emitted twice`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `multiple RDATEs are comma-separated`() { + val result = expand( + spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-10T08:00:00Z", + "2025-01-12T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE removes an occurrence`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `EXDATE can remove the anchor itself`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a UTC EXDATE matches a zoned occurrence at the same instant`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `an all-day EXDATE removes the matching date`() { + val result = expand( + spec( + rrule = "FREQ=DAILY;COUNT=3", + exdate = "20250116", + anchor = "2025-01-15T00:00:00Z", + isAllDay = true, + ), + ) + assertThat(result).containsExactly( + "2025-01-15T00:00:00Z", + "2025-01-17T00:00:00Z", + ).inOrder() + } + + // --- degenerate input ---------------------------------------------------- + + @Test + fun `a spec with no rule expands to just its anchor`() { + assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed rule degrades to the anchor instead of throwing`() { + assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z"))) + .containsExactly("2025-01-07T08:00:00Z") + } + + @Test + fun `a malformed RDATE is dropped and the rule still expands`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"), + ) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + ).inOrder() + } + + @Test + fun `an unknown zone id falls back to the floating zone`() { + val result = expand( + spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"), + floatingZone = newYork, + ) + assertThat(result).containsExactly( + "2025-03-08T14:00:00Z", + "2025-03-09T13:00:00Z", + ).inOrder() + } + + @Test + fun `a sub-second anchor does not double the first occurrence`() { + // RFC 5545 DATE-TIME has second precision, but a task created from + // Clock.now() carries millis. Left un-floored, lib-recur emits the raw + // anchor *and* its truncated self, so the series starts twice. + val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00.081Z")) + assertThat(result).containsExactly( + "2025-01-07T08:00:00Z", + "2025-01-08T08:00:00Z", + "2025-01-09T08:00:00Z", + ).inOrder() + } + + @Test + fun `a window that ends before the anchor yields nothing`() { + val result = expand( + spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"), + window(until = "2024-01-01T00:00:00Z"), + ) + assertThat(result).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceOccurrencesTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceOccurrencesTest.kt new file mode 100644 index 0000000..24760ca --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/RecurrenceOccurrencesTest.kt @@ -0,0 +1,194 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class RecurrenceOccurrencesTest { + + private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day) + + @Test + fun `daily rule starts at the task's own date`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Daily) + .upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 7, 31), + date(2026, 8, 1), + ).inOrder() + } + + @Test + fun `interval multiplies the period`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3) + .upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 2), + date(2026, 8, 5), + ).inOrder() + } + + @Test + fun `weekly without weekday picks repeats the start's weekday`() { + // 30 Jul 2026 is a Thursday. + val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly) + .upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 6), + date(2026, 8, 13), + ).inOrder() + } + + @Test + fun `weekly with picks fires on every chosen day, in weekday order`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY), + ).upcomingOccurrences(date(2026, 7, 30), limit = 4) + + // The Thursday start is an occurrence in its own right (RFC 5545 puts + // DTSTART in the set), then Friday 31 Jul and Mon/Fri of the next week. + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 7, 31), + date(2026, 8, 3), + date(2026, 8, 7), + ).inOrder() + } + + @Test + fun `the start counts once even when it is also one of the picks`() { + // Thursday start with Thursday picked: the seeded DTSTART and the rule's + // own hit are the same date and must not both be listed. + val occurrences = SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.THURSDAY), + ).upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 6), + date(2026, 8, 13), + ).inOrder() + } + + @Test + fun `weekly interval skips whole weeks, counted from Monday`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Weekly, + interval = 2, + byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY), + ).upcomingOccurrences(date(2026, 7, 30), limit = 3) + + // Start week (27 Jul–2 Aug) contributes only its Thursday; the next block + // is two weeks on, so 10 Aug and 13 Aug — not 3 Aug. + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 8, 10), + date(2026, 8, 13), + ).inOrder() + } + + @Test + fun `monthly skips months without the start day rather than clamping`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly) + .upcomingOccurrences(date(2026, 1, 31), limit = 4) + + // February, April and June have no 31st — the rule passes them by. + assertThat(occurrences).containsExactly( + date(2026, 1, 31), + date(2026, 3, 31), + date(2026, 5, 31), + date(2026, 7, 31), + ).inOrder() + } + + @Test + fun `yearly on 29 February only lands in leap years`() { + val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly) + .upcomingOccurrences(date(2024, 2, 29), limit = 3) + + assertThat(occurrences).containsExactly( + date(2024, 2, 29), + date(2028, 2, 29), + date(2032, 2, 29), + ).inOrder() + } + + @Test + fun `count limits the series and skipped periods don't consume one`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Monthly, + end = RecurrenceEnd.Count(3), + ).upcomingOccurrences(date(2026, 1, 31), limit = 10) + + assertThat(occurrences).containsExactly( + date(2026, 1, 31), + date(2026, 3, 31), + date(2026, 5, 31), + ).inOrder() + } + + @Test + fun `until is inclusive of its own date`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Daily, + end = RecurrenceEnd.Until(date(2026, 8, 1)), + ).upcomingOccurrences(date(2026, 7, 30), limit = 10) + + assertThat(occurrences).containsExactly( + date(2026, 7, 30), + date(2026, 7, 31), + date(2026, 8, 1), + ).inOrder() + } + + @Test + fun `an until before the start yields nothing instead of spinning`() { + val occurrences = SimpleRecurrence( + RecurrenceFreq.Daily, + end = RecurrenceEnd.Until(date(2026, 7, 1)), + ).upcomingOccurrences(date(2026, 7, 30), limit = 3) + + assertThat(occurrences).isEmpty() + } + + @Test + fun `a run inside one year needs no year, one that leaves it does`() { + val start = date(2026, 7, 30) + val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3) + assertThat(occurrencesSpanYears(withinYear, start)).isFalse() + + // Yearly: same day and month every time, so the year is the only thing + // telling the three dates apart. + val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3) + assertThat(occurrencesSpanYears(yearly, start)).isTrue() + + // Monthly rolling past December. + val newYearStart = date(2026, 11, 30) + val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly) + .upcomingOccurrences(newYearStart, limit = 3) + assertThat(overflowing).containsExactly( + date(2026, 11, 30), + date(2026, 12, 30), + date(2027, 1, 30), + ).inOrder() + assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue() + } + + @Test + fun `limit and count are both respected, whichever is smaller`() { + val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2)) + + assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2) + assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1) + assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/SimpleRecurrenceTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/SimpleRecurrenceTest.kt new file mode 100644 index 0000000..e7900d9 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/domain/recurrence/SimpleRecurrenceTest.kt @@ -0,0 +1,164 @@ +package de.jeanlucmakiola.agendula.domain.recurrence + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test + +class SimpleRecurrenceTest { + + private val utc = TimeZone.UTC + private val berlin = TimeZone.of("Europe/Berlin") + + @Test + fun `plain frequency parses with defaults`() { + assertThat(parseSimpleRecurrence("FREQ=WEEKLY")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly)) + assertThat(parseSimpleRecurrence("FREQ=DAILY")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily)) + } + + @Test + fun `leading RRULE prefix and WKST are tolerated`() { + assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly)) + } + + @Test + fun `interval parses`() { + assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2)) + } + + @Test + fun `until parses date-only and UTC datetime forms`() { + val expected = SimpleRecurrence( + RecurrenceFreq.Daily, + end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)), + ) + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected) + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc)) + .isEqualTo(expected) + } + + @Test + fun `until datetime converts from UTC into the given zone before taking the date`() { + // 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there. + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin)) + .isEqualTo( + SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))), + ) + } + + @Test + fun `count parses`() { + assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5")) + .isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5))) + } + + @Test + fun `weekly byday parses as weekday picks`() { + assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR")) + .isEqualTo( + SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY), + ), + ) + } + + @Test + fun `rules beyond the simple shape are rejected`() { + // Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules. + assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull() + assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull() + assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull() + assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull() + assertThat(parseSimpleRecurrence("")).isNull() + assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull() + assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull() + assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull() + } + + @Test + fun `toRRule renders the minimal form`() { + assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY") + assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule()) + .isEqualTo("FREQ=DAILY;INTERVAL=3") + assertThat( + SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(), + ).isEqualTo("FREQ=MONTHLY;COUNT=12") + } + + @Test + fun `toRRule renders weekdays in ISO order regardless of set order`() { + val rule = SimpleRecurrence( + RecurrenceFreq.Weekly, + byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY), + ).toRRule() + assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR") + } + + @Test + fun `toRRule ignores weekday picks on non-weekly frequencies`() { + val rule = SimpleRecurrence( + RecurrenceFreq.Monthly, + byDays = setOf(DayOfWeek.MONDAY), + ).toRRule() + assertThat(rule).isEqualTo("FREQ=MONTHLY") + } + + @Test + fun `toRRule writes until as the end of the chosen day in the given zone`() { + val rule = SimpleRecurrence( + RecurrenceFreq.Weekly, + interval = 2, + end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)), + ) + assertThat(rule.toRRule(utc)) + .isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z") + // 23:59:59 Berlin (CEST, +2) == 21:59:59Z. + assertThat(rule.toRRule(berlin)) + .isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z") + } + + // 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence. + private val cutoffMillis = 1_781_913_540_000L + + @Test + fun `truncation replaces count and keeps every other part`() { + assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis)) + .isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z") + } + + @Test + fun `truncation replaces an existing until`() { + assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis)) + .isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z") + } + + @Test + fun `truncation works on rules the simple picker cannot express`() { + assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis)) + .isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z") + } + + @Test + fun `parse and render round-trip`() { + val rules = listOf( + "FREQ=DAILY", + "FREQ=WEEKLY;INTERVAL=2", + "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z", + "FREQ=MONTHLY;COUNT=6", + "FREQ=YEARLY;UNTIL=20301231T235959Z", + ) + rules.forEach { rule -> + assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule) + } + // Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET. + val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z" + assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin)) + .isEqualTo(berlinRule) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt new file mode 100644 index 0000000..34fb130 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/accounts/add/AddAccountViewModelTest.kt @@ -0,0 +1,1330 @@ +package de.jeanlucmakiola.agendula.ui.accounts.add + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +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.caldav.CalDavDiscovery +import de.jeanlucmakiola.caldav.CalDavProvider +import de.jeanlucmakiola.caldav.NextcloudLoginFlow +import de.jeanlucmakiola.caldav.ServerQuirk +import de.jeanlucmakiola.caldav.TaskCollection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +/** + * The sign-in state machine. + * + * Worth testing closely: it decides where five discovery outcomes lead, when a + * one-shot app password is spent, and which host a credential is scoped to — + * all of which fail quietly, and none of which needs a server to exercise. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddAccountViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val gateway = FakeGateway() + private val creator = FakeCreator() + private val record = FakeLoginFlowRecord() + + @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterEach fun tearDown() = Dispatchers.resetMain() + + /** + * ⚠️ Not `backgroundScope`: `advanceUntilIdle` does not run its work, so a + * revocation launched there would silently never happen and every assertion + * about it would pass for the wrong reason. This scope shares the scheduler + * as ordinary work. + */ + private val appScope = CoroutineScope(dispatcher) + + /** + * A fresh wizard. [handle] is a seam for the restore tests: handing the same + * one to a second view model is exactly what process death does. + */ + private fun viewModel(handle: SavedStateHandle = SavedStateHandle()) = + AddAccountViewModel(creator, gateway, record, handle, appScope) + + @Nested + inner class TheProviderStep { + + @Test + fun `a service with an errand puts it on its own screen first`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.FASTMAIL)) + + // The whole reason the picker earns a step: Fastmail rejects the + // account password with a plain 401, and saying so afterwards is + // saying it too late. It is a screen rather than a note because + // it is four actions in someone else's web app. + val step = vm.state.value.step as AddAccountStep.PrepareAccess + assertThat(step.quirk).isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD) + + vm.onSetupAcknowledged() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.EnterAddress::class.java) + } + + @Test + fun `a service we cannot sync with does not advance`() = runTest(dispatcher) { + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.GOOGLE)) + + // Picking the wrong service is a tap to undo, so it stays on the + // picker rather than ending the flow the way a typed address does. + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.ChooseProvider::class.java) + assertThat(vm.state.value.quirk).isEqualTo(ServerQuirk.GOOGLE_UNSUPPORTED) + } + + @Test + fun `a service with nothing to prepare goes straight to the address`() = + runTest(dispatcher) { + val vm = viewModel() + val choice = ProviderChoice.Service(CalDavProvider.POSTEO) + // One tap, no Continue: the row is the decision. + vm.onProviderChosen(choice) + + // The step carries the choice because the question it asks + // depends on it: a hosted service is reached by an email address, + // a server by a URL. + assertThat((vm.state.value.step as AddAccountStep.EnterAddress).choice) + .isEqualTo(choice) + } + + @Test + fun `the address steps back to the picker`() = runTest(dispatcher) { + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + + assertThat(vm.onBackWithin()).isTrue() + assertThat(vm.state.value.step) + .isEqualTo(AddAccountStep.ChooseProvider(ProviderChoice.OtherServer)) + } + + @Test + fun `the address steps back into the errand when there was one`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.ICLOUD)) + vm.onSetupAcknowledged() + + // Back to the instructions, not past them: that is the screen + // someone returns to when they lose the app password. + assertThat(vm.onBackWithin()).isTrue() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.PrepareAccess::class.java) + assertThat(vm.onBackWithin()).isTrue() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.ChooseProvider::class.java) + } + + @Test + fun `the picker is the end of the road, so the host takes the back press`() = + runTest(dispatcher) { + val vm = viewModel() + vm.onProviderChosen(ProviderChoice.OtherServer) + vm.onBackWithin() + + // False is what makes the screen abandon rather than swallow the + // gesture — and abandoning is what runs onStartOver, which is the + // only thing that stops a poll and hands back a minted password. + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.ChooseProvider::class.java) + assertThat(vm.onBackWithin()).isFalse() + } + + @Test + fun `there is nowhere to step back to from the list picker`() = runTest(dispatcher) { + val vm = signedIn() + + // Back there would mean re-running discovery, so the host is left to + // decide what leaving means. + assertThat(vm.onBackWithin()).isFalse() + } + } + + @Nested + inner class TheAddressStep { + + @Test + fun `Google is refused before any request is made`() = runTest(dispatcher) { + val vm = viewModel() + vm.enterAddress("me@gmail.com") + vm.onAddressSubmitted() + advanceUntilIdle() + + // It supports neither VTODO nor MKCALENDAR — its own docs say so — so + // a 401 the user cannot act on is the wrong answer. + assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.GoogleUnsupported) + assertThat(gateway.discoveries).isEmpty() + } + + @Test + fun `a provider quirk is named while the user is still typing`() = runTest(dispatcher) { + val vm = viewModel() + vm.enterAddress("me@fastmail.com") + assertThat(vm.state.value.quirk).isNotNull() + } + + @Test + fun `an unauthenticated 200 offers sign-in rather than bouncing back`() = + runTest(dispatcher) { + // No credentials were sent, so RFC 5397's is + // not a rejection. Sending the user back to the address field + // would make such a server permanently unreachable. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Unauthenticated + gateway.loginFlow = null + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.state.value.step) + .isInstanceOf(AddAccountStep.EnterCredentials::class.java) + } + + @Test + fun `a server that is not CalDAV says so on the address step`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav( + CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT, + "no calendar-access", + ) + + val vm = viewModel() + vm.enterAddress("https://example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.EnterAddress + // ⚠️ A cause, never the server's own words — the screen owns the + // wording, and a raw status line bypasses strings.xml entirely. + assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT) + // The input survives, so the user can correct it rather than retype it. + assertThat(step.input).isEqualTo("https://example.com/") + } + } + + @Nested + inner class TheBrowserStep { + + @Test + fun `discovery after approval targets the server that issued the credentials`() = + runTest(dispatcher) { + // The host-mismatch case: the user typed one host, the server + // reported another. Probing the typed one with a credential scoped + // to the reported one 401s and burns the one-shot app password. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + NextcloudLoginFlow.Credentials( + server = "https://dav.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "app-pw", + ), + ) + gateway.discoveryOutcomes += found(collection("Tasks")) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(gateway.discoveries.last().target).isEqualTo("https://dav.example.com/") + assertThat(gateway.discoveries.last().credentials?.origin.toString()) + .isEqualTo("https://dav.example.com/") + } + + @Test + fun `an origin the server got wrong is surfaced and stays visible`() = + runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + credentials = NextcloudLoginFlow.Credentials( + server = "https://cloud.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "app-pw", + ), + hostMismatch = NextcloudLoginFlow.HostMismatch( + expected = "cloud.example.com", + actual = "nextcloud", + ), + ) + gateway.discoveryOutcomes += found(collection("Tasks")) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + // Sticky: it is learned mid-flow, but the setting it blames is + // what the user has to go and fix afterwards. + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.ChooseLists::class.java) + assertThat(vm.state.value.originMismatch?.actual).isEqualTo("nextcloud") + } + + @Test + fun `a discovery failure after approval reports what actually failed`() = + runTest(dispatcher) { + // The first outcome is the unauthenticated probe that sends us + // into the browser flow; the second is the discovery that runs + // once the credentials come back. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + NextcloudLoginFlow.Credentials( + server = "https://cloud.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "app-pw", + ), + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "nextcloud: nodename nor servname provided", + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + // ⚠️ Collapsing this to NO_CALENDARS tells someone with a DNS + // failure that their account holds no task lists — wrong, and + // nothing they can act on. + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE) + } + + @Test + fun `the login URL is handed to the browser exactly once`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + // runCurrent, not advanceUntilIdle: the poll loop's first act is a + // delay, so this settles discovery without consuming the flow. + runCurrent() + + assertThat(vm.state.value.openInBrowser).isNotNull() + vm.onBrowserLaunched() + assertThat(vm.state.value.openInBrowser).isNull() + vm.onStartOver() + } + + @Test + fun `polling stops on its own rather than running forever`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + // The fake never approves, so only the loop's own bound ends it. + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.WaitingForBrowser + assertThat(step.error).isNotNull() + } + + @Test + fun `a failed sign-in is retried on the same flow`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Failed( + NextcloudLoginFlow.PollResult.Cause.SERVER_ERROR, + "HTTP 500", + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + vm.onBrowserLaunched() + advanceTimeBy(3_000) + runCurrent() + assertThat((vm.state.value.step as AddAccountStep.WaitingForBrowser).error) + .isEqualTo(AddAccountMessage.BrowserFailed) + + vm.onBrowserRetry() + runCurrent() + + // The old flow is still approvable, so it is reopened rather than + // replaced: a second flow would overwrite the record that collects + // an approval made in the first tab. + val step = vm.state.value.step as AddAccountStep.WaitingForBrowser + assertThat(step.error).isNull() + assertThat(vm.state.value.openInBrowser).isNotNull() + assertThat(gateway.loginFlowsStarted).isEqualTo(1) + vm.onStartOver() + } + + @Test + fun `an expired sign-in is retried with a fresh flow`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Expired("window closed") + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + vm.onBrowserLaunched() + advanceTimeBy(3_000) + runCurrent() + + vm.onBrowserRetry() + runCurrent() + + assertThat((vm.state.value.step as AddAccountStep.WaitingForBrowser).error).isNull() + assertThat(gateway.loginFlowsStarted).isEqualTo(2) + vm.onStartOver() + } + + @Test + fun `a server with no login flow falls back to a password`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = null + + val vm = viewModel() + vm.enterAddress("https://baikal.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.state.value.step) + .isInstanceOf(AddAccountStep.EnterCredentials::class.java) + } + } + + /** + * The gate in front of the Custom Tab. + * + * The login page is where the **account** password is typed, and the note + * about it used to arrive in the same state update as the URL — so it drew + * behind a browser that was already open, which is not a confirmation. + */ + @Nested + inner class ConfirmingTheLoginPage { + + @Test + fun `a host mismatch stops short of opening the browser`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow( + hostMismatch = NextcloudLoginFlow.HostMismatch( + expected = "cloud.example.com", + actual = "nextcloud", + ), + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + + val step = vm.state.value.step as AddAccountStep.ConfirmBrowser + assertThat(step.hostMismatch?.actual).isEqualTo("nextcloud") + // The two halves of the defect: no URL handed out, and nothing + // written down for a page nobody has agreed to open. + assertThat(vm.state.value.openInBrowser).isNull() + assertThat(record.remembered).isNull() + } + + @Test + fun `a cleartext login page is confirmed too`() = runTest(dispatcher) { + // requireSecureOrigin cannot fire here: the base was typed as http, + // so a cleartext login URL is consistent rather than a downgrade — + // and it is still the page the account password is typed into. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow(loginUrl = "http://cloud.example.com/login/flow") + + val vm = viewModel() + vm.enterAddress("http://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + + assertThat((vm.state.value.step as AddAccountStep.ConfirmBrowser).insecure).isTrue() + assertThat(vm.state.value.openInBrowser).isNull() + } + + @Test + fun `confirming opens the page and starts waiting for it`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow( + hostMismatch = NextcloudLoginFlow.HostMismatch("cloud.example.com", "nextcloud"), + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + vm.onBrowserConfirmed() + runCurrent() + + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.WaitingForBrowser::class.java) + assertThat(vm.state.value.openInBrowser).isNotNull() + // Persisted here rather than at the start, which is what the flow's + // own doc asks for: before the browser, not before the question. + assertThat(record.remembered).isEqualTo(gateway.loginFlow) + } + + @Test + fun `declining goes back to the address that produced the note`() = + runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow( + hostMismatch = NextcloudLoginFlow.HostMismatch("cloud.example.com", "nextcloud"), + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + + // The note blames an address, so the way out reaches the field + // that holds it. Nothing was minted or written down, so there is + // nothing to hand back. + assertThat(vm.onBackWithin()).isTrue() + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.input).isEqualTo("https://cloud.example.com/") + assertThat(record.remembered).isNull() + } + + @Test + fun `a clean flow opens without asking`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + runCurrent() + + // Nothing to confirm is not a screen: the common case must not grow + // a step for the sake of the uncommon one. + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.WaitingForBrowser::class.java) + assertThat(vm.state.value.openInBrowser).isNotNull() + } + } + + @Nested + inner class SurvivingTheProcess { + + @Test + fun `the flow is written down before the browser is offered the URL`() = + runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + gateway.loginFlow = flow() + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + // Not advanceUntilIdle: that would run the whole twenty-minute + // poll loop out to its expiry, which forgets the flow again. + runCurrent() + + // ⚠️ The browser is a separate task, so dying while the user + // approves is ordinary — and the poll token is the only way back + // to the password the server is about to mint. + assertThat(record.remembered).isEqualTo(gateway.loginFlow) + assertThat(vm.state.value.openInBrowser).isNotNull() + } + + @Test + fun `a spent flow is not left behind to be polled again`() = runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + NextcloudLoginFlow.Credentials( + server = "https://cloud.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "app-pw", + ), + ) + gateway.discoveryOutcomes += found(collection("Tasks")) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + // The server deleted the flow row before answering, so the token now + // buys nothing and a reclaim would poll it for no reason. + assertThat(record.remembered).isNull() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.ChooseLists::class.java) + } + + @Test + fun `an abandoned flow is kept, because the user may still approve it`() = + runTest(dispatcher) { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Expired("window closed") + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onStartOver() + advanceUntilIdle() + + // ⚠️ We gave up; the browser tab did not. Approving in it still + // mints a password, and this record is the only thing that can + // collect it and hand it back — a poll past the deadline is what + // retires it, not our giving up. + assertThat(record.remembered).isNotNull() + assertThat(record.log).containsExactly("remember") + } + } + + /** + * What a killed process leaves behind. + * + * The browser is a separate task, so ours is a background process while the + * user approves and the system is entitled to kill it. A second view model + * over the same [SavedStateHandle] is exactly that. + */ + @Nested + inner class RestoringAfterProcessDeath { + + @Test + fun `a minted password survives and the lists are re-read`() = runTest(dispatcher) { + val handle = SavedStateHandle() + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + NextcloudLoginFlow.Credentials( + server = "https://cloud.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "app-pw", + ), + ) + gateway.discoveryOutcomes += found(collection("Tasks"), collection("Shopping")) + + val first = viewModel(handle) + first.enterAddress("https://cloud.example.com/") + first.onAddressSubmitted() + advanceUntilIdle() + (first.state.value.step as AddAccountStep.ChooseLists).let { step -> + first.onListToggled(step.collections.last().url) + } + + // The process dies here — after approval, holding a one-shot app + // password the server will never issue again. + gateway.discoveryOutcomes += found(collection("Tasks"), collection("Shopping")) + val second = viewModel(handle) + advanceUntilIdle() + + val step = second.state.value.step as AddAccountStep.ChooseLists + // Re-read rather than restored: `found` is not saved, so the server + // answers for it again — with the credential that survived. + assertThat(gateway.discoveries.last().credentials?.password).isEqualTo("app-pw") + // And what the user had ticked is still ticked. + assertThat(step.selected.map { it.toString() }) + .containsExactly("https://cloud.example.com/dav/Tasks/") + } + + @Test + fun `a typed password is not written down`() = runTest(dispatcher) { + val handle = SavedStateHandle() + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Unauthenticated + gateway.loginFlow = null + + val first = viewModel(handle) + first.enterAddress("https://cloud.example.com/") + first.onAddressSubmitted() + advanceUntilIdle() + first.onUsernameChanged("me") + first.onPasswordChanged("account-password") + + val second = viewModel(handle) + advanceUntilIdle() + + // ⚠️ The username comes back, the password does not. It is the + // user's own account password on this route, retyping it costs one + // field, and saved state is written out by the system. + val step = second.state.value.step as AddAccountStep.EnterCredentials + assertThat(step.username).isEqualTo("me") + assertThat(step.password).isEmpty() + } + + @Test + fun `the address that was typed comes back with it`() = runTest(dispatcher) { + val handle = SavedStateHandle() + val first = viewModel(handle) + first.enterAddress("https://cloud.example.com/") + + val step = viewModel(handle).state.value.step as AddAccountStep.EnterAddress + assertThat(step.input).isEqualTo("https://cloud.example.com/") + assertThat(step.choice).isEqualTo(ProviderChoice.OtherServer) + } + + @Test + fun `a service picked by name comes back as that service`() = runTest(dispatcher) { + val handle = SavedStateHandle() + viewModel(handle).onProviderChosen(ProviderChoice.Service(CalDavProvider.FASTMAIL)) + + // The errand screen is a step of its own, and it is the one the + // choice lands on — so restoring the choice has to restore that too. + val step = viewModel(handle).state.value.step as AddAccountStep.PrepareAccess + assertThat(step.choice).isEqualTo(ProviderChoice.Service(CalDavProvider.FASTMAIL)) + } + + @Test + fun `dying at the browser step lands back on the address`() = runTest(dispatcher) { + val handle = SavedStateHandle() + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = flow() + + val first = viewModel(handle) + first.enterAddress("https://cloud.example.com/") + first.onAddressSubmitted() + runCurrent() + + // Nothing to resume: the flow is out at a browser this process no + // longer owns, and the persisted record is what deals with it. + val step = viewModel(handle).state.value.step as AddAccountStep.EnterAddress + assertThat(step.input).isEqualTo("https://cloud.example.com/") + } + + @Test + fun `starting over leaves nothing to restore`() = runTest(dispatcher) { + val handle = SavedStateHandle() + val first = viewModel(handle) + first.enterAddress("https://cloud.example.com/") + first.onStartOver() + + val step = viewModel(handle).state.value.step as AddAccountStep.ChooseProvider + assertThat(step.choice).isNull() + } + } + + @Nested + inner class AbandoningTheBrowserFlow { + + @Test + fun `stepping back from the credentials hands the minted password back`() = + runTest(dispatcher) { + val vm = approveThen( + CalDavDiscovery.Outcome.NeedsAuthentication(listOf("cloud.example.com")), + ) + // The credentials step is reachable *from* an approved browser + // flow, so stepping back off it is one of the routes that leaks a + // one-shot password if it does not clean up. + vm.onBrowserCancelled() + gateway.revoked.clear() + vm.onBackWithin() + advanceUntilIdle() + + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.EnterAddress::class.java) + } + + private fun TestScope.approveThen(outcome: CalDavDiscovery.Outcome): AddAccountViewModel { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + NextcloudLoginFlow.Credentials( + server = "https://dav.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "app-pw", + ), + ) + gateway.discoveryOutcomes += outcome + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + return vm + } + + @Test + fun `a password nobody will use is handed back`() = runTest(dispatcher) { + val vm = approveThen( + CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "no route to host", + ), + ) + + // ⚠️ The path the user actually has. After a post-approval failure + // the screen shows the address with an error and a *Continue* button + // — there is no start-over here, so a test that used one would pass + // against an affordance nobody can reach. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "no route to host", + ) + vm.onAddressSubmitted() + advanceUntilIdle() + + // Nextcloud hands it over exactly once. Walking away leaves it valid + // for ever, under the same name as every other attempt — so the user + // cannot tell which entry their working account uses. + val handedBack = gateway.revoked.single() + assertThat(handedBack.password).isEqualTo("app-pw") + // The server that issued it, not the one the user typed. + assertThat(handedBack.origin.toString()).isEqualTo("https://dav.example.com/") + } + + @Test + fun `an account with no usable lists hands its password back`() = runTest(dispatcher) { + val vm = approveThen(found()) + + // fatal() offers only "start over", so that is the whole exit. + vm.onStartOver() + advanceUntilIdle() + + assertThat(gateway.revoked).hasSize(1) + } + + @Test + fun `leaving the screen hands the password back`() = runTest(dispatcher) { + val vm = approveThen( + CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "no route to host", + ), + ) + + vm.abandonMintedPassword() + advanceUntilIdle() + + assertThat(gateway.revoked).hasSize(1) + } + + @Test + fun `a saved account keeps its password`() = runTest(dispatcher) { + val vm = approveThen(found(collection("Tasks"))) + vm.onSave() + advanceUntilIdle() + + // What the screen does once it is Done. + vm.onStartOver() + advanceUntilIdle() + + // ⚠️ The one way this fix can do real harm: revoking the credential + // of the account just created. It would sync once and then 401 for + // ever, long after anyone connects it to adding the account. + assertThat(gateway.revoked).isEmpty() + } + + @Test + fun `a typed password after an approval hands the minted one back`() = + runTest(dispatcher) { + val vm = approveThen( + CalDavDiscovery.Outcome.NeedsAuthentication(listOf("cloud.example.com")), + ) + // Retry, this time with no login flow on offer, so the user types + // their own password instead. + gateway.loginFlow = null + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + vm.onAddressSubmitted() + advanceUntilIdle() + + // The retry itself hands back the first password. + assertThat(gateway.revoked.map { it.password }).containsExactly("app-pw") + } + + @Test + fun `a revoke that fails is never surfaced`() = runTest(dispatcher) { + gateway.revokeSucceeds = false + val vm = approveThen( + CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "no route to host", + ), + ) + + vm.onStartOver() + advanceUntilIdle() + + assertThat(vm.state.value.fatal).isNull() + assertThat(vm.state.value.step).isInstanceOf(AddAccountStep.ChooseProvider::class.java) + } + + @Test + fun `a rejected password is not reported as an empty account`() = runTest(dispatcher) { + val vm = approveThen( + CalDavDiscovery.Outcome.NeedsAuthentication(listOf("cloud.example.com")), + ) + + // "Signed in, but no task lists" said of a credential the server had + // just refused. Same registrable domain, so retrying may help. + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.SERVER_ERROR) + } + + @Test + fun `a home set outside the credential scope is called what it is`() = + runTest(dispatcher) { + val vm = approveThen( + CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.elsewhere.org")), + ) + + // Retrying cannot reach it, and each attempt mints another + // password — so this is fatal rather than back-to-the-address. + // Stronger than a substring: it pins the host into the argument + // rather than anywhere in a sentence, so the translation decides + // where it goes. + assertThat(vm.state.value.fatal) + .isEqualTo(AddAccountMessage.OutsideCredentialScope("dav.elsewhere.org")) + } + } + + @Nested + inner class TheCredentialsStep { + + @Test + fun `an address with no parseable root is an error, not a silent retry`() = + runTest(dispatcher) { + // serverRootFor cannot parse a host-with-path, and passing null + // sent no credentials at all — then reported the 401 as + // "credentials rejected" about a password nothing had tried. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = null + + val vm = viewModel() + vm.enterAddress("cloud.example.com/nextcloud") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onUsernameChanged("me") + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS) + // One discovery, the anonymous one. Nothing was sent blind. + assertThat(gateway.discoveries).hasSize(1) + } + + @Test + fun `the authenticated pass refreshes the hosts the diagnostic reads`() = + runTest(dispatcher) { + // The anonymous probe stops at the root; an authenticated one + // reaches the principal and its home sets, so the cross-domain + // home set behind the 401 first appears here. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = null + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("dav.elsewhere.test"), + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onUsernameChanged("me") + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.EnterCredentials + assertThat(step.error) + .isEqualTo(AddAccountMessage.OutsideCredentialScope("dav.elsewhere.test")) + } + } + + @Nested + inner class ChoosingLists { + + @Test + fun `writable lists are pre-ticked and read-only ones are not`() = runTest(dispatcher) { + gateway.discoveryOutcomes += found( + collection("Mine"), + collection("Someone else's", readOnly = true), + ) + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + val step = vm.state.value.step as AddAccountStep.ChooseLists + assertThat(step.selected).hasSize(1) + assertThat(step.selected.single().toString()).contains("Mine") + } + + @Test + fun `an account with no usable lists is a dead end worth explaining`() = + runTest(dispatcher) { + gateway.discoveryOutcomes += found() + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.NoUsableLists) + } + } + + @Nested + inner class Finishing { + + @Test + fun `starting over clears the previous account's credentials`() = runTest(dispatcher) { + // Without this, a second account is created with the first account's + // username and app password. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = null + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onUsernameChanged("me") + vm.onPasswordChanged("first-pw") + + vm.onStartOver() + + assertThat(vm.state.value).isEqualTo(AddAccountUiState()) + // And a save attempt now has nothing to save, rather than reusing it. + gateway.discoveryOutcomes += found(collection("Tasks")) + vm.enterAddress("https://other.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onSave() + advanceUntilIdle() + assertThat(creator.created).isEmpty() + assertThat(vm.state.value.step) + .isInstanceOf(AddAccountStep.EnterCredentials::class.java) + } + + @Test + fun `a new address does not inherit the previous server's credentials`() = + runTest(dispatcher) { + // Approve on A, discovery fails afterwards, type B — which + // answers anonymously. The screen offers *Continue* here, not + // start over, so nothing else clears what A minted. + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication( + listOf("cloud.example.com"), + ) + gateway.loginFlow = flow() + gateway.pollResults += NextcloudLoginFlow.PollResult.Approved( + NextcloudLoginFlow.Credentials( + server = "https://cloud.example.com/".toHttpUrl(), + loginName = "me", + appPassword = "a-pw", + ), + ) + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.UNREACHABLE, + "no route to host", + ) + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + gateway.loginFlow = null + gateway.discoveryOutcomes += found(collection("Tasks")) + vm.enterAddress("https://other.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + vm.onSave() + advanceUntilIdle() + + // ⚠️ Otherwise an account named me@other.example.com is created + // carrying cloud.example.com's app password. + assertThat(creator.created).isEmpty() + assertThat(vm.state.value.step) + .isInstanceOf(AddAccountStep.EnterCredentials::class.java) + } + + @Test + fun `a failure inside create leaves a translatable message, not a spinner`() = + runTest(dispatcher) { + creator.thrown = IllegalStateException("UNIQUE constraint failed") + + val vm = signedIn() + vm.onSave() + advanceUntilIdle() + + // ⚠️ The exception's words are deliberately gone. They are + // untranslated and unreadable; the cause is what the user sees, + // and the detail is kept for logs. + assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.NotSaved) + assertThat(vm.state.value.step).isNotInstanceOf(AddAccountStep.Working::class.java) + } + + @Test + fun `an add refused for External storage says why`() = runTest(dispatcher) { + creator.outcome = AccountRepository.Outcome.ExternalStorage + + val vm = signedIn() + vm.onSave() + advanceUntilIdle() + + assertThat(vm.state.value.fatal).isEqualTo(AddAccountMessage.ExternalStorage) + } + + @Test + fun `a completed add reports the account it created`() = runTest(dispatcher) { + val vm = signedIn() + vm.onSave() + advanceUntilIdle() + + assertThat(creator.created).containsExactly("me@cloud.example.com") + + // The receipt, not the exit: the flow says what it built before it + // hands back, and only the user's acknowledgement ends it. + val summary = vm.state.value.step as AddAccountStep.Summary + assertThat(summary.title).isEqualTo("cloud.example.com") + assertThat(summary.username).isEqualTo("me") + assertThat(summary.lists).containsExactly("Tasks") + + vm.onSummaryDone() + assertThat(vm.state.value.step).isEqualTo(AddAccountStep.Done) + } + } + + @Nested + inner class SigningInAgain { + + @Test + fun `the server and username come prefilled`() = runTest(dispatcher) { + val vm = viewModel() + vm.startReauthentication( + accountId = 9, + principalUrl = "https://host.example.com/nextcloud/remote.php/dav/principals/users/me/", + username = "me", + ) + + val address = vm.state.value.step as AddAccountStep.EnterAddress + assertThat(address.choice).isEqualTo(ProviderChoice.Service(CalDavProvider.NEXTCLOUD)) + // The root, which is where the login flow lives on a subpath install. + assertThat(address.input).isEqualTo("https://host.example.com/nextcloud") + assertThat(vm.state.value.reauthenticating).isTrue() + + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + vm.onAddressSubmitted() + advanceUntilIdle() + + assertThat((vm.state.value.step as AddAccountStep.EnterCredentials).username).isEqualTo("me") + } + + @Test + fun `the save is aimed at the same account`() = runTest(dispatcher) { + val vm = viewModel() + vm.startReauthentication(9, "https://cloud.example.com/dav/principals/me/", "me") + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + vm.onAddressSubmitted() + advanceUntilIdle() + gateway.discoveryOutcomes += found(collection("Tasks")) + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + + vm.onSave() + advanceUntilIdle() + + assertThat(creator.reauthenticated).containsExactly(9L) + } + + @Test + fun `starting over forgets the account being signed in to`() = runTest(dispatcher) { + val vm = viewModel() + vm.startReauthentication(9, "https://cloud.example.com/dav/principals/me/", "me") + vm.onStartOver() + + assertThat(vm.state.value.reauthenticating).isFalse() + vm.enterAddress("https://cloud.example.com/") + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + vm.onAddressSubmitted() + advanceUntilIdle() + gateway.discoveryOutcomes += found(collection("Tasks")) + vm.onUsernameChanged("me") + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + vm.onSave() + advanceUntilIdle() + + assertThat(creator.reauthenticated).containsExactly(null) + } + + @Test + fun `a server without a subpath is its origin`() { + assertThat( + AddAccountViewModel.serverAddressOf("https://radicale.example.com/user/".toHttpUrl()), + ).isEqualTo("https://radicale.example.com") + } + } + + /** + * A ViewModel driven the way the screen drives it, up to the list step: + * address → credentials → discovery → lists. Credentials are captured on + * submit, so going straight from the address to a `Found` leaves the + * username and password empty. + */ + private fun kotlinx.coroutines.test.TestScope.signedIn(): AddAccountViewModel { + gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) + gateway.loginFlow = null + + val vm = viewModel() + vm.enterAddress("https://cloud.example.com/") + vm.onAddressSubmitted() + advanceUntilIdle() + + gateway.discoveryOutcomes += found(collection("Tasks")) + vm.onUsernameChanged("me") + vm.onPasswordChanged("pw") + vm.onCredentialsSubmitted() + advanceUntilIdle() + return vm + } + + /** + * The taps and typing that put a bare address into the flow: step 1's "Other + * server", then the field. An unnamed server is what a typed address *is*, so + * every test that only cares about discovery takes this route. + */ + private fun AddAccountViewModel.enterAddress(input: String) { + onProviderChosen(ProviderChoice.OtherServer) + onAddressChanged(input) + } + + // ------------------------------------------------------------- fixtures + + private fun collection(name: String, readOnly: Boolean = false) = TaskCollection( + url = "https://cloud.example.com/dav/$name/".toHttpUrl(), + displayName = name, + color = null, + readOnly = readOnly, + isShared = false, + supportsSyncCollection = true, + maxResourceSize = null, + ) + + private fun found(vararg collections: TaskCollection) = CalDavDiscovery.Outcome.Found( + principal = "https://cloud.example.com/principals/me/".toHttpUrl(), + collections = collections.toList(), + homeSets = listOf("https://cloud.example.com/dav/".toHttpUrl()), + movedTo = null, + crossHostHomeSets = emptyList(), + ) + + private fun flow( + loginUrl: String = "https://cloud.example.com/login/flow", + hostMismatch: NextcloudLoginFlow.HostMismatch? = null, + ) = NextcloudLoginFlow.Flow( + loginUrl = loginUrl.toHttpUrl(), + pollEndpoint = "https://cloud.example.com/login/v2/poll".toHttpUrl(), + pollToken = "token", + deadlineEpochSeconds = Long.MAX_VALUE, + hostMismatch = hostMismatch, + ) + + private class FakeGateway : CalDavGateway { + data class Call(val target: String, val credentials: CalDavGateway.Credentials?) + + val discoveries = mutableListOf() + val discoveryOutcomes = ArrayDeque() + val pollResults = ArrayDeque() + var loginFlow: NextcloudLoginFlow.Flow? = null + + override suspend fun discover( + target: String, + credentials: CalDavGateway.Credentials?, + ): CalDavDiscovery.Outcome { + discoveries += Call(target, credentials) + return discoveryOutcomes.removeFirstOrNull() + ?: CalDavDiscovery.Outcome.Failed( + CalDavDiscovery.Outcome.Cause.SERVER_ERROR, + "no outcome queued", + ) + } + + var loginFlowsStarted = 0 + + override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? { + loginFlowsStarted++ + return loginFlow + } + + override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow) = + pollResults.removeFirstOrNull() ?: NextcloudLoginFlow.PollResult.Pending + + /** Revocation is best effort and the flow never depends on it. */ + override suspend fun revokeAppPassword( + credentials: CalDavGateway.Credentials, + ): Boolean = true + + val revoked = mutableListOf() + var revokeSucceeds = true + + override suspend fun revokeIssuedAppPassword( + credentials: CalDavGateway.Credentials, + ): Boolean { + revoked += credentials + return revokeSucceeds + } + } + + /** Remembers what the flow told it, in the order it was told. */ + private class FakeLoginFlowRecord : LoginFlowRecord { + val log = mutableListOf() + var remembered: NextcloudLoginFlow.Flow? = null + + override suspend fun remember(flow: NextcloudLoginFlow.Flow) { + remembered = flow + log += "remember" + } + + override suspend fun forget() { + remembered = null + log += "forget" + } + } + + private class FakeCreator : AccountCreator { + val created = mutableListOf() + val reauthenticated = mutableListOf() + var thrown: Throwable? = null + var outcome: AccountRepository.Outcome? = null + + override suspend fun create( + displayName: String, + username: String, + appPassword: String, + found: CalDavDiscovery.Outcome.Found, + selected: Set, + reauthenticating: Long?, + ): AccountRepository.Outcome { + reauthenticated += reauthenticating + thrown?.let { throw it } + outcome?.let { return it } + created += displayName + return AccountRepository.Outcome.Created(created.size.toLong()) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/common/RecurrenceTextTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/common/RecurrenceTextTest.kt new file mode 100644 index 0000000..d1353ab --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/common/RecurrenceTextTest.kt @@ -0,0 +1,67 @@ +package de.jeanlucmakiola.agendula.ui.common + +import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId +import org.junit.jupiter.api.Test + +/** + * The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately + * writes the end of the chosen *local* day expressed in UTC, so displaying it + * means converting back into the device zone first. + */ +class RecurrenceTextTest { + + private val berlin = ZoneId.of("Europe/Berlin") + private val losAngeles = ZoneId.of("America/Los_Angeles") + private val utc = ZoneId.of("UTC") + + @Test + fun `UTC form converts back to the picked day west of UTC`() { + // toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z + // on 1 Jan. Reading the digits raw would show 1 Jan 2027. + assertThat(untilLocalDate("20270101T075959Z", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form converts back to the picked day east of UTC`() { + // Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day. + assertThat(untilLocalDate("20261231T225959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form is unchanged at UTC itself`() { + assertThat(untilLocalDate("20261231T235959Z", utc)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `summer offset is honoured, not a fixed one`() { + // Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z. + assertThat(untilLocalDate("20260801T215959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 8, 1)) + } + + @Test + fun `date-only form is already local and passes through`() { + assertThat(untilLocalDate("20261231", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `floating date-time form uses its date as-is`() { + // No trailing Z, so it isn't an instant — no conversion may be applied. + assertThat(untilLocalDate("20261231T235959", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `garbage returns null rather than throwing`() { + assertThat(untilLocalDate("", berlin)).isNull() + assertThat(untilLocalDate("nonsense", berlin)).isNull() + assertThat(untilLocalDate("2026", berlin)).isNull() + assertThat(untilLocalDate("20261340", berlin)).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt new file mode 100644 index 0000000..022915d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/ui/navigation/DestinationsTest.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.agendula.ui.navigation + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class DestinationsTest { + + @Test + fun `task detail carries the occurrence only when there is one`() { + assertThat(Dest.TaskDetail.build(7)).isEqualTo("taskDetail/7") + assertThat(Dest.TaskDetail.build(7, 1_700_000_000_000)).isEqualTo("taskDetail/7?occ=1700000000000") + assertThat(Dest.TaskDetail.route).isEqualTo("taskDetail/{taskId}?occ={occ}") + } + + @Test + fun `settings opens on the hub unless a section is named`() { + assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS) + assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt new file mode 100644 index 0000000..cbc4a77 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetDataTest.kt @@ -0,0 +1,101 @@ +package de.jeanlucmakiola.agendula.widget + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.SmartList +import de.jeanlucmakiola.agendula.domain.TaskFilter +import de.jeanlucmakiola.agendula.domain.TaskSorting +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.allDayInstantOf +import de.jeanlucmakiola.agendula.domain.testTask +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.time.Instant + +class WidgetDataTest { + + private val newYork = ZoneId.of("America/New_York") + private val today = LocalDate.of(2026, 7, 20) + + private fun at(local: String, zone: ZoneId = newYork): Instant = + Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli()) + + private val now = at("2026-07-20T12:00:00") + + // --- filter storage ------------------------------------------------------ + + @Test + fun `every offered filter round-trips through storage`() { + val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42) + filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) } + } + + @Test + fun `a fresh widget shows Today`() { + assertThat(parseWidgetFilter(null)).isEqualTo(TaskFilter.Smart(SmartList.TODAY)) + } + + @Test + fun `garbage and smart lists the widget does not offer fall back to Today`() { + listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach { + assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER) + } + } + + // --- due classification -------------------------------------------------- + + @Test + fun `an all-day due is read as its UTC date, not the local one`() { + // UTC midnight of the 21st is still the 20th in New York; read as timed it would say today. + val tomorrowAllDay = allDayInstantOf(today.plusDays(1)) + assertThat(dueKind(tomorrowAllDay, allDay = true, today = today, zone = newYork)).isEqualTo(DueKind.TOMORROW) + assertThat(isOverdue(allDayInstantOf(today), allDay = true, now = now, today = today, zone = newYork)).isFalse() + } + + @Test + fun `a timed task earlier today is today, and overdue`() { + val morning = at("2026-07-20T09:00:00") + assertThat(dueKind(morning, allDay = false, today = today, zone = newYork)).isEqualTo(DueKind.TODAY) + assertThat(isOverdue(morning, allDay = false, now = now, today = today, zone = newYork)).isTrue() + } + + @Test + fun `past and later days classify by date`() { + assertThat(dueKind(at("2026-07-18T09:00:00"), false, today, newYork)).isEqualTo(DueKind.PAST) + assertThat(dueKind(at("2026-07-25T09:00:00"), false, today, newYork)).isEqualTo(DueKind.LATER) + assertThat(isOverdue(allDayInstantOf(today.minusDays(1)), true, now, today, newYork)).isTrue() + } + + // --- rows ------------------------------------------------------------------ + + @Test + fun `closed tasks are dropped and the rest sorted`() { + val rows = widgetRows( + listOf( + testTask(id = 1, title = "later", due = at("2026-07-22T09:00:00")), + testTask(id = 2, title = "done", status = TaskStatus.COMPLETED), + testTask(id = 3, title = "soon", due = at("2026-07-20T15:00:00")), + ), + TaskSorting.DEFAULT, + now, + newYork, + ) + assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder() + } + + @Test + fun `a subtask is left to its parent when both are shown, kept when orphaned`() { + val parent = testTask(id = 1, title = "parent") + val child = testTask(id = 2, title = "child").copy(parentId = 1) + val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99) + val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork) + assertThat(rows.map { it.title }).containsExactly("parent", "orphan") + } + + @Test + fun `rows are capped`() { + val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") } + assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt new file mode 100644 index 0000000..9769a0a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/widget/WidgetRolloverSchedulerTest.kt @@ -0,0 +1,179 @@ +package de.jeanlucmakiola.agendula.widget + +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */ +class WidgetRolloverSchedulerTest { + + private val berlin = TimeZone.of("Europe/Berlin") + + private fun at(local: String, zone: TimeZone): Instant = + LocalDateTime.parse(local).toInstant(zone) + + private fun nextRollover(local: String, zone: TimeZone = berlin): Instant = + WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone) + + // --- the ordinary day ---------------------------------------------------- + + @Test + fun `midday rolls over at the coming midnight`() { + val next = nextRollover("2026-08-27T12:00:00") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `a second before midnight still targets tonight, not tomorrow night`() { + val next = nextRollover("2026-08-27T23:59:59") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `at midnight exactly the target is the next day, never the current instant`() { + // Re-arming after a firing must move a whole day on, or the widget wakes + // itself in a tight loop. + val now = at("2026-08-28T00:00:00", berlin) + val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) + assertThat(next - now).isGreaterThan(Duration.ZERO) + } + + @Test + fun `re-arming from the slack instant itself moves a full day on`() { + // What actually happens in practice: the receiver runs at midnight + slack. + val now = at("2026-08-28T00:00:05", berlin) + assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin)) + .isEqualTo(at("2026-08-29T00:00:05", berlin)) + } + + @Test + fun `the result is always in the future for every minute of a day`() { + val zone = berlin + // Spans Berlin's 2024 spring-forward: the likeliest source of a target + // in the past, i.e. an alarm that fires immediately, forever. + var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone) + val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone) + while (probe < end) { + assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe) + probe += 1.minutes + } + } + + // --- daylight saving ----------------------------------------------------- + + @Test + fun `spring forward keeps the rollover one day away, not one hour short`() { + // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long. + // A rollover computed as "now + 24h" would land at 01:00 on 1 April. + val now = at("2024-03-30T12:00:00", berlin) + val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin)) + assertThat(next - now).isLessThan(24.hours) + } + + @Test + fun `fall back does not overshoot into the repeated hour`() { + // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so + // "now + 24h" would land at 23:00 on the 26th and never roll over. + val now = at("2024-10-26T12:00:00", berlin) + val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin)) + assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27")) + } + + @Test + fun `a zone that repeats midnight takes the first start of day`() { + // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day + // began twice. Pinned as the accepted trade: the widget runs an hour + // ahead until the next redraw. No live zone does this since 2019. + val saoPaulo = TimeZone.of("America/Sao_Paulo") + val next = WidgetRolloverScheduler.nextRolloverAt( + at("2018-02-16T12:00:00", saoPaulo), saoPaulo, + ) + val local = next.toLocalDateTime(saoPaulo) + assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17")) + assertThat(local.hour).isEqualTo(0) + } + + @Test + fun `a zone where midnight does not exist rolls over at the real start of day`() { + // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana. + // Targeting a literal 00:00 there would arm an instant on the wrong day. + val havana = TimeZone.of("America/Havana") + val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana) + val local = next.toLocalDateTime(havana) + assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11")) + assertThat(local.hour).isEqualTo(1) + assertThat(local.minute).isEqualTo(0) + // And it is genuinely the first instant of that date, not a guess. + assertThat(next).isEqualTo( + LocalDate.parse("2018-03-11").atStartOfDayIn(havana) + + WidgetRolloverScheduler.ROLLOVER_SLACK, + ) + } + + // --- timezone changes ---------------------------------------------------- + + @Test + fun `the same instant rolls over at different times in different zones`() { + // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic + // follows the zone, not a cached offset. + val instant = at("2026-08-27T12:00:00", berlin) + val tokyo = TimeZone.of("Asia/Tokyo") + val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin) + val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo) + assertThat(tokyoNext).isNotEqualTo(berlinNext) + assertThat(tokyoNext).isLessThan(berlinNext) + assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0) + } + + @Test + fun `a half-hour offset zone still lands on its own midnight`() { + val kathmandu = TimeZone.of("Asia/Kathmandu") + val next = WidgetRolloverScheduler.nextRolloverAt( + at("2026-08-27T12:00:00", kathmandu), kathmandu, + ) + val local = next.toLocalDateTime(kathmandu) + assertThat(local.date.toString()).isEqualTo("2026-08-28") + assertThat(local.hour).isEqualTo(0) + } + + // --- the wiring ---------------------------------------------------------- + + @Test + fun `the receiver actually handles the action the alarm is sent with`() { + // The single point where the whole fix would die silently: the alarm + // fires, the receiver drops it on the action guard, nothing redraws. + assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS) + .contains(WidgetUpdateReceiver.ACTION_ROLLOVER) + } + + @Test + fun `a language change redraws without re-arming`() { + assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS).contains(Intent.ACTION_LOCALE_CHANGED) + assertThat(WidgetUpdateReceiver.REARM_ACTIONS).doesNotContain(Intent.ACTION_LOCALE_CHANGED) + } + + @Test + fun `everything that loses the alarm re-arms it`() { + assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast( + WidgetUpdateReceiver.ACTION_ROLLOVER, + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_DATE_CHANGED, + ) + } +} diff --git a/app/src/test/resources/vtodo/README.md b/app/src/test/resources/vtodo/README.md new file mode 100644 index 0000000..ca0516b --- /dev/null +++ b/app/src/test/resources/vtodo/README.md @@ -0,0 +1,32 @@ +# VTODO fixture corpus + +The mapper's **specification**, not its regression net. Every file here is a +shape that was found in the wild and that a plausible implementation gets wrong. +A clean, server-generated VTODO catches nothing and is deliberately absent. + +`RoundTripTest` asserts every fixture survives `parse → map → write → serialise` +with no property lost, compared as a canonical multiset modulo an allowlist +(`PRODID`, `DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`, +VTIMEZONE bodies, fold positions, parameter quoting). + +| File | What it is for | +|---|---| +| `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task | +| `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID | +| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent | +| `unknown-component.ics` | An entirely unknown sub-component | +| `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` | +| `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe | +| `rrule-due-no-dtstart.ics` | `RRULE` + `DUE` and no `DTSTART`: no well-defined `RECURRENCE-ID`, undefined in RFC 5545, ubiquitous in the wild | +| `moz-alarm-props.ics` | `X-MOZ-LASTACK` / `X-MOZ-SNOOZE-TIME`. Dropping them causes documented Thunderbird alarm storms | +| `apple-sort-order.ics` | `X-APPLE-SORT-ORDER` — preserve, never interpret | +| `floating-time.ics` | `DTSTART` with neither `Z` nor `TZID`. Readable, not reproducible: it must round-trip from the residue, not be rewritten as a guess | +| `completion-model-a.ics` | Completion as a `RECURRENCE-ID` override, master stays open — jtx Board, Thunderbird. **The model we write** | +| `completion-model-b.ics` | Master's `DUE` advanced in place, completion cleared — tasks.org, Evolution, Nextcloud in practice | +| `completion-model-c.ics` | `STATUS:COMPLETED` on the master, killing the series — Nextcloud Tasks ≤ 0.17. **Always a bug**; we read it, we never write it | +| `completion-model-d.ics` | The completed occurrence detached as a new task with a new UID — OpenTasks | +| `quoted-param-colon.ics` | A quoted parameter value containing a colon and a comma. The value separator is the first colon *outside* quotes | +| `mixed-value-types.ics` | `DTSTART;VALUE=DATE` with a timed `DUE`. One `is_all_day` flag cannot author both, so the odd one out must stay in the residue rather than be flattened to a date | +| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data | +| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both | +| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries | diff --git a/app/src/test/resources/vtodo/apple-sort-order.ics b/app/src/test/resources/vtodo/apple-sort-order.ics new file mode 100644 index 0000000..fb05b64 --- /dev/null +++ b/app/src/test/resources/vtodo/apple-sort-order.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//macOS 15.3//EN +BEGIN:VTODO +UID:apple-sort-001 +DTSTAMP:20260901T120000Z +SUMMARY:Buy milk +X-APPLE-SORT-ORDER:734829163 +PRIORITY:3 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/completion-model-a.ics b/app/src/test/resources/vtodo/completion-model-a.ics new file mode 100644 index 0000000..518e460 --- /dev/null +++ b/app/src/test/resources/vtodo/completion-model-a.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//jtx Board//EN +BEGIN:VTODO +UID:model-a-001 +DTSTAMP:20260901T120000Z +SUMMARY:Take out the bins +DTSTART;VALUE=DATE:20260901 +RRULE:FREQ=WEEKLY;BYDAY=TU +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:model-a-001 +RECURRENCE-ID;VALUE=DATE:20260901 +DTSTAMP:20260901T190000Z +SUMMARY:Take out the bins +DTSTART;VALUE=DATE:20260901 +STATUS:COMPLETED +COMPLETED:20260901T190000Z +PERCENT-COMPLETE:100 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/completion-model-b.ics b/app/src/test/resources/vtodo/completion-model-b.ics new file mode 100644 index 0000000..8827482 --- /dev/null +++ b/app/src/test/resources/vtodo/completion-model-b.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//tasks.org//EN +BEGIN:VTODO +UID:model-b-001 +DTSTAMP:20260908T190000Z +SUMMARY:Take out the bins +DUE;VALUE=DATE:20260908 +RRULE:FREQ=WEEKLY;BYDAY=TU +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/completion-model-c.ics b/app/src/test/resources/vtodo/completion-model-c.ics new file mode 100644 index 0000000..9dbd0a6 --- /dev/null +++ b/app/src/test/resources/vtodo/completion-model-c.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nextcloud Tasks 0.17//EN +BEGIN:VTODO +UID:model-c-001 +DTSTAMP:20260901T190000Z +SUMMARY:Take out the bins +DUE;VALUE=DATE:20260901 +RRULE:FREQ=WEEKLY;BYDAY=TU +STATUS:COMPLETED +COMPLETED:20260901T190000Z +PERCENT-COMPLETE:100 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/completion-model-d.ics b/app/src/test/resources/vtodo/completion-model-d.ics new file mode 100644 index 0000000..1fb3da2 --- /dev/null +++ b/app/src/test/resources/vtodo/completion-model-d.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//dmfs.org/OpenTasks//EN +BEGIN:VTODO +UID:model-d-001 +DTSTAMP:20260908T190000Z +SUMMARY:Take out the bins +DUE;VALUE=DATE:20260908 +RRULE:FREQ=WEEKLY;BYDAY=TU +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:model-d-detached-001 +DTSTAMP:20260901T190000Z +SUMMARY:Take out the bins +DUE;VALUE=DATE:20260901 +STATUS:COMPLETED +COMPLETED:20260901T190000Z +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/floating-time.ics b/app/src/test/resources/vtodo/floating-time.ics new file mode 100644 index 0000000..5f30078 --- /dev/null +++ b/app/src/test/resources/vtodo/floating-time.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Radicale//NONSGML Radicale Server//EN +BEGIN:VTODO +UID:floating-001 +DTSTAMP:20260901T120000Z +SUMMARY:Wake up wherever you are +DTSTART:20260905T070000 +DUE:20260905T073000 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/folded-utf8.ics b/app/src/test/resources/vtodo/folded-utf8.ics new file mode 100644 index 0000000..6122257 --- /dev/null +++ b/app/src/test/resources/vtodo/folded-utf8.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Agendula//Test//EN +BEGIN:VTODO +UID:folded-utf8-001 +DTSTAMP:20260901T120000Z +SUMMARY:Gießkanne füllen und die Pflanzen im Wintergarten gießen — 🌱🌿🪴 + jede Woche montags +DESCRIPTION:Zeile eins\nZeile zwei mit Komma\, Semikolon\; und Backslash\\ dr + in +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/malformed-scalars.ics b/app/src/test/resources/vtodo/malformed-scalars.ics new file mode 100644 index 0000000..67aab8c --- /dev/null +++ b/app/src/test/resources/vtodo/malformed-scalars.ics @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Careless Client//EN +BEGIN:VTODO +UID:malformed-scalars-001 +DTSTAMP:20260901T120000Z +SUMMARY:Values no spec allows +PRIORITY:11 +PERCENT-COMPLETE:150 +SEQUENCE:x +STATUS:X-DEFERRED +CLASS:X-INTERNAL +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/mixed-value-types.ics b/app/src/test/resources/vtodo/mixed-value-types.ics new file mode 100644 index 0000000..4e4d202 --- /dev/null +++ b/app/src/test/resources/vtodo/mixed-value-types.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mixed//EN +BEGIN:VTODO +UID:mixed-value-001 +DTSTAMP:20260901T120000Z +SUMMARY:Starts on a day, due at a time +DTSTART;VALUE=DATE:20260901 +DUE;TZID=Europe/Berlin:20260901T090000 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/moz-alarm-props.ics b/app/src/test/resources/vtodo/moz-alarm-props.ics new file mode 100644 index 0000000..08f695e --- /dev/null +++ b/app/src/test/resources/vtodo/moz-alarm-props.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTODO +UID:moz-alarm-001 +DTSTAMP:20260901T120000Z +SUMMARY:Call the dentist +DUE;TZID=Europe/Berlin:20260905T100000 +X-MOZ-LASTACK:20260904T090000Z +X-MOZ-SNOOZE-TIME:20260905T093000Z +X-MOZ-GENERATION:3 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Call the dentist +TRIGGER;RELATED=END:-PT15M +END:VALARM +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/nextcloud-shared-confidential.ics b/app/src/test/resources/vtodo/nextcloud-shared-confidential.ics new file mode 100644 index 0000000..22288df --- /dev/null +++ b/app/src/test/resources/vtodo/nextcloud-shared-confidential.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nextcloud calendar v4.7.0 +BEGIN:VTODO +UID:conf-shared-001 +DTSTAMP:20260901T120000Z +SUMMARY:Quarterly filing +CLASS:CONFIDENTIAL +CREATED:20260801T090000Z +LAST-MODIFIED:20260901T115900Z +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/quoted-param-colon.ics b/app/src/test/resources/vtodo/quoted-param-colon.ics new file mode 100644 index 0000000..a7af75d --- /dev/null +++ b/app/src/test/resources/vtodo/quoted-param-colon.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//SOGo//EN +BEGIN:VTODO +UID:quoted-param-001 +DTSTAMP:20260901T120000Z +SUMMARY:Review with the team +ATTENDEE;CN="Smith, J:r";PARTSTAT=NEEDS-ACTION:mailto:jr@example.com +ORGANIZER;CN=Alice:mailto:alice@example.com +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/recurring-master-overrides.ics b/app/src/test/resources/vtodo/recurring-master-overrides.ics new file mode 100644 index 0000000..4664b14 --- /dev/null +++ b/app/src/test/resources/vtodo/recurring-master-overrides.ics @@ -0,0 +1,33 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +BEGIN:VTODO +UID:series-weekly-001 +DTSTAMP:20260901T120000Z +SUMMARY:Water the plants +DTSTART;TZID=Europe/Berlin:20260901T080000 +DUE;TZID=Europe/Berlin:20260901T090000 +RRULE:FREQ=WEEKLY;BYDAY=MO +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:series-weekly-001 +RECURRENCE-ID;TZID=Europe/Berlin:20260908T080000 +DTSTAMP:20260908T081500Z +SUMMARY:Water the plants +DTSTART;TZID=Europe/Berlin:20260908T080000 +DUE;TZID=Europe/Berlin:20260908T090000 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260908T081500Z +END:VTODO +BEGIN:VTODO +UID:series-weekly-001 +RECURRENCE-ID;TZID=Europe/Berlin:20260915T080000 +DTSTAMP:20260915T070000Z +SUMMARY:Water the plants (away — ask neighbour) +DTSTART;TZID=Europe/Berlin:20260915T080000 +DUE;TZID=Europe/Berlin:20260915T090000 +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/rrule-due-no-dtstart.ics b/app/src/test/resources/vtodo/rrule-due-no-dtstart.ics new file mode 100644 index 0000000..9f84cab --- /dev/null +++ b/app/src/test/resources/vtodo/rrule-due-no-dtstart.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Nextcloud Tasks//EN +BEGIN:VTODO +UID:rrule-no-dtstart-001 +DTSTAMP:20260901T120000Z +SUMMARY:Pay rent +DUE;VALUE=DATE:20260901 +RRULE:FREQ=MONTHLY;BYMONTHDAY=1 +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/two-timezones.ics b/app/src/test/resources/vtodo/two-timezones.ics new file mode 100644 index 0000000..f8a44b0 --- /dev/null +++ b/app/src/test/resources/vtodo/two-timezones.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Traveller//EN +BEGIN:VTODO +UID:two-zones-001 +DTSTAMP:20260901T120000Z +SUMMARY:Leave Berlin, land in New York +DTSTART;TZID=Europe/Berlin:20260901T080000 +DUE;TZID=America/New_York:20260901T140000 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/uid-special-chars.ics b/app/src/test/resources/vtodo/uid-special-chars.ics new file mode 100644 index 0000000..65c9e43 --- /dev/null +++ b/app/src/test/resources/vtodo/uid-special-chars.ics @@ -0,0 +1,9 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp//CalDAV Client//EN +BEGIN:VTODO +UID:20260901T120000Z/task@example.com +DTSTAMP:20260901T120000Z +SUMMARY:UID that is not filename-safe +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/unknown-component.ics b/app/src/test/resources/vtodo/unknown-component.ics new file mode 100644 index 0000000..a53e13c --- /dev/null +++ b/app/src/test/resources/vtodo/unknown-component.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Some Other Client//EN +BEGIN:VTODO +UID:unknown-comp-001 +DTSTAMP:20260901T120000Z +SUMMARY:Task with a component we have never seen +BEGIN:X-VENDOR-METADATA +X-VENDOR-KEY:project-alpha +X-VENDOR-WEIGHT:7 +END:X-VENDOR-METADATA +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/unknown-tzid.ics b/app/src/test/resources/vtodo/unknown-tzid.ics new file mode 100644 index 0000000..07292b7 --- /dev/null +++ b/app/src/test/resources/vtodo/unknown-tzid.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Legacy Groupware//EN +BEGIN:VTIMEZONE +TZID:Custom/Company-HQ +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:+0130 +TZOFFSETTO:+0130 +TZNAME:HQ +END:STANDARD +END:VTIMEZONE +BEGIN:VTODO +UID:unknown-tzid-001 +DTSTAMP:20260901T120000Z +SUMMARY:Board pack due +DUE;TZID=Custom/Company-HQ:20260910T170000 +END:VTODO +END:VCALENDAR diff --git a/app/src/test/resources/vtodo/valarm-unknown-nested.ics b/app/src/test/resources/vtodo/valarm-unknown-nested.ics new file mode 100644 index 0000000..15dd127 --- /dev/null +++ b/app/src/test/resources/vtodo/valarm-unknown-nested.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Apple Inc.//iOS 18.2//EN +BEGIN:VTODO +UID:alarm-nested-001 +DTSTAMP:20260901T120000Z +SUMMARY:Renew passport +DUE;TZID=Europe/Berlin:20261001T170000 +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER;RELATED=START:-PT30M +ACKNOWLEDGED:20260901T113000Z +X-WR-ALARMUID:8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11 +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VTODO +END:VCALENDAR diff --git a/build.gradle.kts b/build.gradle.kts index a96f72d..d8237c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false diff --git a/caldav/build.gradle.kts b/caldav/build.gradle.kts new file mode 100644 index 0000000..e159e77 --- /dev/null +++ b/caldav/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + // No version — AGP already puts the Kotlin plugin on the build classpath. + id("org.jetbrains.kotlin.jvm") +} + +// Our CalDAV protocol layer: discovery, auth, Nextcloud Login Flow v2. MIT, and +// deliberately a separate module from the MPL-2.0 vendored `:dav`. +// +// A plain JVM module, like `:dav`, and for the same two reasons: it enforces "no +// Android types" at compile time rather than by discipline, and it lets the whole +// protocol layer be tested against MockWebServer on the JVM. Anything that needs +// the platform — Keystore, AccountManager, Custom Tabs — belongs in `:app`. +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + api(project(":dav")) + implementation(libs.dnsjava) + // Runtime API only (Json.parseToJsonElement) — no @Serializable, so the + // serialization compiler plugin is not needed here. + implementation(libs.kotlinx.serialization.json) + + compileOnly(libs.xpp3) + testImplementation(libs.xpp3) + testImplementation(libs.junit4) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.truth) +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/AppPassword.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/AppPassword.kt new file mode 100644 index 0000000..19096a2 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/AppPassword.kt @@ -0,0 +1,126 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration + +/** + * Gives an app password back when the account is removed. + * + * ⚠️ Without this, **uninstalling never revokes access**. Nextcloud's Login Flow + * v2 mints a device-specific password that survives the app entirely: it stays + * listed under Settings → Security → Devices & sessions until the user notices + * and deletes it by hand, on an entry named after an app that is no longer + * installed. Minting a credential and then abandoning it is not an acceptable + * end state for a client that asked for one. + * + * Best-effort by design. The account is being removed either way, and a server + * that is unreachable, or was never a Nextcloud, must not block that. + */ +object AppPassword { + + /** Nextcloud's OCS endpoint for "delete the password I authenticated with". */ + private const val PATH = "ocs/v2.php/core/apppassword" + + /** + * Derives the OCS root from a Nextcloud principal URL. + * + * ⚠️ The principal URL is **not** the server root, and appending to it is the + * bug this function exists to prevent: a principal is + * `…/remote.php/dav/principals/users/alice/`, so + * `principal + "ocs/v2.php/…"` produces a path that 404s on every server, + * every time, silently — the revocation reads as "attempted" and does + * nothing at all. + * + * Nextcloud mounts WebDAV under `remote.php`, so everything before that + * segment is the server root, and that is true of a subpath install + * (`https://host/nextcloud/`) as much as of a root one. Falling back to the + * origin is right for a server that does not use `remote.php` — it is not a + * Nextcloud, so the endpoint does not exist there under any path. + */ + fun ocsRootFor(principal: HttpUrl): HttpUrl { + val mount = principal.pathSegments.indexOfFirst { it.equals("remote.php", ignoreCase = true) } + // No `remote.php` means this is not a Nextcloud layout, and guessing a + // prefix from an arbitrary DAV path would aim the DELETE somewhere + // unrelated. The origin is the only defensible answer, and on a server + // without the endpoint it simply 404s. + val prefix = if (mount < 0) emptyList() else principal.pathSegments.take(mount) + return principal.newBuilder() + .encodedPath("/") + .apply { + prefix.filter { it.isNotEmpty() }.forEach { addPathSegment(it) } + // A trailing empty segment keeps this a directory URL, so + // appending the OCS path cannot fuse onto the last segment. + if (prefix.any { it.isNotEmpty() }) addPathSegment("") + } + .build() + } + + /** + * ⚠️ The budget is enforced here because nothing above can enforce it. + * `execute()` parks on a socket read, which no coroutine cancellation and no + * `Thread.interrupt` can break — only closing the socket does, which is what + * `callTimeout` does. Wrapping this call in `withTimeoutOrNull` instead + * returns only once the read has finished anyway, so the caller waits out + * the shared client's own budget: 30s *per resolved address*, doubled by the + * authenticator's retry, plus up to 120s of read timeout. Minutes, for a + * step whose own doc says it must not block the removal. + * + * The shared client has a ceiling of its own, but it is sized for a + * multiget of a full batch over a slow link — minutes, where a courtesy + * revocation the user is waiting behind gets seconds. + * + * @return true when the server confirmed the revocation. False means the + * credential may still exist server-side — the caller carries on regardless. + */ + fun revoke( + httpClient: OkHttpClient, + principal: HttpUrl, + timeout: Duration = REVOCATION_TIMEOUT, + ): Boolean = revokeAt(httpClient, ocsRootFor(principal), timeout) + + /** + * The same revocation, for a caller that already holds the server root. + * + * ⚠️ Do not route such a caller through [revoke]. [ocsRootFor] is + * *principal*-shaped: it looks for `remote.php` and falls back to the bare + * origin when there is none. A login flow hands back a server base, so a + * subpath install's `https://host/nextcloud/` would collapse to + * `https://host/` and the DELETE would 404 on every one of them — attempted, + * and doing nothing, which is the failure [ocsRootFor] exists to prevent. + */ + fun revokeAt( + httpClient: OkHttpClient, + ocsRoot: HttpUrl, + timeout: Duration = REVOCATION_TIMEOUT, + ): Boolean = try { + val url = ocsRoot.newBuilder().addPathSegments(PATH).build() + Redirects.follow( + httpClient.newBuilder() + // Shares the pool and dispatcher, so this costs nothing. + .callTimeout(timeout.toJavaDuration()) + .build(), + Request.Builder() + .url(url) + .delete() + // ⚠️ Not optional. Without this header Nextcloud answers the OCS + // API with a 401 and a CSRF complaint rather than doing the work, + // which reads exactly like a wrong password. + .header("OCS-APIRequest", "true") + .header("Accept", "application/json") + .build(), + ).use { it.isSuccessful } + } catch (_: IOException) { + // callTimeout throws InterruptedIOException, which lands here. + false + } catch (_: IllegalArgumentException) { + false + } + + /** What a best-effort courtesy call is worth waiting for. */ + val REVOCATION_TIMEOUT: Duration = 5.seconds +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt new file mode 100644 index 0000000..0854440 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavDiscovery.kt @@ -0,0 +1,343 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.CalendarHomeSet +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.CurrentUserPrincipal +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import java.io.IOException + +/** + * RFC 6764 discovery: from what the user typed to the list of task collections. + * + * The happy path is five requests. Everything else here is a rule that exists + * because a real server broke the obvious implementation. + */ +class CalDavDiscovery( + private val httpClient: OkHttpClient, + private val dns: DnsResolver = DnsResolver.None, + /** + * Whether a typed `http://` base URL may be probed. + * + * Off by default. Credentials are never sent over cleartext — the + * interceptor withholds them — so an http:// server would otherwise answer + * 401 forever and the user would be told their password was wrong when the + * real problem is the scheme. Any escape hatch has to be a narrow, warned, + * per-account opt-in; this is that switch. + */ + private val allowCleartext: Boolean = false, +) { + + /** + * Properties requested **by name**, because allprop legitimately omits them. + * + * Owned by [CollectionClassifier], which is what reads them back — a second + * copy here would drift the day one of them is added. + */ + private val collectionProperties = CollectionClassifier.PROPERTIES + + /** A home set that could not be listed. One failing must not hide the others. */ + data class HomeSetFailure( + val url: HttpUrl, + val reason: String, + val needsAuthentication: Boolean, + ) + + sealed interface Outcome { + data class Found( + val principal: HttpUrl, + val collections: List, + /** Every `calendar-home-set` href, in the order the principal listed them. */ + val homeSets: List, + /** Set when a 301/308 moved us; the caller must persist it. */ + val movedTo: HttpUrl?, + /** Home sets on a different host than the principal. Normative, but worth surfacing. */ + val crossHostHomeSets: List, + /** Home sets that could not be read. The rest of the result is still good. */ + val failedHomeSets: List = emptyList(), + ) : Outcome + + /** + * The server wants credentials. Not a failure — authenticate and retry. + * + * [hosts] names *which* hosts asked, so the caller can say which one it + * could not reach. A cross-host home set under the same registrable + * domain — iCloud puts the principal on `caldav.icloud.com` and the home + * set on `pNN-caldav.icloud.com` — is already covered, because that is + * the scope `CalDavHttp` gives the credential. + * + * ⚠️ What is *not* covered is a home set on a genuinely different + * registrable domain, which RFC 4791 §6.2.1 allows. Nothing widens the + * allowlist for it and nothing should without the user's say-so: the + * password would be offered to a host named by the first server, and one + * credential scope is what makes that decidable. The add flow reports + * such a host by name instead of pretending the account is empty. + */ + data class NeedsAuthentication(val hosts: List) : Outcome + + /** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */ + data object Unauthenticated : Outcome + + data class NotCalDav(val cause: Cause, val detail: String) : Outcome + + data class Failed(val cause: Cause, val detail: String) : Outcome + + /** + * Why discovery ended, in a form the UI can translate. + * + * ⚠️ The UI must render *this*, never [Failed.detail]. A server's own + * words are untranslatable, frequently in a language the user does not + * read, and quite often a bare status line — "HTTP 405 Method Not + * Allowed" tells someone entering their address precisely nothing, and + * bypasses `strings.xml` entirely. [detail] exists for logs and bug + * reports, and is never shown. + */ + enum class Cause { + /** The address is not a URL, or names nothing we can look up. */ + NOT_AN_ADDRESS, + + /** Reached something, but it does not speak WebDAV at all. */ + NOT_A_DAV_SERVER, + + /** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */ + NO_CALENDAR_SUPPORT, + + /** Nothing answered: DNS, connection refused, TLS, timeout. */ + UNREACHABLE, + + /** The address, or where it redirects, is plain HTTP. */ + INSECURE, + + /** Signed in, but the account exposes no calendar home. */ + NO_CALENDARS, + + /** The server answered, and the answer was an error of its own. */ + SERVER_ERROR, + } + } + + /** + * Classifies a transport or protocol failure for the UI. + * + * ⚠️ **405 is the interesting one.** It is what an ordinary web server + * answers to `PROPFIND`, which makes it the single most likely response to + * someone typing their *website* instead of their CalDAV address — and it + * means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method + * Not Allowed" hands the user a status code where they needed a sentence. + */ + private fun causeOf(error: Throwable): Outcome.Cause = when { + error is HttpException -> when (error.code) { + METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER + else -> Outcome.Cause.SERVER_ERROR + } + // Everything that never got an answer: DNS, refused, TLS, timeout. + error is IOException -> Outcome.Cause.UNREACHABLE + else -> Outcome.Cause.SERVER_ERROR + } + + /** + * Walks every candidate for [input] and returns the first real result. + * + * A 401 stops the walk immediately: it means we found a DAV server and simply + * have no credentials for it, and continuing down the ladder would replace a + * precise "sign in" with a vague "nothing found". + */ + fun discover(input: String): Outcome { + ServiceDiscovery.asBaseUrl(input)?.let { typed -> + if (!typed.isHttps && !allowCleartext) { + return Outcome.Failed( + Outcome.Cause.INSECURE, + "\"$input\" is an unencrypted http:// address", + ) + } + } + + val candidates = ServiceDiscovery.candidatesFor(input, dns) + if (candidates.isEmpty()) { + return Outcome.Failed(Outcome.Cause.NOT_AN_ADDRESS, "no candidates for \"$input\"") + } + + var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered") + for (candidate in candidates) { + when (val outcome = probe(candidate.url)) { + is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome + else -> lastFailure = outcome + } + } + return lastFailure + } + + internal fun probe(base: HttpUrl): Outcome { + val resource = DavResource(httpClient, base) + + // The OPTIONS gate. `DAV: calendar-access` is what distinguishes a CalDAV + // server from any other WebDAV host, and it is what keeps Google's SRV + // record — which points at something that answers 405 to PROPFIND — from + // looking like a discovery that merely found no calendars. + var davCapabilities: Set = emptySet() + runCatching { resource.options { capabilities, _ -> davCapabilities = capabilities } } + + var principalHref: String? = null + var unauthenticated = false + val propfindResult = runCatching { + resource.propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + response[CurrentUserPrincipal::class.java]?.let { + principalHref = it.href + unauthenticated = unauthenticated || it.unauthenticated + } + } + } + + propfindResult.exceptionOrNull()?.let { error -> + // 401 is not a failure — iCloud and Zoho answer it from + // /.well-known/caldav, which *is* the DAV root, and it is RFC-legal. + if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host)) + return Outcome.Failed(causeOf(error), error.message ?: error.toString()) + } + + // ⚠️ RFC 5397 §3: a 200 carrying means the + // credentials were rejected. Without this check a failed login looks like + // a successful discovery that found nothing — the shape of bug report + // nobody can act on. The element is parsed explicitly (dav/PROVENANCE.md + // change 6) rather than inferred from a null href, which would also fire + // for a merely non-conformant empty element. + if (unauthenticated) return Outcome.Unauthenticated + + val href = principalHref + ?: return if (davCapabilities.contains("calendar-access")) { + Outcome.Failed( + Outcome.Cause.NO_CALENDAR_SUPPORT, + "advertises calendar-access but returned no principal", + ) + } else { + Outcome.NotCalDav( + Outcome.Cause.NOT_A_DAV_SERVER, + "no DAV:current-user-principal, and no calendar-access in OPTIONS", + ) + } + + if (davCapabilities.isNotEmpty() && !davCapabilities.contains("calendar-access")) { + return Outcome.NotCalDav( + Outcome.Cause.NO_CALENDAR_SUPPORT, + "OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access", + ) + } + + val principal = resource.location.resolve(href) + ?: return Outcome.Failed( + Outcome.Cause.SERVER_ERROR, + "principal href \"$href\" is not a usable URL", + ) + + return fromPrincipal(principal, movedTo = resource.permanentLocation) + } + + internal fun fromPrincipal(principal: HttpUrl, movedTo: HttpUrl? = null): Outcome { + val homeSets = mutableListOf() + val principalResource = DavResource(httpClient, principal) + val homeSetResult = runCatching { + principalResource.propfind(0, CalendarHomeSet.NAME) { response, _ -> + // ⚠️ Iterate ALL hrefs. Multiple home sets are normative (RFC 4791 + // §6.2.1's own example) and iCloud depends on it: the principal is + // on caldav.icloud.com and the home set on pNN-caldav.icloud.com. + // ⚠️ Against where the PROPFIND *landed*, not where it was + // aimed. `followRedirects` rewrites `location` in place, and the + // probe above already reads it back for exactly this reason. A + // principal that has permanently moved answers a relative + // `calendars/alice/`, which resolved against the + // pre-redirect URL names a path on the old host — the home-set + // PROPFIND 404s and a perfectly good account reports that it + // holds no calendars. + response[CalendarHomeSet::class.java]?.hrefs?.forEach { raw -> + principalResource.location.resolve(raw)?.let(homeSets::add) + } + } + } + homeSetResult.exceptionOrNull()?.let { error -> + if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host)) + return Outcome.Failed(causeOf(error), error.message ?: error.toString()) + } + + if (homeSets.isEmpty()) { + return Outcome.Failed(Outcome.Cause.NO_CALENDARS, "principal has no calendar-home-set") + } + + // Cross-host is legal and required, but never over plain HTTP: the + // credentials follow the home set, and a downgrade would send them in the + // clear. The caller surfaces the host change to the user. + val crossHost = homeSets.filter { it.host != principalResource.location.host } + val insecure = homeSets.filter { principal.isHttps && !it.isHttps } + if (insecure.isNotEmpty()) { + return Outcome.Failed( + Outcome.Cause.INSECURE, + "calendar-home-set downgrades to HTTP: ${insecure.first()}", + ) + } + + val collections = linkedMapOf() + val failures = mutableListOf() + var anySucceeded = false + + for (homeSet in homeSets.distinct()) { + val result = runCatching { + DavResource(httpClient, homeSet).propfind(1, *collectionProperties) { response, relation -> + if (relation == Response.HrefRelation.SELF) return@propfind + CollectionClassifier.classify(response)?.let { collections[it.url] = it } + } + } + result.fold( + // A failed home set must not fail the account — the same rule the + // engine runs on. One broken share must not hide every other list, + // and a 401 from a cross-host home set must not tell the user to + // sign in again with credentials that just worked. + onSuccess = { anySucceeded = true }, + onFailure = { + failures += HomeSetFailure( + url = homeSet, + reason = it.message ?: it.toString(), + needsAuthentication = isUnauthorized(it), + ) + }, + ) + } + + // Every home set failing is a server problem, not a discovery that found + // no lists. Reporting it as success gives the user "connected, no task + // lists" for what is actually an error. + if (!anySucceeded) { + val needAuth = failures.filter { it.needsAuthentication } + return if (needAuth.isNotEmpty() && needAuth.size == failures.size) { + Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct()) + } else { + Outcome.Failed( + Outcome.Cause.NO_CALENDARS, + failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed", + ) + } + } + + return Outcome.Found( + principal = principal, + collections = collections.values.toList(), + homeSets = homeSets.distinct(), + movedTo = movedTo, + crossHostHomeSets = crossHost, + failedHomeSets = failures, + ) + } + + private fun isUnauthorized(error: Throwable): Boolean = + error is at.bitfire.dav4jvm.exception.UnauthorizedException + + companion object { + /** `https://host/path` → the URL, or null. Convenience for callers. */ + fun url(value: String): HttpUrl? = value.toHttpUrlOrNull() + + private const val NOT_FOUND = 404 + private const val METHOD_NOT_ALLOWED = 405 + private const val NOT_IMPLEMENTED = 501 + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt new file mode 100644 index 0000000..0ff1ad1 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavHttp.kt @@ -0,0 +1,176 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody.Companion.asResponseBody +import okio.GzipSource +import okio.buffer +import java.util.concurrent.TimeUnit + +/** + * The HTTP clients the CalDAV layer talks through. + * + * ⚠️ `followRedirects(false)` is mandatory, not a preference: `DavResource` + * requires it and asserts on it. Redirects are followed by hand so a + * HTTPS→HTTP downgrade can be refused and a permanent move can be reported to + * the caller — see `dav/PROVENANCE.md` change 3. + */ +object CalDavHttp { + + /** + * One shared base client, so every derived client reuses its connection pool + * and dispatcher threads. Building a fresh `OkHttpClient` per probe gives + * each its own pool — every rung of the RFC 6764 ladder reopens TLS, and the + * abandoned clients' idle threads live until GC. + */ + private val shared: OkHttpClient by lazy { + OkHttpClient.Builder() + .followRedirects(false) + // A homelab server on the end of a slow link is normal; a hung socket + // is not. Bounded so a killed worker is the exception, not the rule. + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + // ⚠️ A whole-call ceiling, because the three above are per-attempt: + // a server trickling one byte every 119 seconds satisfies the read + // timeout for ever, and holds a sequential sync for the entire + // WorkManager window while every later collection is skipped. Wide + // enough for a large multiget on a slow homelab link, narrow enough + // that one stalled request cannot eat the run. + .callTimeout(3, TimeUnit.MINUTES) + .build() + } + + /** Discovery before we have credentials, and the Nextcloud login flow. */ + fun anonymous(userAgent: String): OkHttpClient = base(userAgent).build() + + /** + * Authenticated against [origin]'s registrable domain. + * + * Uses the vendored [BasicDigestAuthHandler] rather than a hand-rolled + * interceptor, and it is worth saying why, because a preemptive-Basic + * interceptor is the obvious thing to write and this project wrote one first: + * + * - **It does Digest.** Baïkal defaults to `dav_auth_type = Digest` and OkHttp + * has no Digest support of its own (square/okhttp#205, open for years). + * Baïkal is squarely in the self-hosting audience. + * - **It already sends Basic preemptively over HTTPS**, and only over HTTPS, + * so the extra round trip on every request of a PROPFIND-heavy sync is + * avoided without a second implementation. + * - **It restricts by registrable domain**, which is what a cross-host home + * set needs: iCloud puts the principal on `caldav.icloud.com` and the home + * set on `pNN-caldav.icloud.com`, and an exact-host allowlist refuses the + * second one. + * - **It caches which scheme worked for the lifetime of the client**, so a + * sync that reuses one client pays the 401 challenge once. ⚠️ The cache + * lives on the handler, and this builds a fresh one per call — `discover`, + * `revokeAppPassword` and `sync` each get their own — so a Digest-only + * server pays one challenge per client, not one per process. + */ + fun authenticated( + userAgent: String, + username: String, + password: String, + origin: HttpUrl, + ): OkHttpClient { + val handler = BasicDigestAuthHandler( + // ⚠️ The **registrable** domain, not the host, and it must be + // derived exactly as the handler derives it for each request — pass + // "cloud.example.com" where the handler computes "example.com" and + // the credential is withheld from every request. That is every + // self-hosted Nextcloud. + // + // Public-suffix list, not a last-two-labels split: the split scopes + // cloud.example.co.uk to co.uk and 192.168.1.10 to 1.10, offering + // the password preemptively to strangers. `topPrivateDomain()` is + // null for an IP literal or a single-label host, where the exact + // host is the only safe scope. + // + // What this still trusts: two hosts under one registrable domain + // have one owner. That is what a cross-host `calendar-home-set` + // needs — iCloud answers on caldav.icloud.com and serves from + // pNN-caldav.icloud.com — so a breached dav.example.com can still + // claim evil.example.com. Narrowing further costs iCloud. + domain = origin.topPrivateDomain() ?: origin.host, + username = username, + password = password, + // Never over cleartext, challenged or not. + insecureBasic = false, + ) + return base(userAgent) + .authenticator(handler) + .addNetworkInterceptor(handler) + .build() + } + + private fun base(userAgent: String) = shared.newBuilder() + .addInterceptor(calendarHeaders) + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder().header("User-Agent", userAgent).build(), + ) + } + + /** + * The two headers that decide whether ETags are usable and whether our bytes + * survive the server. + * + * **`Accept-Encoding: identity`.** Setting it by hand disables OkHttp's + * transparent gzip, and that is the point: ⚠️ a compressing intermediary + * *weakens the ETag*. Any gzip-enabled nginx, Cloudflare or Traefik in front + * of an otherwise perfect server turns every strong validator into `W/"…"`, + * and a weak tag cannot be used with `If-Match` (RFC 9110 §13.1.1) — so + * conditional writes stop working for reasons that live in the user's reverse + * proxy rather than in their CalDAV server. Trading some bandwidth for a + * working conditional PUT is the right side of that deal. + * + * **`Prefer: handling=strict` on writes.** sabre-based servers (Baïkal, + * Nextcloud, ownCloud, SabreDAV itself) run vobject's `REPAIR` over anything + * uploaded unless this is set, and `Server::createFile` then deliberately + * withholds the ETag because the stored bytes are no longer ours. Strict + * handling keeps both. RFC 7240 §2 requires an unrecognised preference to be + * ignored, so sending it to every server costs nothing. + */ + private val calendarHeaders = Interceptor { chain -> + val request = chain.request() + val builder = request.newBuilder().header("Accept-Encoding", "identity") + if (request.method in WRITE_METHODS) { + // ⚠️ Appended, not replaced. A caller that set its own `Prefer` — + // `return=minimal`, a `depth-noroot` — would otherwise have it + // silently dropped on every write. + val existing = request.header("Prefer")?.takeIf { it.isNotBlank() } + builder.header("Prefer", listOfNotNull(existing, STRICT_HANDLING).joinToString(", ")) + } + gunzip(chain.proceed(builder.build())) + } + + /** + * Undoes a compression we asked not to have. + * + * Setting `Accept-Encoding` by hand takes OkHttp's transparent gunzip out of + * the loop — it only decompresses what it asked for itself. ⚠️ A proxy that + * gzips regardless (a misconfigured nginx, an ISP middlebox) then hands raw + * deflate bytes to the iCalendar parser, which reports it as an unreadable + * body and quarantines a perfectly good resource. Honouring the header the + * response actually carries costs nothing when nobody compressed anything. + */ + private fun gunzip(response: Response): Response { + if (!"gzip".equals(response.header("Content-Encoding"), ignoreCase = true)) return response + val body = response.body ?: return response + val source = GzipSource(body.source()).buffer() + return response.newBuilder() + .removeHeader("Content-Encoding") + // Dropped with it: the length describes the compressed bytes and is + // a lie about what the caller now reads. + .removeHeader("Content-Length") + .body(source.asResponseBody(body.contentType(), contentLength = -1)) + .build() + } + + private val WRITE_METHODS = setOf("PUT", "POST", "PATCH") + + private const val STRICT_HANDLING = "handling=strict" +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavProvider.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavProvider.kt new file mode 100644 index 0000000..eb3b904 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalDavProvider.kt @@ -0,0 +1,116 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl + +/** + * Which CalDAV service or server software an account talks to. + * + * Read off the two things an account already stores, so nothing extra is asked + * of the server and no column has to be added: the **host**, which names a + * hosted service outright, and the **principal URL's path**, whose shape is a + * fingerprint of the software behind it. + * + * [hosted] is what decides how an account is *named* on screen. A hosted + * service's host is boilerplate (`caldav.fastmail.com` for everyone), so its + * brand is the name; self-hosted software runs on the user's own host, which is + * the only thing that tells two of them apart. + */ +enum class CalDavProvider( + val label: String, + val hosted: Boolean, + internal val domains: Set = emptySet(), + internal val principalMarkers: Set = emptySet(), +) { + + /** + * ownCloud serves `/remote.php/dav/` too and cannot be told apart from here. + * Nextcloud is the far commoner of the two and the only one the add flow has + * a browser login for, so the mark goes to it — and because a self-hosted + * account is titled by its host, the name "Nextcloud" is never written next + * to an ownCloud server, only its mark. + */ + NEXTCLOUD("Nextcloud", hosted = false, principalMarkers = setOf("/remote.php/dav/")), + + BAIKAL("Baïkal", hosted = false, principalMarkers = setOf("/dav.php/")), + + DAVICAL("DAViCal", hosted = false, principalMarkers = setOf("/caldav.php/")), + + SOGO("SOGo", hosted = false, principalMarkers = setOf("/sogo/dav/")), + + FASTMAIL( + "Fastmail", + hosted = true, + domains = setOf("fastmail.com", "fastmail.fm", "messagingengine.com"), + ), + + ICLOUD("iCloud", hosted = true, domains = setOf("icloud.com", "me.com", "mac.com")), + + GOOGLE("Google", hosted = true, domains = setOf("gmail.com", "googlemail.com", "google.com")), + + MAILBOX_ORG("mailbox.org", hosted = true, domains = setOf("mailbox.org")), + + POSTEO("Posteo", hosted = true, domains = setOf("posteo.de", "posteo.net")), + + ZOHO("Zoho", hosted = true, domains = setOf("zoho.com", "zoho.eu")), + + YANDEX("Yandex", hosted = true, domains = setOf("yandex.ru", "yandex.com")), + ; + + /** + * The domain to put in an address hint, for a service the user reaches by + * email address. Null for software they run themselves, whose host is theirs. + */ + val primaryDomain: String? get() = domains.firstOrNull() + + companion object { + + /** + * The services worth offering by name on a picker, in the order they + * should be offered. + * + * Everything identified by domain, plus Nextcloud — picked for its + * browser sign-in rather than for its host. The rest are self-hosted + * software recognised *after* discovery, from the principal path, so + * naming them here would only add rows that all ask the same question. + * + * A service that cannot be synced at all sorts to the **end**. It is + * still listed — people come looking for Google, and an absent row reads + * as the app being unfinished rather than as Google's own limitation — + * but a dead row belongs under the live ones, not in the middle of them. + * The sort is stable, so everything else keeps declaration order. + */ + val selectable: List + get() = entries.filter { it.domains.isNotEmpty() || it == NEXTCLOUD } + .sortedBy { ServerQuirk.forProvider(it)?.isFatal == true } + + /** The service a host belongs to, if it is one we know by name. */ + fun forHost(host: String): CalDavProvider? { + val lower = host.lowercase().trimEnd('.') + return entries.firstOrNull { provider -> + provider.domains.any { lower == it || lower.endsWith(".$it") } + } + } + + /** The service behind an email address or a typed URL, if any. */ + fun forInput(input: String): CalDavProvider? { + val host = ServiceDiscovery.asBaseUrl(input)?.host + ?: ServiceDiscovery.domainOf(input) + ?: return null + return forHost(host) + } + + /** + * The provider behind a principal URL: the host first, because a service + * we know by name is not in doubt, then the path shape. + */ + fun forPrincipal(url: HttpUrl): CalDavProvider? = + forHost(url.host) ?: forPath(url.encodedPath) + + private fun forPath(path: String): CalDavProvider? { + val lower = path.lowercase() + return entries.firstOrNull { provider -> + provider.principalMarkers.any { it in lower } + } + } + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarChanges.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarChanges.kt new file mode 100644 index 0000000..746b3a1 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarChanges.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl + +/** What one `sync-collection` REPORT came back with. */ +sealed interface ChangeSet { + + /** + * One page of changes. + * + * A page, not a result: RFC 6578 lets a server truncate and hand back a token + * that resumes where it stopped, so the caller applies this page's bodies, + * stores [token], and only then asks for the next. + */ + data class Page( + val changed: List, + val removed: List, + /** + * The token to store **after** this page is applied. + * + * ⚠️ Null when the server sent a 207 with no `DAV:sync-token` at all, + * which happens. That is not an error and not a reason to throw — the + * changes in this page are still real. It means the next run has no + * cursor and must reconcile in full. + */ + val token: String?, + /** + * The server stopped early and there is more behind [token]. + * + * Signalled by a `507` on the collection's **own** href, which is + * truncation rather than failure — a 507 as the *outer* response status + * means something else entirely. + */ + val truncated: Boolean = false, + ) : ChangeSet + + /** + * The token is no longer usable and the collection must be reconciled in full. + * + * ⚠️ **Invalidation has no status code.** `403` (sabre), `400` (Google), + * `409` (Radicale) and `412` (Evolution) are all observed in the wild, so the + * only reliable signal is `DAV:valid-sync-token` in the body on **any** 4xx. + * Thunderbird matches on 400 alone and therefore never recovers from the 403 + * that most of the self-hosted world emits. + */ + data object TokenInvalid : ChangeSet + + /** The server will not do `sync-collection` here. Fall back, do not retry. */ + data object Unsupported : ChangeSet + + data class Failed(val reason: String) : ChangeSet +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarCollection.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarCollection.kt new file mode 100644 index 0000000..02634fd --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarCollection.kt @@ -0,0 +1,493 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.DavCalendar +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.Error +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.CalendarData +import at.bitfire.dav4jvm.property.GetCTag +import at.bitfire.dav4jvm.property.GetETag +import at.bitfire.dav4jvm.property.SyncToken +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + +/** + * One remote task collection, as a set of operations rather than a set of HTTP + * calls. Every method here returns an outcome; none of them throw for anything a + * server can legitimately answer. + * + * This class holds **no task-domain type and no Android type**, per the module + * boundary — it deals in `String` bodies of `text/calendar`. Deciding what those + * bodies mean is the engine's job, one module up. + */ +class CalendarCollection( + client: OkHttpClient, + override val url: HttpUrl, + /** Our WebDAV-Push subscription here, named in `Push-Dont-Notify` on writes. */ + pushRegistration: HttpUrl? = null, +) : RemoteCalendar { + + private val httpClient: OkHttpClient = pushRegistration?.let { registration -> + val header = WebDavPush.dontNotifyHeader(registration) + client.newBuilder() + .addInterceptor { chain -> + val request = chain.request() + chain.proceed( + if (request.method in NOTIFYING_METHODS) { + request.newBuilder().header("Push-Dont-Notify", header).build() + } else { + request + }, + ) + } + .build() + } ?: client + + private val dav get() = DavCalendar(httpClient, url) + + /** What a Depth-0 PROPFIND says about the collection right now. */ + data class State( + val collection: TaskCollection, + val ctag: String?, + val syncToken: String?, + ) + + /** + * Re-reads the collection's own properties. + * + * Worth doing on every sync rather than trusting what account-add recorded: + * ⚠️ ACL churn is real — a calendar shared with the user can be revoked, or + * demoted from read-write to read-only, with no notification of any kind. A + * stale "writable" flag turns every upload into a 403 the user cannot act on, + * and a stale "not shared" flag disarms the guard that keeps us from writing + * a mutilated body back over the owner's task. + */ + override fun state(): Result = runCatching { + var found: State? = null + var fromSelf = false + DavResource(httpClient, url).propfind( + 0, + *CollectionClassifier.PROPERTIES, + GetCTag.NAME, + SyncToken.NAME, + ) { response, relation -> + val isSelf = relation == Response.HrefRelation.SELF + // ⚠️ SELF wins outright and nothing overwrites it. A Depth-0 PROPFIND + // that also reports a member — which some servers send — would + // otherwise have that member classified as the collection's own + // state. The non-SELF branch exists only because a server whose href + // differs from ours by a trailing slash is classified OTHER, and + // refusing those outright breaks it against real deployments. + if (!isSelf && (fromSelf || found != null)) return@propfind + + val collection = CollectionClassifier.classify(response) ?: return@propfind + found = State( + collection = collection, + ctag = response[GetCTag::class.java]?.cTag, + syncToken = response[SyncToken::class.java]?.token, + ) + if (isSelf) fromSelf = true + } + found ?: throw IOException("$url is no longer a task collection") + } + + /** + * Every VTODO resource in the collection, as href + ETag. + * + * ⚠️ **No time-range filter.** DAVx5 omits it for the same reason: some + * servers return nothing at all for a `VTODO` comp-filter that carries one, + * and a task without `DTSTART` or `DUE` is outside every range by + * construction — which is most tasks. + * + * ⚠️ **No `calendar-data` in this REPORT.** A `calendar-query` that asks for + * bodies downloads the whole collection on every listing; worse, the bodies + * would arrive without a matched ETag from the same response. Bodies come + * from [fetch] only. + */ + override fun list(): Result> = runCatching { + val refs = mutableListOf() + dav.calendarQuery(COMPONENT, null, null) { response, relation -> + if (relation != Response.HrefRelation.MEMBER) return@calendarQuery + if (!response.isSuccess()) return@calendarQuery + refs += RemoteRef(response.href, ETag.from(response[GetETag::class.java])) + } + refs + } + + /** + * One page of changes since [token] (RFC 6578). + * + * ⚠️ **No `DAV:limit`.** Nextcloud regressed it to a localised HTML error + * page, so asking for a bounded page is how you get an unparseable response + * instead of a bounded one. Truncation is the server's decision, signalled + * back through [ChangeSet.Page.truncated]. + * + * ⚠️ **A removal is a `404` at `DAV:response` level**, not inside a + * `propstat`. A client that only reads statuses out of propstats sees every + * deletion as an unremarkable response with no properties and silently keeps + * the row. + */ + override fun changes(token: String?): ChangeSet = try { + val changed = mutableListOf() + val removed = mutableListOf() + var truncated = false + + val properties = DavCollection(httpClient, url).reportChanges( + syncToken = token, + infiniteDepth = false, + limit = null, + GetETag.NAME, + ) { response, relation -> + val code = response.status?.code + when { + relation == Response.HrefRelation.SELF -> + // On our own href this is truncation, not a failure. + if (code == INSUFFICIENT_STORAGE) truncated = true + + code == NOT_FOUND || code == GONE -> removed += response.href + + response.isSuccess() -> + changed += RemoteRef(response.href, ETag.from(response[GetETag::class.java])) + + // ⚠️ Neither a success nor a removal — a per-object ACL + // answering 403, a server failing on its own object with 5xx. + // Dropping it reports the resource as *unchanged*, so the row + // keeps whatever it holds until the next full reconciliation + // notices the ETag differs. Carried as changed with no + // validator instead: the multiget then produces a real verdict + // for it, and `fetch` already grades one — the same reasoning + // error, and the same fix, as its twin next door. + else -> changed += RemoteRef(response.href, eTag = null) + } + } + + ChangeSet.Page( + changed = changed, + removed = removed, + token = properties.filterIsInstance().firstOrNull()?.token, + truncated = truncated, + ) + } catch (e: HttpException) { + when { + e.code / 100 == 4 && mentionsInvalidToken(e) -> ChangeSet.TokenInvalid + e.code in UNSUPPORTED -> ChangeSet.Unsupported + else -> ChangeSet.Failed("HTTP ${e.code}") + } + } catch (e: DavException) { + ChangeSet.Failed(e.toString()) + } catch (e: IOException) { + ChangeSet.Failed(e.toString()) + } + + /** + * ⚠️ The body, not the status. Every observed invalidation status is also a + * legitimate answer to something else, and every server picks a different + * one — so the element is the signal and the code is noise. + */ + private fun mentionsInvalidToken(e: HttpException): Boolean = + Error.VALID_SYNC_TOKEN in e.errors || + e.responseBody?.contains(VALID_SYNC_TOKEN_ELEMENT) == true + + /** + * Downloads [hrefs] with `calendar-multiget`, in batches. + * + * ⚠️ Responses are **matched against the request**. Real servers reply with + * responses for URLs that were never asked for — a collection's own href, a + * sibling, occasionally something from another collection entirely — and a + * client that indexes the response by position or trusts it wholesale will + * apply one resource's body to another's row. + */ + override fun fetch(hrefs: List): Result = fetch(hrefs, MULTIGET_BATCH) + + /** + * What makes two hrefs the same resource. + * + * ⚠️ Not the raw `encodedPath`. The request href is *ours*, written into the + * REPORT body verbatim, and the response href is the server's own spelling + * of it — a server that answers `/my@dav.ics` for a requested `/my%40dav.ics` + * is the reason `UrlUtils.equals` exists at all. Compared raw, that resource + * lands in `unsolicited` *and* in `missing`, its intact body is dropped, and + * three such runs quarantine it out of the download for good, since nothing + * on this side can refund the count. + * + * `pathSegments` is decoded, so both spellings agree. Keeping it a list, not + * a joined string, stops `/a%2Fb` colliding with `/a/b`. The trailing slash + * still distinguishes, exactly as `UrlUtils.equals` refuses to normalise it. + * + * ⚠️ The origin is deliberately *not* part of this key, and is checked + * separately by [answersFor]. The two sides do not share one by + * construction: the REPORT body carries only our paths, so the reply's href + * resolves against the collection's *post-redirect* location, while our + * stored hrefs still carry the origin we had before the redirect. Keying on + * the origin would put every resource of a redirected collection into both + * `unsolicited` and `missing`. + */ + private fun identityOf(url: HttpUrl): List = url.pathSegments + + /** + * Whether a reply about [answered] can be the resource we asked for at + * [asked]. + * + * Same host as the href we asked for, or as the collection we asked it of — + * the latter covering a redirect we followed. Anything else is a body from + * somewhere we never spoke to, and applying it would write one host's + * content into another host's row. + */ + private fun answersFor(answered: HttpUrl, asked: HttpUrl, collection: HttpUrl): Boolean = + answered.host.equals(asked.host, ignoreCase = true) || + answered.host.equals(collection.host, ignoreCase = true) + + /** [batchSize] is a seam for tests; production always uses [MULTIGET_BATCH]. */ + internal fun fetch(hrefs: List, batchSize: Int): Result { + val resources = mutableListOf() + val unsolicited = mutableListOf() + val failed = mutableListOf() + val seen = mutableSetOf() + + // ⚠️ Per batch, not around the whole loop. A 500 on batch seven of ten + // used to discard the two hundred resources already parsed and answer + // `Result.failure`, so the caller recorded a collection failure and + // re-downloaded everything next run — on a flaky link, for ever. A batch + // that could not be asked is exactly what `missing` means, and the + // per-resource verdicts the engine already applies say the rest. + val batches = hrefs.chunked(batchSize) + for (batch in batches) { + val attempt = runCatching { + val byExactPath = batch.associateBy { it.encodedPath } + // ⚠️ Only identities that name exactly one request href. Two + // spellings of one path — `/my%40dav.ics` and `/my@dav.ics` — + // decode alike but are separate rows here, and silently keeping + // the last would report the other as missing and eventually + // quarantine it. Where we cannot tell them apart, the exact + // spelling is the only honest match. + val byPath = batch.groupBy { identityOf(it) } + .filterValues { it.size == 1 } + .mapValues { (_, matches) -> matches.single() } + val calendar = dav + calendar.multiget(batch, MIME_ICALENDAR, ICALENDAR_VERSION) { response, relation -> + if (relation == Response.HrefRelation.SELF) return@multiget + val candidate = byExactPath[response.href.encodedPath] + ?: byPath[identityOf(response.href)] + val asked = candidate + ?.takeIf { answersFor(response.href, it, calendar.location) } + if (asked == null) { + unsolicited += response.href + return@multiget + } + // Before the checks below, and deliberately: the server did + // mention this href, so whatever it said, the href is not + // one the server omitted. + seen += asked + if (!response.isSuccess()) { + failed += FetchFailure(asked, response.status?.code ?: 0) + return@multiget + } + val body = response[CalendarData::class.java]?.iCalendar + if (body == null) { + // Success at the response level, no body. The usual shape + // is a per-property refusal — a `propstat` carrying 403 + // around `calendar-data` — and `Response.properties` drops + // non-2xx propstats silently, so the verdict has to be + // read back out of them or a deterministic refusal + // arrives looking like a transient blank. + val refusal = response.propstat.firstOrNull { !it.isSuccess() } + failed += FetchFailure(asked, refusal?.status?.code ?: 0) + return@multiget + } + resources += RemoteResource( + href = asked, + // The tag from *this* response, paired with *this* body. + eTag = ETag.from(response[GetETag::class.java]), + iCalendar = body, + ) + } + } + // ⚠️ The first batch to fail ends the fetch, but keeps what came + // before it. Carrying on would re-ask a server that has just said it + // cannot answer, and the hrefs left unasked fall out as `missing` — + // which the engine grades as "listed but not returned" and counts, + // rather than acting on as a deletion. A whole run that fails on the + // first batch still answers `failure`, exactly as before. + if (attempt.isFailure) { + if (resources.isEmpty() && failed.isEmpty()) { + return Result.failure(attempt.exceptionOrNull()!!) + } + break + } + } + + return Result.success( + FetchResult( + resources = resources, + missing = hrefs.filterNot { it in seen }, + // A server that repeats a response element must not spend two + // thirds of the quarantine threshold in one run — nor count + // against a resource it also answered properly. + failed = failed + .filterNot { failure -> resources.any { it.href == failure.href } } + .distinctBy { it.href }, + unsolicited = unsolicited, + ), + ) + } + + /** + * Creates a resource at [name] with `If-None-Match: *`. + * + * The conditional is what makes creation safe to retry: without it a + * re-running worker overwrites whatever a previous run put there. + */ + override fun create(name: String, iCalendar: String): PutOutcome { + val href = url.newBuilder().addPathSegment(name).build() + return put(href, iCalendar, ifETag = null, ifNoneMatch = true) + } + + /** + * Replaces [href], conditional on [eTag] when one is given. + * + * A null [eTag] means the server has never handed us a strong validator for + * this resource, and a conditional write is therefore impossible rather than + * merely inconvenient. The engine decides whether writing anyway is + * acceptable; this class only carries out the decision. + */ + override fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome = + put(href, iCalendar, ifETag = eTag, ifNoneMatch = false) + + private fun put( + href: HttpUrl, + iCalendar: String, + ifETag: String?, + ifNoneMatch: Boolean, + ): PutOutcome = try { + var outcome: PutOutcome = PutOutcome.StoredNeedsRefetch(href) + DavResource(httpClient, href).put( + body = iCalendar.toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8), + ifETag = ifETag, + ifNoneMatch = ifNoneMatch, + ) { response -> + // The server may have stored the resource somewhere else entirely. + val location = response.header("Location") + ?.let { href.resolve(it) } + ?: href + val eTag = ETag.parse(response.header("ETag")) + outcome = if (eTag != null && eTag.usable) { + PutOutcome.Stored(location, eTag) + } else { + PutOutcome.StoredNeedsRefetch(location) + } + } + outcome + } catch (e: HttpException) { + classifyPutFailure(href, e, ifNoneMatch) + } catch (e: DavException) { + // Not an IOException: a refused HTTPS->HTTP redirect arrives here, and + // it is a configuration problem rather than a transient one. + PutOutcome.Rejected(0, e.toString()) + } catch (e: IOException) { + PutOutcome.Failed(e.toString()) + } + + /** + * ⚠️ **412 means three different things** and merging any two of them is a + * data-loss bug. + */ + private fun classifyPutFailure( + href: HttpUrl, + e: HttpException, + wasCreate: Boolean, + ): PutOutcome = when { + e.code != PRECONDITION_FAILED -> PutOutcome.Rejected(e.code, e.message.orEmpty()) + + // On create the precondition was `If-None-Match: *`, so 412 says only + // that the name is occupied. It says nothing about by what — the engine + // re-fetches and adopts the resource if the UID matches. + wasCreate -> PutOutcome.NameTaken(href) + + // On update the precondition was `If-Match`, and a resource that is gone + // fails it just as one that changed does. RFC 9110 §13.1.1 requires 412 + // rather than 404 once the precondition is evaluated, so the two are + // indistinguishable without asking again. + !exists(href) -> PutOutcome.Vanished + + else -> PutOutcome.ServerNewer + } + + /** + * Deletes [href], conditional on [eTag] when we hold a usable one. + * + * ⚠️ 404 and 410 are **success**. The purpose of a DELETE is for the + * resource to be absent, and it is; treating "already gone" as a failure + * leaves a tombstone that is retried on every sync forever. + */ + override fun delete(href: HttpUrl, eTag: String?): DeleteOutcome = try { + DavResource(httpClient, href).delete(ifETag = eTag) { } + DeleteOutcome.Deleted + } catch (e: HttpException) { + when (e.code) { + NOT_FOUND, GONE -> DeleteOutcome.Deleted + PRECONDITION_FAILED -> if (exists(href)) { + DeleteOutcome.ServerNewer + } else { + DeleteOutcome.Deleted + } + else -> DeleteOutcome.Rejected(e.code, e.message.orEmpty()) + } + } catch (e: DavException) { + DeleteOutcome.Rejected(0, e.toString()) + } catch (e: IOException) { + DeleteOutcome.Failed(e.toString()) + } + + /** A HEAD, used only to tell "changed" from "gone" apart after a 412. */ + private fun exists(href: HttpUrl): Boolean = try { + DavResource(httpClient, href).head { } + true + } catch (e: HttpException) { + e.code != NOT_FOUND && e.code != GONE + } catch (_: DavException) { + true + } catch (_: IOException) { + // Unknown. Treat as still present: discarding a local edit is + // irreversible, and giving up on this resource until the next run is not. + true + } + + companion object { + const val COMPONENT = "VTODO" + + /** + * Resources per `calendar-multiget`. + * + * A whole collection in one REPORT is a request body and a response that + * a modest server will refuse outright, and a failure mid-way costs the + * entire batch. DAVx5 settled on the same order of magnitude. + */ + const val MULTIGET_BATCH = 30 + + private const val MIME_ICALENDAR = "text/calendar" + private const val ICALENDAR_VERSION = "2.0" + + private val NOTIFYING_METHODS = setOf("PUT", "DELETE") + + private const val NOT_FOUND = 404 + private const val GONE = 410 + private const val PRECONDITION_FAILED = 412 + private const val INSUFFICIENT_STORAGE = 507 + + /** Matched as a local name, so a server's own namespace prefix is irrelevant. */ + private const val VALID_SYNC_TOKEN_ELEMENT = "valid-sync-token" + + /** + * The server does not implement the report. Deliberately narrow: 400, + * 403, 409 and 412 are all *invalidation* codes on some server, so + * treating them as "unsupported" would abandon `sync-collection` on a + * collection that merely needs a fresh token. + */ + private val UNSUPPORTED = setOf(405, 415, 501) + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarResources.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarResources.kt new file mode 100644 index 0000000..b52938b --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CalendarResources.kt @@ -0,0 +1,158 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.property.GetETag +import okhttp3.HttpUrl + +/** + * An entity tag, plus the one bit of it that changes behaviour. + * + * ⚠️ A weak tag is not a slightly worse strong tag — it is unusable for what we + * need one for. RFC 9110 §13.1.1: *"A weak entity-tag cannot be used with + * If-Match."* So a weak tag must never be persisted as a validator; the resource + * is re-fetched instead. + * + * Weak tags are not exotic. On sabre they are the **default** path unless + * `Prefer: handling=strict` is sent, and any gzip-compressing nginx, Cloudflare + * or Traefik in front of the server weakens them regardless of the server + * software — which is why the calendar client asks for `Accept-Encoding: + * identity`. + */ +data class ETag(val value: String, val weak: Boolean) { + + /** Whether this tag may be persisted and later sent as `If-Match`. */ + val usable: Boolean get() = !weak && value.isNotBlank() + + override fun toString() = if (weak) "W/\"$value\"" else "\"$value\"" + + companion object { + /** + * The tag from a parsed `DAV:getetag` property. + * + * ⚠️ Not [parse]. `GetETag` has *already* stripped the `W/` marker and + * recorded it separately, so feeding its [GetETag.eTag] back through a + * parser reports every weak tag as strong — which is precisely the + * failure the weak flag exists to prevent. + */ + fun from(property: GetETag?): ETag? { + val value = property?.eTag?.takeIf { it.isNotBlank() } ?: return null + return ETag(value, property.weak == true) + } + + /** Parses a raw `ETag` **header**; null when absent. */ + fun parse(raw: String?): ETag? { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val parsed = GetETag(trimmed) + val value = parsed.eTag?.takeIf { it.isNotBlank() } ?: return null + return ETag(value, parsed.weak == true) + } + } +} + +/** A resource as a listing reports it: an href and an ETag, with no body. */ +data class RemoteRef(val href: HttpUrl, val eTag: ETag?) + +/** + * A resource together with the body it was served with. + * + * ⚠️ [eTag] is the tag that arrived **in the same response as [iCalendar]**, and + * only such a pairing is ever stored. A tag taken from a listing and a body + * taken from a later fetch are not a matched pair, and treating them as one is + * how the Nextcloud shared-calendar landmine detonates: `CalendarObject::get()` + * hands back a body it has stripped while leaving the ETag alone, so an + * unmatched pair lets us `If-Match` a mutilated body over the real one. + */ +data class RemoteResource(val href: HttpUrl, val eTag: ETag?, val iCalendar: String) + +/** + * A resource the server answered for, but did not hand over. + * + * @param code the response-level status, or `0` when the server reported + * success and supplied no `calendar-data` — the same "no HTTP judgement to + * report" convention [PutOutcome.Rejected] uses. + */ +data class FetchFailure(val href: HttpUrl, val code: Int) + +/** What a multiget actually returned, and what it did not. */ +data class FetchResult( + val resources: List, + /** Asked for, not answered — the server simply omitted them. */ + val missing: List, + /** + * Asked for, answered, and refused or empty. + * + * Separate from [missing] because "the server said 403" and "the server said + * nothing" are different facts with different right answers — merging them + * is the same mistake as merging the three meanings of a 412. + */ + val failed: List, + /** + * Answered without being asked for. + * + * Real servers do this, so responses are matched against the request rather + * than trusted, and the strays are counted rather than silently applied. + */ + val unsolicited: List, +) + +/** The outcome of a PUT. Every branch here is a different bug if merged with another. */ +sealed interface PutOutcome { + + /** Stored, with a strong ETag we can use as the next `If-Match`. */ + data class Stored(val href: HttpUrl, val eTag: ETag) : PutOutcome + + /** + * Stored, but the ETag was absent or weak. + * + * Not an error and not a conflict: the resource is on the server. It has to + * be re-fetched for a usable validator *and* for the server's canonical + * body, which may not be the bytes we sent. + */ + data class StoredNeedsRefetch(val href: HttpUrl) : PutOutcome + + /** 412 against `If-None-Match: *` — something already lives at this name. */ + data class NameTaken(val href: HttpUrl) : PutOutcome + + /** 412 against `If-Match`, and the resource is still there: the server is newer. */ + data object ServerNewer : PutOutcome + + /** + * 412 against `If-Match`, and the resource is gone. + * + * Spec-correct and not a conflict at all — a delete on the server racing an + * edit here. RFC 9110 requires 412 rather than 404 when the precondition is + * evaluated, so only a follow-up `HEAD` can tell the two apart. + */ + data object Vanished : PutOutcome + + /** + * The server refused this body, and sending it again will not help. + * + * Covers 4xx and 5xx alike: `507` MUST NOT be auto-retried (RFC 4918 §11.5) + * and a contradictory `RRULE`/`EXDATE` pair returns 500 from Nextcloud + * forever, so neither class earns a retry loop. + */ + data class Rejected(val code: Int, val message: String) : PutOutcome + + /** The request never got an answer. Transport, not judgement. */ + data class Failed(val reason: String) : PutOutcome +} + +/** The outcome of a DELETE. */ +sealed interface DeleteOutcome { + + /** + * Gone from the server. + * + * 404 and 410 count as success: the goal was for the resource not to exist, + * and it does not. + */ + data object Deleted : DeleteOutcome + + /** 412: the server's copy changed after we last saw it. */ + data object ServerNewer : DeleteOutcome + + data class Rejected(val code: Int, val message: String) : DeleteOutcome + + data class Failed(val reason: String) : DeleteOutcome +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CollectionAdmin.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CollectionAdmin.kt new file mode 100644 index 0000000..6364acf --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CollectionAdmin.kt @@ -0,0 +1,323 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.insertTag +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.io.StringWriter + +/** + * What a server will let us do to the collections themselves. + * + * ⚠️ Asked, never assumed, because the alternative is an affordance that + * 405s: **iCloud is + * extended-MKCOL only** and answers 405 to MKCALENDAR, **Google has neither** + * (and no VTODO either), and **Posteo disables collection creation outright**. + * Offering "new list on this account" to any of them produces a failure the user + * can do nothing about, at the end of a form they have already filled in. + */ +data class CollectionSupport( + /** RFC 4791 §5.3.1. The straightforward route, and what Nextcloud wants. */ + val mkCalendar: Boolean, + /** RFC 5689: MKCOL carrying a body that sets `resourcetype`. iCloud's route. */ + val extendedMkCol: Boolean, +) { + /** Whether a "new list here" affordance should exist at all. */ + val canCreate: Boolean get() = mkCalendar || extendedMkCol + + companion object { + /** What an OPTIONS we could not read has to mean: offer nothing. */ + val NONE = CollectionSupport(mkCalendar = false, extendedMkCol = false) + } +} + +/** How a collection write ended. */ +sealed interface CollectionOutcome { + data class Created(val url: HttpUrl) : CollectionOutcome + data object Updated : CollectionOutcome + + /** + * The server understood and refused. Terminal: retrying writes nothing and + * the user has to be told. + * + * [code] is for logs and for deciding; the caller owns the wording, the same + * rule [CalDavDiscovery.Outcome.Cause] states. + */ + data class Refused(val code: Int, val reason: String) : CollectionOutcome + + /** The server never answered. Worth retrying, unlike [Refused]. */ + data class Failed(val reason: String) : CollectionOutcome + + /** Neither creation method exists here. Nothing to retry and nothing to fix. */ + data object Unsupported : CollectionOutcome +} + +/** + * Creating, renaming, recolouring and deleting task collections. + * + * A seam, like [RemoteCalendar]: deciding *what* to do to a collection — refuse + * a read-only one, pick a name that will not collide, roll a local row back when + * the server says no — is the app's business and should not need a server to + * exercise. [DavCollectionAdmin] is the only production implementation. + */ +interface CollectionAdmin { + + /** What [homeSet] supports, as an OPTIONS rather than a guess. */ + fun support(homeSet: HttpUrl): CollectionSupport + + /** + * Makes a task collection under [homeSet]. + * + * @param name the path segment to create it at. The caller owns collision + * handling, because only it knows whether a second attempt is wanted. + */ + fun create( + homeSet: HttpUrl, + name: String, + displayName: String, + color: Int?, + support: CollectionSupport, + ): CollectionOutcome + + /** PROPPATCH: a rename, a recolour, or both. */ + fun updateProperties(url: HttpUrl, displayName: String?, color: Int?): CollectionOutcome + + /** DELETE. The tasks go with it, on the server and everywhere else. */ + fun delete(url: HttpUrl): CollectionOutcome +} + +class DavCollectionAdmin( + private val httpClient: OkHttpClient, +) : CollectionAdmin { + + /** + * ⚠️ Both headers, and neither on its own is enough. + * + * `Allow` names the methods, which is where MKCALENDAR shows up; `DAV` names + * the compliance classes, which is the only place `extended-mkcol` is ever + * advertised (RFC 5689 §5.1). A server may support extended MKCOL while + * listing plain MKCOL in `Allow` — that is the normal shape — so reading + * `Allow` alone reports iCloud as unable to create anything. + */ + override fun support(homeSet: HttpUrl): CollectionSupport = try { + // No compression: some servers have broken compression for OPTIONS, + // which is why DavResource disables it for the same request. + val request = Request.Builder() + .url(homeSet) + .method("OPTIONS", null) + .header("Content-Length", "0") + .header("Accept-Encoding", "identity") + .build() + Redirects.follow(httpClient, request).use { response -> + if (!response.isSuccessful) return CollectionSupport.NONE + val allow = headerTokens(response.headers("Allow")) + val dav = headerTokens(response.headers("DAV")) + CollectionSupport( + mkCalendar = "MKCALENDAR" in allow, + extendedMkCol = "EXTENDED-MKCOL" in dav, + ) + } + } catch (_: IOException) { + // Unknown reads as "no", which costs the user an affordance they can + // reach again on the next attempt — the other way round costs them a + // 405 at the end of a form. + CollectionSupport.NONE + } + + /** + * ⚠️ MKCALENDAR first where both exist. It is the method written for this + * job, every server that has it treats it as authoritative, and extended + * MKCOL's `resourcetype` set is refused outright by some servers that + * nonetheless advertise the class. + */ + override fun create( + homeSet: HttpUrl, + name: String, + displayName: String, + color: Int?, + support: CollectionSupport, + ): CollectionOutcome { + if (!support.canCreate) return CollectionOutcome.Unsupported + // A trailing slash, always: a collection URL that lacks one is resolved + // against its *parent* by every later `resolve`, so the first resource + // written into it lands one level up. + val url = homeSet.newBuilder().addPathSegment(name).addPathSegment("").build() + val (method, body) = if (support.mkCalendar) { + "MKCALENDAR" to mkCalendarBody(displayName, color) + } else { + "MKCOL" to extendedMkColBody(displayName, color) + } + return send(method, url, body) { CollectionOutcome.Created(url) } + } + + override fun updateProperties( + url: HttpUrl, + displayName: String?, + color: Int?, + ): CollectionOutcome { + if (displayName == null && color == null) return CollectionOutcome.Updated + return send("PROPPATCH", url, propPatchBody(displayName, color)) { + // ⚠️ The 207 is not read for per-property statuses, deliberately. A + // server that stores the name and refuses the colour answers 207 + // with a 403 propstat around `calendar-color` — and there is nothing + // for the user to do about a colour their server will not keep, + // while failing the whole rename over it would be worse. The colour + // is ours locally either way. + CollectionOutcome.Updated + } + } + + override fun delete(url: HttpUrl): CollectionOutcome { + val request = Request.Builder().url(url).delete().build() + return execute(request) { response -> + // ⚠️ 404 and 410 are success, exactly as they are for a resource: + // the point of a DELETE is for the thing to be absent, and it is. + when { + response.isSuccessful || response.code == NOT_FOUND || response.code == GONE -> + CollectionOutcome.Updated + else -> CollectionOutcome.Refused(response.code, response.message) + } + } + } + + private fun send( + method: String, + url: HttpUrl, + body: String, + onSuccess: () -> CollectionOutcome, + ): CollectionOutcome { + val request = Request.Builder() + .url(url) + .method(method, body.toRequestBody(MIME_XML)) + .build() + return execute(request) { response -> + if (response.isSuccessful) onSuccess() else { + CollectionOutcome.Refused(response.code, response.message) + } + } + } + + private fun execute( + request: Request, + grade: (okhttp3.Response) -> CollectionOutcome, + ): CollectionOutcome = try { + Redirects.follow(httpClient, request).use(grade) + } catch (e: IOException) { + CollectionOutcome.Failed(e.message ?: e.toString()) + } + + /** + * ⚠️ `VTODO` and nothing else. + * + * The component set is the one property here that changes what the + * collection *is*: a calendar created without it is a + * `supported-calendar-component-set` of the server's choosing, which on + * several is events-only — and a task list that refuses tasks is the worst + * possible outcome of "new list". + */ + private fun mkCalendarBody(displayName: String, color: Int?): String = xml { serializer -> + serializer.insertTag(MKCALENDAR) { + insertTag(SET) { + insertTag(PROP) { + insertTag(DISPLAYNAME) { text(displayName) } + insertTag(COMPONENT_SET) { + insertTag(COMP) { attribute(null, "name", COMPONENT) } + } + color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } } + } + } + } + } + + /** + * RFC 5689, where the resource type is set by the request rather than by the + * method — which is the whole difference, and the reason iCloud needs this + * one. + */ + private fun extendedMkColBody(displayName: String, color: Int?): String = xml { serializer -> + serializer.insertTag(MKCOL) { + insertTag(SET) { + insertTag(PROP) { + insertTag(RESOURCETYPE) { + insertTag(COLLECTION) + insertTag(CALENDAR) + } + insertTag(DISPLAYNAME) { text(displayName) } + insertTag(COMPONENT_SET) { + insertTag(COMP) { attribute(null, "name", COMPONENT) } + } + color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } } + } + } + } + } + + private fun propPatchBody(displayName: String?, color: Int?): String = xml { serializer -> + serializer.insertTag(PROPERTYUPDATE) { + insertTag(SET) { + insertTag(PROP) { + displayName?.let { insertTag(DISPLAYNAME) { text(it) } } + color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } } + } + } + } + } + + private fun xml(build: (org.xmlpull.v1.XmlSerializer) -> Unit): String { + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.setPrefix("d", XmlUtils.NS_WEBDAV) + serializer.setPrefix("c", XmlUtils.NS_CALDAV) + serializer.setPrefix("i", XmlUtils.NS_APPLE_ICAL) + serializer.startDocument("UTF-8", null) + build(serializer) + serializer.endDocument() + return writer.toString() + } + + /** + * ⚠️ `#RRGGBBAA`, which is what Apple's `calendar-color` is and what + * [at.bitfire.dav4jvm.property.CalendarColor] parses back. Writing the + * packed ARGB `Int` verbatim yields "-65536" for red, which is not a colour + * in any spelling. + */ + private fun hexOf(argb: Int): String = "#%06X%02X".format(argb and 0xFFFFFF, argb ushr 24) + + /** + * A comma-separated header, split and normalised. + * + * Repeated headers *and* comma lists, because servers use both spellings for + * `DAV` and for `Allow`. + */ + private fun headerTokens(values: List): Set = values + .flatMap { it.split(',') } + .map { it.trim().uppercase() } + .filter { it.isNotEmpty() } + .toSet() + + private companion object { + const val COMPONENT = "VTODO" + const val NOT_FOUND = 404 + const val GONE = 410 + val MIME_XML = "application/xml; charset=utf-8".toMediaType() + + val MKCALENDAR = Property.Name(XmlUtils.NS_CALDAV, "mkcalendar") + val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar") + val COMPONENT_SET = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set") + val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp") + val MKCOL = Property.Name(XmlUtils.NS_WEBDAV, "mkcol") + val SET = Property.Name(XmlUtils.NS_WEBDAV, "set") + val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop") + val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate") + val DISPLAYNAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname") + val RESOURCETYPE = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype") + val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection") + val CALENDAR_COLOR = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color") + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CollectionClassifier.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CollectionClassifier.kt new file mode 100644 index 0000000..a5a036d --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/CollectionClassifier.kt @@ -0,0 +1,145 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.property.CalendarColor +import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet +import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.MaxICalendarSize +import at.bitfire.dav4jvm.property.ResourceType +import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet +import at.bitfire.dav4jvm.property.SupportedReportSet +import at.bitfire.dav4jvm.property.push.PushTransports +import at.bitfire.dav4jvm.property.push.Topic +import okhttp3.HttpUrl + +/** A collection that survived classification, and what we know about it. */ +data class TaskCollection( + val url: HttpUrl, + val displayName: String?, + /** Packed ARGB, the same form `task_lists.color` stores. */ + val color: Int?, + val readOnly: Boolean, + /** + * A share rather than the user's own collection. + * + * Worth carrying: Nextcloud **rewrites task bodies on GET from a shared + * calendar** — stripping `VALARM`, and reducing `CLASS:CONFIDENTIAL` to a + * VEVENT-shaped whitelist — while leaving the ETag untouched. Re-PUTting what + * we downloaded destroys the owner's task, so the engine needs to know. + */ + val isShared: Boolean, + val supportsSyncCollection: Boolean, + val maxResourceSize: Long?, + /** WebDAV-Push over Web Push, when the server offers it for this collection. */ + val push: PushSupport? = null, +) + +/** + * Decides which collections in a Depth-1 listing can hold tasks. + * + * Both of the rules here are **inversions of the obvious one**, and they are + * the important part of discovery. Getting either + * backwards produces a client that silently finds nothing on a large fraction of + * real servers, with no error to diagnose. + */ +object CollectionClassifier { + + /** + * The properties [classify] reads, requested **by name**. + * + * `allprop` legitimately omits every one of them (RFC 4791 §5.2.3 for the + * component set, RFC 3744 §3.7 for the privileges), so asking for them by + * name is not an optimisation — it is the only way to get them. + */ + val PROPERTIES = arrayOf( + ResourceType.NAME, + DisplayName.NAME, + CalendarColor.NAME, + SupportedCalendarComponentSet.NAME, + SupportedReportSet.NAME, + CurrentUserPrivilegeSet.NAME, + MaxICalendarSize.NAME, + PushTransports.NAME, + Topic.NAME, + ) + + /** `CS:shared`, which Nextcloud adds alongside `caldav:calendar` on a share. */ + private val SHARED = Property.Name("http://calendarserver.org/ns/", "shared") + + fun classify(response: Response): TaskCollection? { + val resourceType = response[ResourceType::class.java] ?: return null + + // ⚠️ Positive test on an *unordered set*, never exclusion and never by + // position. Excluding `schedule-outbox` — the obvious rule — drops SOGo's + // main personal calendar, which reports collection + calendar + + // schedule-outbox simultaneously for every non-Apple client, i.e. for us. + // The positive rule also keeps shared calendars (they add CS:shared) and + // still drops Nextcloud's nc:deleted-calendar, which deliberately strips + // caldav:calendar. + if (ResourceType.CALENDAR !in resourceType.types) return null + + if (!supportsTasks(response)) return null + + val privileges = response[CurrentUserPrivilegeSet::class.java] + // Absent means writable. RFC 3744 §3.7 lets a server withhold the + // property, and DAVx5 defaults to writable for the same reason: assuming + // read-only hides collections the user can perfectly well write to. + // A 403 on write is handled where it happens. + val readOnly = privileges != null && + !privileges.mayWriteContent && !privileges.mayBind + + val reports = response[SupportedReportSet::class.java] + + return TaskCollection( + url = response.href, + displayName = response[DisplayName::class.java]?.displayName, + // CalendarColor.color is a packed ARGB Int. Calling toString() on it + // yields "-65536" for red — an unparseable decimal, not a colour. + color = response[CalendarColor::class.java]?.color, + readOnly = readOnly, + isShared = SHARED in resourceType.types, + // A hint, not a contract — Radicale advertised this for years without + // implementing it, so the engine must still degrade gracefully. + supportsSyncCollection = reports?.reports?.contains(SupportedReportSet.SYNC_COLLECTION) == true, + maxResourceSize = response[MaxICalendarSize::class.java]?.maxSize, + push = pushSupport(response), + ) + } + + /** + * Push needs both halves: a Web Push transport to subscribe with and a topic + * to recognise the messages by. Either alone is useless, so either alone is + * no support at all. + */ + internal fun pushSupport(response: Response): PushSupport? { + val webPush = response[PushTransports::class.java]?.webPush ?: return null + val topic = response[Topic::class.java]?.topic ?: return null + return PushSupport(topic = topic, vapidPublicKey = webPush.vapidPublicKey) + } + + /** + * ⚠️ **An absent `supported-calendar-component-set` means "supports + * everything", not "supports nothing".** + * + * RFC 4791 §5.2.3 says the property SHOULD NOT be returned from an allprop + * request, so it is legitimately missing unless asked for **by name** — and + * even then plenty of servers omit it. Keeping only collections whose set + * *includes* VTODO therefore drops every server that does not advertise it. + * + * The grammar is `(comp+)`; an empty element is non-conformant, and + * dav4jvm's parser starts all-`false`, so an empty one arrives + * indistinguishable from "no VTODO". Treat that as all too — the cost of + * being wrong is an empty collection list, and the cost of the other error is + * a task list the user cannot reach. + */ + internal fun supportsTasks(response: Response): Boolean { + val components = response[SupportedCalendarComponentSet::class.java] ?: return true + val advertisesNothing = !components.supportsEvents && + !components.supportsTasks && + !components.supportsJournal + if (advertisesNothing) return true + return components.supportsTasks + } + +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt new file mode 100644 index 0000000..3824dd1 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/DnsJavaResolver.kt @@ -0,0 +1,34 @@ +package de.jeanlucmakiola.caldav + +import org.xbill.DNS.Lookup +import org.xbill.DNS.SRVRecord +import org.xbill.DNS.TXTRecord +import org.xbill.DNS.Type + +/** + * SRV/TXT lookups over dnsjava. + * + * Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is + * callback-only and does not help with the `TXT path=` half — and JNDI's DNS + * provider does not exist on Android at all. dnsjava is what DAVx5 uses. + * + * A lookup that fails returns an empty list rather than throwing: DNS being + * unavailable means "no SRV record", which is an ordinary and common answer, and + * the well-known ladder still has rungs left. + */ +class DnsJavaResolver : DnsResolver { + + override fun srv(name: String): List = runCatching { + Lookup(name, Type.SRV).run() + .orEmpty() + .filterIsInstance() + .map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) } + }.getOrDefault(emptyList()) + + override fun txt(name: String): List = runCatching { + Lookup(name, Type.TXT).run() + .orEmpty() + .filterIsInstance() + .flatMap { it.strings } + }.getOrDefault(emptyList()) +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/NextcloudLoginFlow.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/NextcloudLoginFlow.kt new file mode 100644 index 0000000..65a5604 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/NextcloudLoginFlow.kt @@ -0,0 +1,363 @@ +package de.jeanlucmakiola.caldav + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Nextcloud Login Flow v2. + * + * The user approves the app in a browser and the server mints a dedicated **app + * password**, so the account password never touches this device and the grant can + * be revoked from the server's own settings. It is not deprecated, there is no + * v3, and OAuth2 is a worse fit. + * + * Every rule below is a correction from an audit of the first draft, and each + * one turns a diagnosable error back into a diagnosable error. + */ +class NextcloudLoginFlow( + private val httpClient: OkHttpClient, + /** + * ⚠️ Becomes the **app password's name** in Settings → Security → Devices & + * sessions. With OkHttp's default the user sees `okhttp/4.12.0` and cannot + * tell what to revoke — which defeats the entire point of the flow. + */ + private val userAgent: String, +) { + + private val json = Json { ignoreUnknownKeys = true } + + /** The 20-minute server-side lifetime (`lifetime = 1200` in `LoginFlowV2Mapper.php`). */ + val flowLifetimeSeconds = 1200L + + /** + * A started flow. Persist this **before** launching the browser: the flow + * outlives our process, and Custom Tabs return no result when dismissed. + */ + data class Flow( + val loginUrl: HttpUrl, + val pollEndpoint: HttpUrl, + val pollToken: String, + val deadlineEpochSeconds: Long, + /** + * Set when the server sent us to a different host than the user typed. + * **Carried, not thrown**: it is generated from `overwrite.cli.url` / + * `overwriteprotocol` / `trusted_proxies` and is legitimate behind a + * reverse proxy, which describes a large share of self-hosted installs. + * Refusing outright would make Login Flow v2 unusable for them. The UI + * confirms it with the user, quoting the cause. + */ + val hostMismatch: HostMismatch? = null, + ) + + data class Credentials(val server: HttpUrl, val loginName: String, val appPassword: String) + + sealed interface PollResult { + data class Approved( + val credentials: Credentials, + /** + * The server named an origin outside the one we polled, and we used + * the one we polled instead. Reported so the user can be told which + * setting is wrong, never acted on. + */ + val hostMismatch: HostMismatch? = null, + ) : PollResult + /** 404: still waiting. Also what an expired or already-consumed flow returns. */ + data object Pending : PollResult + data class Expired(val reason: String) : PollResult + data class Failed(val cause: Cause, val reason: String) : PollResult + + /** + * Why the flow ended, in a form the UI can translate. + * + * ⚠️ The UI must render *this*, never [Failed.reason]. A server's own + * words are untranslatable, often in a language the user does not read, + * and here they are frequently a bare status line or a JSON parser's + * complaint. [reason] exists for logs, and is never shown — the same rule + * [CalDavDiscovery.Outcome.Cause] states for discovery. + */ + enum class Cause { + /** Answering, but turning away repeated attempts (429). */ + RATE_LIMITED, + + /** Down on purpose (503). */ + MAINTENANCE, + + /** The server's own error, or an answer we could not read. */ + SERVER_ERROR, + } + } + + /** + * @param server the base URL the user typed + * @param now epoch seconds, for the locally tracked deadline + */ + fun start(server: HttpUrl, now: Long): Result = runCatching { + val request = Request.Builder() + .url(server.newBuilder().addPathSegments(FLOW_START_PATH).build()) + .header("User-Agent", userAgent) + // OCS-APIRequest is *not* needed: v2 is a Frontpage route, not OCS. + .post(FormBody.Builder().build()) + .build() + + Redirects.follow(httpClient, request).use { response -> + if (!response.isSuccessful) error("login flow init failed: HTTP ${response.code}") + val body = response.body?.string().orEmpty() + val root = json.parseToJsonElement(body).jsonObject + val poll = root["poll"]?.jsonObject ?: error("no poll object in login flow response") + + val endpointRaw = poll["endpoint"]?.jsonPrimitive?.content + ?: error("no poll endpoint") + val endpoint = endpointRaw.toHttpUrlOrNull() ?: error("poll endpoint is not a URL") + requireSecureOrigin(server, endpoint) + + val loginUrl = root["login"]?.jsonPrimitive?.content?.toHttpUrlOrNull() + ?: error("no login URL") + // ⚠️ The login URL is where the user types their **account** password, + // in a browser. Validating only the poll endpoint leaves the more + // dangerous of the two unchecked: a misconfigured or hostile server — + // or a MITM on a typed http:// base — could point the browser at any + // origin and harvest it. + requireSecureOrigin(server, loginUrl) + + Flow( + loginUrl = loginUrl, + pollEndpoint = endpoint, + pollToken = poll["token"]?.jsonPrimitive?.content ?: error("no poll token"), + deadlineEpochSeconds = now + flowLifetimeSeconds, + hostMismatch = hostMismatchOf(server, endpoint) ?: hostMismatchOf(server, loginUrl), + ) + } + } + + /** + * One poll. The 200 is returned **exactly once** — the server deletes the row + * inside `poll()` before returning — so the caller must persist the result + * immediately. + */ + fun poll(flow: Flow, now: Long): PollResult { + if (now > flow.deadlineEpochSeconds) { + // 404 is also what an expired flow returns, so the deadline is tracked + // locally or "expired" is indistinguishable from "still waiting". + return PollResult.Expired("the 20-minute approval window has passed") + } + + val request = Request.Builder() + .url(flow.pollEndpoint) + .header("User-Agent", userAgent) + // ⚠️ POST, form-encoded. A GET gets 405. + .post(FormBody.Builder().add("token", flow.pollToken).build()) + .build() + + return runCatching { + Redirects.follow(httpClient, request).use { response -> + when { + // ⚠️ 404 means pending, and *only* 404 does. "Anything that + // isn't 200 is pending" swallows 429 (brute-force protection), + // 503 (maintenance), a Cloudflare challenge page and every + // DNS/TLS failure — turning a diagnosable error into a + // twenty-minute spinner. + response.code == 404 -> PollResult.Pending + + response.code == 200 -> { + val contentType = response.header("Content-Type").orEmpty() + // A Cloudflare challenge is a 200 carrying HTML. + if (!contentType.contains("application/json", ignoreCase = true)) { + PollResult.Failed( + PollResult.Cause.SERVER_ERROR, + "server answered 200 with $contentType, not JSON", + ) + } else { + parseCredentials(flow, response.body?.string().orEmpty()) + } + } + + response.code == 429 -> + PollResult.Failed( + PollResult.Cause.RATE_LIMITED, + "the server is rate-limiting this address (429)", + ) + + response.code == 503 -> + PollResult.Failed( + PollResult.Cause.MAINTENANCE, + "the server is in maintenance mode (503)", + ) + + else -> PollResult.Failed( + PollResult.Cause.SERVER_ERROR, + "unexpected HTTP ${response.code}", + ) + } + } + }.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) } + } + + private fun parseCredentials(flow: Flow, body: String): PollResult = runCatching { + val root = json.parseToJsonElement(body).jsonObject + val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull() + ?: error("no server URL in poll response") + // ⚠️ Coerced, never refused — neither the host nor the scheme may discard + // the credentials. The 200 is returned exactly once (the server deletes + // the row inside poll() before answering), so a throw here burns a live + // app password and leaves it dangling in the user's device list. + val secureServer = reachableOrigin(flow.pollEndpoint, server) + PollResult.Approved( + hostMismatch = substitutedMismatch(flow.pollEndpoint, server, secureServer), + credentials = Credentials( + server = secureServer, + // ⚠️ loginName is what the user typed — possibly an email, an + // LDAP-derived value, or the right name in the wrong case. It is + // the Basic auth username and nothing else. Interpolating it into + // `remote.php/dav/calendars//` is the classic "logged + // in but no calendars" bug; the principal comes from discovery. + loginName = root["loginName"]?.jsonPrimitive?.content ?: error("no loginName"), + appPassword = root["appPassword"]?.jsonPrimitive?.content ?: error("no appPassword"), + ), + ) + }.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) } + + /** + * These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` / + * `trusted_proxies`, which are misconfigured on a large fraction of + * self-hosted installs — so they are validated rather than trusted verbatim. + * + * A downgrade to `http` is **fatal here and only here**: this runs *before* + * the user has approved anything, and the login URL is where they type their + * account password. There is nothing to lose by refusing, and everything to + * lose by not. + */ + internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) { + if (expected.isHttps && !actual.isHttps) { + error("the server returned an http:// URL (${actual.host}) for an https:// server") + } + } + + /** + * The same downgrade, on the *poll response*, where refusing is the wrong + * answer. + * + * ⚠️ By this point the credential has already been issued, and Nextcloud + * returns it **exactly once** — the row is deleted inside `poll()` before it + * answers. Throwing here does not protect anything: it destroys a live app + * password, leaves one dangling in the user's device list, and sends them + * through the whole flow again. The comment on the host mismatch above says + * precisely this, and the scheme deserves the same treatment. + * + * Coercing is strictly safer than either alternative, because the invariant + * that matters is *what we do next*: we only ever talk to the coerced URL, so + * the credential never travels in cleartext regardless of what the server + * put in the JSON. + */ + internal fun secureOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl = + if (expected.isHttps && !actual.isHttps) { + // ⚠️ The port goes with the scheme. OkHttp only drops a *default* + // port across a scheme change, so `http://cloud.example.com:8080/` + // coerced to https keeps :8080 — a port that almost certainly speaks + // cleartext, and the promised coercion becomes a TLS handshake + // failure. The port that answered our poll is the one known to work, + // and a claimed port emitted alongside a wrong scheme comes from the + // same misconfiguration as the scheme did. + actual.newBuilder().scheme("https").port(expected.port).build() + } else { + actual + } + + /** + * The origin to actually talk to, given what the poll response claimed. + * + * ⚠️ `server` and the poll endpoint come out of *different* generators in + * Nextcloud: the poll endpoint honours `overwrite.cli.url`, while `server` + * is built from the approving request's own protocol and Host header, which + * respect `X-Forwarded-*` only once `trusted_proxies` is set. The ordinary + * docker-compose-behind-nginx install therefore answers a perfectly good + * `https://cloud.example.com` poll endpoint with + * `"server": "http://nextcloud:11000"` — a name that does not resolve on the + * phone. Following it means discovery fails, the account is never created, + * and the app password is already spent. + * + * So when the claimed origin is outside the registrable domain we just + * successfully polled, rebuild it entirely from the poll endpoint — scheme, + * host, port *and* base path — which is the one URL empirically known to + * answer. The claimed path is dropped with the claimed host: both come from + * the same generator, and keeping half of a URL we have decided not to trust + * is how a subdirectory install loses its prefix. The endpoint's own prefix + * is whatever precedes `index.php/login/v2/poll`, which is where the flow + * was opened. Coerced, never refused: by this point the credential exists + * and the 200 is spent, so throwing burns a live app password. + * + * This compares one server-emitted origin against another, never against + * what the user typed, so a correctly configured proxy — which emits the + * same origin in both — is untouched. A sibling host under the same + * registrable domain passes through, because that is a real deployment shape + * and the credential is scoped to that domain anyway. + */ + internal fun reachableOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl { + val secure = secureOrigin(expected, actual) + // The same boundary the credential is scoped by; null for an IP literal + // or a single-label host, where only the exact host will do. + val claimed = secure.topPrivateDomain() ?: secure.host + val polled = expected.topPrivateDomain() ?: expected.host + if (claimed.equals(polled, ignoreCase = true)) return secure + return expected.newBuilder() + .encodedPath(baseOf(expected)) + .query(null) + .fragment(null) + .build() + } + + private companion object { + const val FLOW_START_PATH = "index.php/login/v2" + + /** What `start()` appends, plus the poll leg — with and without the front controller. */ + val FLOW_TAILS = listOf("index.php/login/v2/poll", "login/v2/poll") + } + + /** + * The poll endpoint with the flow's own path removed — the server's web root. + * + * ⚠️ Both spellings. Nextcloud drops `index.php` from generated routes when + * `htaccess.IgnoreFrontController` is on, so a subdirectory install can answer + * `/nc/login/v2/poll` — and matching only the `index.php` form would return + * "/" and lose the `/nc` prefix, which is the exact loss this function exists + * to prevent. + */ + private fun baseOf(pollEndpoint: HttpUrl): String { + val path = pollEndpoint.encodedPath + val tail = FLOW_TAILS.map { path.indexOf(it) }.firstOrNull { it >= 0 } ?: return "/" + return path.take(tail).ifEmpty { "/" } + } + + /** + * The mismatch to report, which is only ever one we acted on. + * + * ⚠️ [hostMismatchOf] compares hosts exactly, while [reachableOrigin] + * substitutes only when the *registrable domain* differs. So a server + * answering `nc.example.com` for a poll endpoint on `cloud.example.com` is + * used verbatim and correctly — and reporting that would tell the user we + * replaced an address we did not, blaming a setting that is right. The note + * is sticky, so it would follow them through the rest of the flow. + */ + internal fun substitutedMismatch( + expected: HttpUrl, + claimed: HttpUrl, + used: HttpUrl, + ): HostMismatch? = + hostMismatchOf(expected, claimed)?.takeIf { used.host != claimed.host } + + /** A different host than the user typed — reported, not refused. */ + internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? = + if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null + + /** The server pointed the flow at a different host than the user typed. */ + data class HostMismatch(val expected: String, val actual: String) { + val message: String + get() = "the server sent us to \"$actual\" but you typed \"$expected\" — " + + "its overwrite.cli.url is probably wrong" + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/Redirects.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/Redirects.kt new file mode 100644 index 0000000..ff761dc --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/Redirects.kt @@ -0,0 +1,67 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException + +/** + * Redirect following for the plain-HTTP corners of this module. + * + * ⚠️ Every client here is built with `followRedirects(false)`, because + * `DavResource` requires it and asserts on it — so the DAV calls follow by hand + * and the two OCS/JSON ones did not follow at all. A Nextcloud that + * canonicalises host or path with a 301 (apex→www, a trailing slash, a proxy) + * therefore turned the login flow's `start` into a bare failure, its `poll` into + * an unexplained SERVER_ERROR *after* the password was minted, and the app + * password revocation into the "attempted, and did nothing" outcome its own + * KDoc exists to prevent. + * + * ⚠️ The method and body are **re-issued as they were**, which is 307/308 + * behaviour rather than the classic 301/302 POST→GET rewrite. Both endpoints + * answer 405 to a GET, so rewriting the method would turn one silent failure + * into another; and the bodies here are in-memory forms, so replaying costs + * nothing. + */ +internal object Redirects { + + /** The same ceiling `DavResource` uses, for the same reason. */ + const val MAX_HOPS = 5 + + @Throws(IOException::class) + fun follow(client: OkHttpClient, request: Request): Response { + var current = request + repeat(MAX_HOPS) { + val response = client.newCall(current).execute() + if (!response.isRedirect) return response + val target = response.header("Location")?.let { current.url.resolve(it) } + response.close() + if (target == null) throw IOException("redirect with no usable Location from ${current.url}") + current = current.newBuilder().url(secure(current.url, target)).build() + } + throw IOException("more than $MAX_HOPS redirects from ${request.url}") + } + + /** + * The downgrade rule `DavResource` already applies, stated once more here. + * + * A downgrade to the *same host* is the single most common misconfiguration + * in this space — a Nextcloud behind a TLS-terminating proxy with no + * `overwriteprotocol` builds every redirect with `http://` — and re-issuing + * over TLS is strictly safer than what we were asked to do. A downgrade to a + * different host has no innocent reading. + * + * ⚠️ The port goes with the scheme: OkHttp only drops a *default* port + * across a scheme change, so a redirect to `http://host:8080` would + * otherwise be re-issued as `https://host:8080`, which almost certainly + * speaks cleartext. The port we were already talking to is the one known to + * answer. + */ + private fun secure(from: HttpUrl, to: HttpUrl): HttpUrl = when { + !from.isHttps || to.isHttps -> to + to.host.equals(from.host, ignoreCase = true) -> + to.newBuilder().scheme("https").port(from.port).build() + else -> throw IOException("refusing a redirect from HTTPS to HTTP at ${to.host}") + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/RemoteCalendar.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/RemoteCalendar.kt new file mode 100644 index 0000000..94f29fa --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/RemoteCalendar.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl + +/** + * The remote side of one task collection, as the sync engine needs it. + * + * A seam, and a load-bearing one: the reconciliation above this interface is + * where local edits get discarded, tombstones get swept and conflicts get + * resolved, and none of that should need a server — or a network — to exercise. + * [CalendarCollection] is the only production implementation. + */ +interface RemoteCalendar { + + val url: HttpUrl + + fun state(): Result + + fun list(): Result> + + /** + * One page of changes since [token], or null for a first run. + * + * Never throws for anything a server can legitimately answer — a rejected + * token is [ChangeSet.TokenInvalid], not an exception. + */ + fun changes(token: String?): ChangeSet + + fun fetch(hrefs: List): Result + + fun create(name: String, iCalendar: String): PutOutcome + + /** + * Replaces [href]. A null [eTag] writes **unconditionally**, which is only + * ever correct when the server offers no usable validator at all — see + * [ETag]. + */ + fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome + + fun delete(href: HttpUrl, eTag: String?): DeleteOutcome +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ResourceNames.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ResourceNames.kt new file mode 100644 index 0000000..0994b13 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ResourceNames.kt @@ -0,0 +1,85 @@ +package de.jeanlucmakiola.caldav + +import java.util.UUID + +/** + * Filenames for calendar resources. + * + * ⚠️ **An href is not a UID.** A UID is opaque text chosen by whoever created + * the task — it may contain slashes, spaces, percent signs or nothing printable + * at all — while an href is a path segment on someone else's filesystem. Using + * one as the other is the classic CalDAV client bug: it works against the server + * you tested on and produces 400s, 403s or silently truncated names elsewhere. + * + * The character class is vdirsyncer's, and the exclusion of `@` is deliberate: + * plenty of UIDs are email-shaped, and `@` in a path segment is legal but + * routinely mishandled by proxies and by servers that re-parse their own URLs. + */ +object ResourceNames { + + /** + * Cap on the basename, in bytes. + * + * Well under the 255 that most filesystems allow, because the server appends + * to what we send — Nextcloud's trashbin renames a deleted resource to + * `-deleted.ics`, and a name that only just fit before deletion does + * not fit afterwards. + */ + const val MAX_BASENAME_BYTES = 200 + + const val EXTENSION = ".ics" + + private val UNSAFE = Regex("[^A-Za-z0-9_.+-]") + + /** + * A filename derived from [uid], or a random one when [uid] does not survive + * sanitisation. + * + * The derivation is a convenience for humans reading the collection over + * WebDAV, never an identity: nothing reads the UID back out of an href. + */ + fun forUid(uid: String): String { + val cleaned = UNSAFE.replace(uid, "-") + .trim('-', '.') + .take(MAX_BASENAME_BYTES) + .trimEnd('-', '.') + return if (cleaned.isEmpty() || cleaned.all { it == '-' }) random() else cleaned + EXTENSION + } + + /** A fresh name that cannot collide, for the fallback and the 412 retry. */ + fun random(): String = UUID.randomUUID().toString() + EXTENSION + + /** + * A path segment for a new **collection**, derived from what the user called + * it. + * + * No extension: a collection is a directory, not a file. Derived rather than + * random for the same reason [forUid] is — someone browsing the account over + * WebDAV, or in their server's own web UI, should be able to tell which of + * these is "Shopping" — and, like [forUid], it is a convenience and never an + * identity: the href is what identifies the collection afterwards. + * + * ⚠️ Sanitised to the same class, which matters more here than it does for a + * resource: a display name is typed by a person, in their own language, and + * "Einkäufe 🛒" is an ordinary thing to call a list. What survives may be + * empty, and then a random segment is the honest answer. + */ + fun forCollection(displayName: String): String { + val cleaned = UNSAFE.replace(displayName, "-") + .trim('-', '.') + .take(MAX_BASENAME_BYTES) + .trimEnd('-', '.') + return if (cleaned.isEmpty() || cleaned.all { it == '-' }) randomCollection() else cleaned + } + + /** + * A collection segment that cannot collide. + * + * ⚠️ Also the retry, and Nextcloud is why it has to exist rather than being + * a fallback for unprintable names: its trashbin renames a deleted + * collection instead of removing it, so re-creating one under a name that + * was used before answers 403 for ever. A fresh segment is the only way + * back, and the display name is unaffected — two lists may share one. + */ + fun randomCollection(): String = UUID.randomUUID().toString() +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerQuirks.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerQuirks.kt new file mode 100644 index 0000000..620b4e3 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServerQuirks.kt @@ -0,0 +1,77 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl + +/** + * What we know about a server before talking to it. + * + * This exists for one reason: *"wrong password" that is actually "you used your + * account password"* is the single most common support ticket any CalDAV client + * inherits. Detecting it at account-add time by domain turns a dead end into one + * sentence of instruction. + * + * The domains themselves live on [CalDavProvider] — one list, so a provider the + * accounts screen can mark is also a provider this can warn about. + */ +enum class ServerQuirk { + + /** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */ + FASTMAIL_APP_PASSWORD, + + /** iCloud: app-specific password, and 2FA must be on to mint one. */ + ICLOUD_APP_SPECIFIC_PASSWORD, + + /** + * Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own + * documentation says *"Doesn't support VTODO or VJOURNAL data"*. It is + * not a target, so this is a refusal with an explanation rather than + * a 401 the user cannot act on. + */ + GOOGLE_UNSUPPORTED, + + /** + * Nextcloud's brute-force protection throttles then 429s **per source IP**, so + * a retry loop on a dead app password takes down the user's other Nextcloud + * clients on that network. Not domain-detectable; set when a server identifies + * itself. Kept here so the engine has one place to ask. + */ + NEXTCLOUD_BRUTE_FORCE_PROTECTED, + ; + + companion object { + + /** The quirk implied by an email address or a URL host, if any. */ + fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk + + fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk + + /** What is known about a service the user picked by name rather than typed. */ + fun forProvider(provider: CalDavProvider): ServerQuirk? = provider.quirk + + fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host) + + private val CalDavProvider.quirk: ServerQuirk? + get() = when (this) { + CalDavProvider.FASTMAIL -> FASTMAIL_APP_PASSWORD + CalDavProvider.ICLOUD -> ICLOUD_APP_SPECIFIC_PASSWORD + CalDavProvider.GOOGLE -> GOOGLE_UNSUPPORTED + CalDavProvider.NEXTCLOUD -> NEXTCLOUD_BRUTE_FORCE_PROTECTED + else -> null + } + } + + /** True when discovery should not even be attempted. */ + val isFatal: Boolean get() = this == GOOGLE_UNSUPPORTED + + /** + * True when the quirk is an errand the user can actually run — go to the + * provider, mint an app password, come back — rather than a refusal or a + * note for the engine. + * + * What separates the two is whether there is anything to *do*. Google is a + * dead end and the brute-force note is never shown, so neither earns the + * screen that walks through the steps. + */ + val hasSetupSteps: Boolean + get() = this == FASTMAIL_APP_PASSWORD || this == ICLOUD_APP_SPECIFIC_PASSWORD +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt new file mode 100644 index 0000000..1a5951f --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/ServiceDiscovery.kt @@ -0,0 +1,214 @@ +package de.jeanlucmakiola.caldav + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** One `_caldavs._tcp` SRV record. */ +data class SrvRecord( + val priority: Int, + val weight: Int, + val port: Int, + val target: String, +) + +/** + * DNS lookups for RFC 6764. Behind an interface so the pipeline is testable + * without a network, and so the resolver can be swapped per platform. + */ +interface DnsResolver { + fun srv(name: String): List + fun txt(name: String): List + + /** For the base-URL path, where DNS is never consulted. */ + object None : DnsResolver { + override fun srv(name: String) = emptyList() + override fun txt(name: String) = emptyList() + } +} + +/** + * Turns what the user typed into the ordered list of URLs worth probing. + * + * This is RFC 6764 §6 (`SRV` + `TXT`) followed by the well-known ladder. + * Every rule below cost + * somebody a support ticket: + * + * - **`TXT path=` is not optional.** Posteo publishes `path=/`, GMX and Web.de + * `path=/begenda/dav/users/`. Skipping the TXT lookup lands both on the wrong + * path and discovery finds nothing. + * - **The SRV port is not 443.** Posteo is SRV-only on **8443**, and its + * `/.well-known/caldav` 404s. Hardcoding the port fails it outright. + * - **A `.` target means "explicitly unavailable"** (RFC 2782), not "no record". + * `_caldav._tcp.fastmail.com` and `runbox.com` both answer `0 0 0 .`. + * - **`/` is the last rung**, after the TXT path and `/.well-known/caldav`. The + * draft omitted it. + * - **Priority and weight are honoured**, lowest priority first. + */ +object ServiceDiscovery { + + private const val SRV_SERVICE = "_caldavs._tcp" + private const val WELL_KNOWN = "/.well-known/caldav" + + /** A URL to probe, and where it came from — the "why" a failure report needs. */ + data class Candidate(val url: HttpUrl, val origin: String) + + /** + * Hosts whose SRV record leads somewhere that is not a DAV server. + * + * Google publishes a valid `_caldavs._tcp` record pointing at + * `calendar.google.com`, which answers **405** to PROPFIND. A strict RFC 6764 + * client follows it into a dead end for every `@gmail.com` address. Google is + * out of scope anyway — it supports neither VTODO nor MKCALENDAR — so this is + * caught at the front rather than surfaced as a baffling 405. + */ + private val SRV_DEAD_ENDS = setOf("calendar.google.com") + + /** + * @param input what the user typed: an email address, a `mailto:` URI, or a + * base URL + */ + fun candidatesFor(input: String, dns: DnsResolver = DnsResolver.None): List { + val trimmed = input.trim() + if (trimmed.isEmpty()) return emptyList() + + // A typed URL is taken as typed and DNS is never consulted — a user who + // gave us an address meant it. + asBaseUrl(trimmed)?.let { typed -> + val candidates = mutableListOf() + // ⚠️ A typed URL is not automatically a *DAV* URL, and the bare + // origin is what people actually type. `https://cloud.example.com` + // is the web UI: PROPFIND on it returns the 405 any web server + // answers, which reads as "not a CalDAV server" about a perfectly + // good one. RFC 6764 §6 exists precisely for this case, so the + // well-known probe has to run here too — returning the typed URL as + // the only candidate is what made a working Nextcloud undiscoverable. + // + // ⚠️ Built through `newBuilder`, not by interpolating `host`, which + // returns an IPv6 literal *without* its brackets — "fd00::1", not + // "[fd00::1]". Pasting that back into a URL yields a string OkHttp + // will not parse, so both candidates would be dropped and a typed + // IPv6 address would report as "not an address". `toString()` also + // drops a default port on its own. + val origin = typed.newBuilder() + .encodedPath("/") + .query(null) + .fragment(null) + .build() + .toString() + if (typed.encodedPath.trim('/').isEmpty()) { + add(candidates, origin, WELL_KNOWN, "typed origin + .well-known") + add(candidates, origin, "/", "typed origin + root") + } else { + // A deep URL may well be the DAV root itself, and PROPFIND on it + // can return principal, home-set and collection in one response. + // The well-known stays as the fallback for a path that was a + // guess. + candidates += Candidate(typed, "base URL as typed") + add(candidates, origin, WELL_KNOWN, "typed host + .well-known") + } + return candidates + } + + val domain = domainOf(trimmed) ?: return emptyList() + val candidates = mutableListOf() + + val srv = dns.srv("$SRV_SERVICE.$domain") + .filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 } + // DNS names are case-insensitive and dnsjava returns the wire case, so + // "Calendar.Google.com." would otherwise walk straight past the guard. + .filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS } + // ⚠️ RFC 2782 does not require a target to live inside the domain it + // was queried for, and this ladder is walked over plain UDP DNS with + // no DNSSEC — then walked *again* after a 401, with the + // authenticated client. An out-of-domain target is also one the + // credential would never be offered to, since CalDavHttp scopes it + // to the typed address's registrable domain, so following it can + // only end in a 401 the user cannot act on. Refusing leaves the + // well-known ladder on the typed domain, which is where a correctly + // delegated install answers anyway. + .filter { sharesDomain(it.target, domain) } + .sortedWith(compareBy({ it.priority }, { -it.weight })) + + // The TXT path applies to the SRV target, and to the bare domain when + // there is no SRV record — that is the GMX/Web.de shape. + val txtPath = dns.txt("$SRV_SERVICE.$domain") + .firstNotNullOfOrNull { record -> + record.split(' ', ';') + .firstOrNull { it.startsWith("path=", ignoreCase = true) } + ?.substringAfter('=') + ?.takeIf { it.isNotEmpty() } + } + + val origins = srv.map { record -> + val host = record.target.trimEnd('.').lowercase() + // Port 443 is the default and stays implicit; anything else is + // explicit, which is the whole point of Posteo's 8443. + val port = if (record.port == 443) "" else ":${record.port}" + "https://$host$port" to "SRV $host$port" + }.ifEmpty { + listOf("https://$domain" to "domain as typed") + } + + for ((origin, label) in origins) { + if (txtPath != null) { + add(candidates, origin, txtPath, "$label + TXT path=$txtPath") + } + add(candidates, origin, WELL_KNOWN, "$label + .well-known") + add(candidates, origin, "/", "$label + root") + } + return candidates + } + + /** + * Whether an SRV target may stand in for [domain] — the same registrable + * domain, or the exact host where there is none (an IP literal, a + * single-label name, a host that *is* a public suffix). + */ + private fun sharesDomain(target: String, domain: String): Boolean { + val host = target.trimEnd('.').lowercase() + val scope = scopeOf(domain) ?: return host.equals(domain, ignoreCase = true) + return scopeOf(host)?.equals(scope, ignoreCase = true) == true + } + + /** The boundary `CalDavHttp` scopes a credential by, derived the same way. */ + private fun scopeOf(host: String): String? { + val literal = if (':' in host) "[$host]" else host + val url = "https://$literal".toHttpUrlOrNull() ?: return null + return url.topPrivateDomain() ?: url.host + } + + private fun add(into: MutableList, origin: String, path: String, label: String) { + val url = (origin.trimEnd('/') + "/" + path.trimStart('/')).toHttpUrlOrNull() ?: return + if (into.none { it.url == url }) into += Candidate(url, label) + } + + /** + * The server **root** for [input] — the origin, not the DAV path. + * + * Nextcloud's Login Flow v2 lives at `index.php/login/v2` off the root, so it + * needs this rather than a discovered collection URL. A typed base URL is + * taken as typed; an email address becomes `https://`. + */ + fun serverRootFor(input: String): HttpUrl? { + val trimmed = input.trim() + asBaseUrl(trimmed)?.let { return it } + return domainOf(trimmed)?.let { "https://$it".toHttpUrlOrNull() } + } + + /** The input as a base URL, or null if it is an address rather than a URL. */ + internal fun asBaseUrl(input: String): HttpUrl? { + if (!input.startsWith("http://", ignoreCase = true) && + !input.startsWith("https://", ignoreCase = true) + ) { + return null + } + return input.toHttpUrlOrNull() + } + + /** The domain of an email address, a `mailto:` URI, or a bare domain. */ + internal fun domainOf(input: String): String? { + val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:") + val domain = withoutScheme.substringAfterLast('@').trim().trimEnd('.') + return domain.takeIf { it.isNotEmpty() && it.contains('.') && !it.contains('/') } + } +} diff --git a/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/WebDavPush.kt b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/WebDavPush.kt new file mode 100644 index 0000000..2440fe0 --- /dev/null +++ b/caldav/src/main/kotlin/de/jeanlucmakiola/caldav/WebDavPush.kt @@ -0,0 +1,202 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.DavCollection +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.HttpUtils +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.XmlUtils.propertyName +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.push.WebDAVPush +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.io.IOException +import java.io.StringReader +import java.io.StringWriter +import java.util.Date +import kotlin.time.Instant + +/** What a collection's server offers for WebDAV-Push over Web Push. */ +data class PushSupport( + /** Carried by every push message about this collection. */ + val topic: String, + /** The server's VAPID key; absent means unauthenticated pushes. */ + val vapidPublicKey: String?, +) + +/** + * The client side of the WebDAV-Push draft (https://github.com/bitfireAT/webdav-push): + * subscribing, unsubscribing and reading messages. Returns outcomes, never throws. + */ +object WebDavPush { + + sealed interface Registration { + /** Accepted. [url] is where it lives; null if the server sent no `Location`. */ + data class Registered(val url: HttpUrl?, val expires: Instant) : Registration + + /** A definite no — push unavailable here, or our subscription refused. */ + data class Refused(val code: Int) : Registration + + /** Nothing definite: the network, or a server error worth retrying. */ + data class Failed(val reason: String) : Registration + } + + /** A decoded push message. */ + data class Message( + /** Which resource changed; null only for a VAPID key rotation. */ + val topic: String?, + /** The collection's sync token after the change, when the server says. */ + val syncToken: String?, + /** The server's VAPID key changed and every subscription must be renewed. */ + val keyRotation: Boolean, + ) + + /** + * Subscribes [endpoint] to changes in [collection] and its members. Registering + * the same endpoint again renews it. + * + * @param publicKey the subscription's p256dh key, base64url. + * @param authSecret the subscription's auth secret, base64url; sent only with [publicKey]. + */ + fun register( + client: OkHttpClient, + collection: HttpUrl, + endpoint: String, + publicKey: String?, + authSecret: String?, + expires: Instant, + ): Registration = try { + var result: Registration = Registration.Failed("no response") + val body = registerBody(endpoint, publicKey, authSecret, expires).toRequestBody(MIME_XML) + DavCollection(client, collection).post(body) { response -> + val location = response.header("Location")?.let { collection.resolve(it) } + // The server may shorten what we asked for. + val granted = response.header("Expires") + ?.let { HttpUtils.parseDate(it) } + ?.let { Instant.fromEpochMilliseconds(it.time) } + result = Registration.Registered(location, granted ?: expires) + } + result + } catch (e: HttpException) { + if (e.code / 100 == 4) { + Registration.Refused(e.code) + } else { + Registration.Failed(listOfNotNull("HTTP ${e.code}", serverMessage(e.responseBody)).joinToString(": ")) + } + } catch (e: DavException) { + Registration.Failed(e.toString()) + } catch (e: IOException) { + Registration.Failed(e.toString()) + } + + /** The exception text sabre-based servers put in an error body, for the log. */ + internal fun serverMessage(body: String?): String? = + body?.let { MESSAGE.find(it)?.groupValues?.get(1)?.trim() }?.takeIf { it.isNotEmpty() } + + /** Removes the subscription at [registration]; false only when it may still exist. */ + fun unregister(client: OkHttpClient, registration: HttpUrl): Boolean = try { + DavResource(client, registration).delete { } + true + } catch (e: HttpException) { + e.code == NOT_FOUND || e.code == GONE + } catch (_: DavException) { + false + } catch (_: IOException) { + false + } + + /** `Push-Dont-Notify` for our own writes, so they do not wake us. */ + fun dontNotifyHeader(registration: HttpUrl): String = "\"$registration\"" + + /** Decodes a decrypted push message, or null when it is not one. */ + fun parse(message: String): Message? = try { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader(message)) + var result: Message? = null + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT && result == null) { + if (eventType == XmlPullParser.START_TAG && parser.propertyName() == WebDAVPush.PushMessage) { + result = readMessage(parser) + } + eventType = parser.next() + } + result + } catch (_: XmlPullParserException) { + null + } catch (_: IOException) { + null + } + + private fun readMessage(parser: XmlPullParser): Message { + var topic: String? = null + var syncToken: String? = null + var keyRotation = false + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG) { + when (parser.propertyName()) { + WebDAVPush.Topic -> topic = XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() } + SYNC_TOKEN -> syncToken = XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() } + // A key rotation names the transports property. + WebDAVPush.Transports -> keyRotation = true + } + } + eventType = parser.next() + } + return Message(topic = topic, syncToken = syncToken, keyRotation = keyRotation && topic == null) + } + + internal fun registerBody( + endpoint: String, + publicKey: String?, + authSecret: String?, + expires: Instant, + ): String { + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", true) + serializer.setPrefix("", WebDAVPush.NS_WEBDAV_PUSH) + serializer.setPrefix("D", XmlUtils.NS_WEBDAV) + serializer.insertTag(WebDAVPush.PushRegister) { + insertTag(WebDAVPush.Subscription) { + insertTag(WebDAVPush.WebPushSubscription) { + insertTag(WebDAVPush.PushResource) { text(endpoint) } + if (publicKey != null && authSecret != null) { + insertTag(WebDAVPush.ContentEncoding) { text("aes128gcm") } + insertTag(WebDAVPush.SubscriptionPublicKey) { + attribute(null, "type", "p256dh") + text(publicKey) + } + insertTag(WebDAVPush.AuthSecret) { text(authSecret) } + } + } + } + // Depth 1 covers the tasks; property changes are left to the periodic sync. + insertTag(WebDAVPush.Trigger) { + insertTag(WebDAVPush.ContentUpdate) { + insertTag(DEPTH) { text("1") } + } + } + insertTag(WebDAVPush.Expires) { + text(HttpUtils.formatDate(Date(expires.toEpochMilliseconds()))) + } + } + serializer.endDocument() + return writer.toString() + } + + private val MIME_XML = "application/xml; charset=utf-8".toMediaType() + private val DEPTH = Property.Name(XmlUtils.NS_WEBDAV, "depth") + private val SYNC_TOKEN = Property.Name(XmlUtils.NS_WEBDAV, "sync-token") + private val MESSAGE = Regex("<(?:[A-Za-z0-9]+:)?message>(.*?)", RegexOption.DOT_MATCHES_ALL) + private const val NOT_FOUND = 404 + private const val GONE = 410 +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/AppPasswordTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/AppPasswordTest.kt new file mode 100644 index 0000000..54bbb2e --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/AppPasswordTest.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds + +class AppPasswordTest { + + private val server = MockWebServer() + private val httpClient = OkHttpClient() + + @Before fun start() = server.start() + + @After fun stop() = server.shutdown() + + @Test fun `a redirected revocation is followed`() { + // Production builds its clients with followRedirects(false), so a server + // that canonicalises host or path leaves the password dangling — + // attempted, and doing nothing. + val noRedirects = OkHttpClient.Builder().followRedirects(false).build() + server.enqueue( + MockResponse().setResponseCode(301) + .setHeader("Location", server.url("/nc/ocs/v2.php/core/apppassword").toString()), + ) + server.enqueue(MockResponse().setResponseCode(200)) + + val revoked = AppPassword.revokeAt(noRedirects, server.url("/")) + + assertThat(revoked).isTrue() + server.takeRequest() + val followed = server.takeRequest() + assertThat(followed.method).isEqualTo("DELETE") + assertThat(followed.getHeader("OCS-APIRequest")).isEqualTo("true") + } + + @Test fun `the OCS root is derived from a principal, not appended to it`() { + // ⚠️ Appending to the principal produces a path that 404s on every + // server, silently — the revocation looks attempted and does nothing. + val principal = "https://cloud.example.com/remote.php/dav/principals/users/alice/" + assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString()) + .isEqualTo("https://cloud.example.com/") + } + + @Test fun `a subpath install keeps its subpath`() { + val principal = "https://host/nextcloud/remote.php/dav/principals/users/alice/" + assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString()) + .isEqualTo("https://host/nextcloud/") + } + + @Test fun `a principal with no remote_php falls back to the origin`() { + val principal = "https://baikal.example.com/dav.php/principals/alice/" + assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString()) + .isEqualTo("https://baikal.example.com/") + } + + @Test fun `a server root is used verbatim, not treated as a principal`() { + server.enqueue(MockResponse().setResponseCode(200)) + + val revoked = AppPassword.revokeAt(httpClient, server.url("/nextcloud/")) + + // ⚠️ ocsRootFor looks for remote.php and falls back to the bare origin. + // A login flow hands back a server base, so routing one through revoke() + // would collapse /nextcloud/ to / and DELETE a path that 404s on every + // subpath install — attempted, and doing nothing. + assertThat(revoked).isTrue() + assertThat(server.takeRequest().path) + .isEqualTo("/nextcloud/ocs/v2.php/core/apppassword") + } + + @Test fun `a server that never answers does not hold the removal`() { + // Accepts the connection and says nothing — the off-VPN homelab shape. + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + + val startedAt = System.nanoTime() + val revoked = AppPassword.revoke( + httpClient, + server.url("/remote.php/dav/principals/users/alice/"), + timeout = 200.milliseconds, + ) + val elapsed = (System.nanoTime() - startedAt).nanoseconds + + // ⚠️ The budget has to live on the call. execute() parks on a socket read + // that neither coroutine cancellation nor Thread.interrupt can break, so + // a timeout imposed from outside returns only once the read has finished + // anyway — after the shared client's own minutes-long budget. + assertThat(revoked).isFalse() + assertThat(elapsed).isLessThan(2.seconds) + } + + @Test fun `the default budget is what the removal is willing to wait`() { + assertThat(AppPassword.REVOCATION_TIMEOUT).isEqualTo(5.seconds) + } + + @Test fun `the revocation is a DELETE with the OCS header`() { + server.enqueue(MockResponse().setResponseCode(200)) + + val revoked = AppPassword.revoke( + httpClient, + server.url("/remote.php/dav/principals/users/alice/"), + ) + + assertThat(revoked).isTrue() + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/ocs/v2.php/core/apppassword") + // ⚠️ Without this header Nextcloud answers with a CSRF complaint that + // reads exactly like a wrong password. + assertThat(request.getHeader("OCS-APIRequest")).isEqualTo("true") + } + + @Test fun `an unreachable server is reported, not thrown`() { + server.enqueue(MockResponse().setResponseCode(404)) + assertThat(AppPassword.revoke(httpClient, server.url("/remote.php/dav/"))).isFalse() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavDiscoveryTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavDiscoveryTest.kt new file mode 100644 index 0000000..65c0e6c --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavDiscoveryTest.kt @@ -0,0 +1,386 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +class CalDavDiscoveryTest { + + private val httpClient = OkHttpClient.Builder().followRedirects(false).build() + private val server = MockWebServer() + private lateinit var discovery: CalDavDiscovery + + @Before fun start() { + server.start() + discovery = CalDavDiscovery(httpClient) + } + + @After fun stop() = server.shutdown() + + private fun options(dav: String = "1, 2, 3, calendar-access") = + MockResponse().setResponseCode(200).setHeader("DAV", dav) + + private fun multistatus(body: String) = MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody(body) + + private fun principalResponse(href: String?) = multistatus( + """ + + + /dav/ + + ${href?.let { "$it" } ?: ""} + HTTP/1.1 200 OK + + + """.trimIndent(), + ) + + private fun homeSetResponse(vararg hrefs: String) = multistatus( + """ + + + /principals/me/ + + ${hrefs.joinToString("\n") { "$it" }} + HTTP/1.1 200 OK + + + """.trimIndent(), + ) + + /** One `` for a collection in a Depth-1 listing. */ + private fun collection( + href: String, + resourceTypes: String, + componentSet: String? = null, + displayName: String = "Tasks", + extraProps: String = "", + ) = """ + + $href + + $resourceTypes + $displayName + ${componentSet ?: ""} + $extraProps + HTTP/1.1 200 OK + + """ + + private fun listing(vararg responses: String) = multistatus( + """ + + /dav/calendars/me/ + + HTTP/1.1 200 OK + ${responses.joinToString("\n")} + + """.trimIndent(), + ) + + private fun discoverCollections(listingBody: MockResponse): List { + server.enqueue(homeSetResponse("/dav/calendars/me/")) + server.enqueue(listingBody) + val outcome = discovery.fromPrincipal(server.url("/principals/me/")) + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Found::class.java) + return (outcome as CalDavDiscovery.Outcome.Found).collections + } + + // ------------------------------------------------------------ principal + + @Test + fun `a home set is resolved against where the principal actually landed`() { + // ⚠️ followRedirects rewrites the resource's location in place, and a + // relative home-set href means "relative to the answer". Resolved + // against the URL we aimed at, a permanently moved principal names a + // path under the old prefix: the home-set PROPFIND 404s and a perfectly + // good account reports that it holds no calendars. + server.enqueue( + MockResponse().setResponseCode(301) + .setHeader("Location", server.url("/nc/principals/me/").toString()), + ) + server.enqueue(homeSetResponse("calendars/me/")) + server.enqueue(listing(collection("/nc/principals/me/calendars/me/tasks/", ""))) + + val outcome = discovery.fromPrincipal(server.url("/principals/me/")) + + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Found::class.java) + // Under the prefix the redirect landed on, not the one we aimed at — + // which would have been /principals/me/calendars/me/, a 404. + assertThat((outcome as CalDavDiscovery.Outcome.Found).homeSets.single().encodedPath) + .isEqualTo("/nc/principals/me/calendars/me/") + } + + + @Test + fun `a 200 carrying unauthenticated is a failed login, not an empty result`() { + // RFC 5397 section 3. Without this check a rejected credential looks like + // a successful discovery that happened to find nothing — which is the + // shape of bug report nobody can act on. + server.enqueue(options()) + server.enqueue(principalResponse(null)) + + assertThat(discovery.probe(server.url("/dav/"))) + .isEqualTo(CalDavDiscovery.Outcome.Unauthenticated) + } + + @Test + fun `a 401 means sign in, not failure`() { + // iCloud and Zoho answer 401 from /.well-known/caldav — the endpoint *is* + // the DAV root and wants auth. RFC-legal, and must not end the walk. + server.enqueue(options()) + server.enqueue(MockResponse().setResponseCode(401)) + + assertThat(discovery.probe(server.url("/dav/"))) + .isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java) + } + + @Test + fun `a WebDAV server without calendar-access is not a CalDAV server`() { + server.enqueue(options(dav = "1, 2, 3")) + server.enqueue(principalResponse("/principals/me/")) + + val outcome = discovery.probe(server.url("/dav/")) + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NotCalDav::class.java) + } + + // ------------------------------------------------------------ home sets + + @Test + fun `every calendar-home-set href is followed, not just the first`() { + // Multiple home sets are normative (RFC 4791 section 6.2.1's own example) + // and iCloud depends on it. + server.enqueue(homeSetResponse("/dav/one/", "/dav/two/")) + server.enqueue(listing(collection("/dav/one/tasks/", ""))) + server.enqueue(listing(collection("/dav/two/more/", ""))) + + val outcome = discovery.fromPrincipal(server.url("/principals/me/")) + val found = outcome as CalDavDiscovery.Outcome.Found + assertThat(found.collections.map { it.url.encodedPath }) + .containsExactly("/dav/one/tasks/", "/dav/two/more/") + } + + @Test + fun `a 401 on one home set does not invalidate the whole account`() { + // The iCloud shape: principal on one host, home set on another. The + // interceptor withholds the credential from the second host by design, so + // a 401 there must not tell the user to sign in again with credentials + // that just worked. + server.enqueue(homeSetResponse("/dav/one/", "/dav/two/")) + server.enqueue(listing(collection("/dav/one/tasks/", ""))) + server.enqueue(MockResponse().setResponseCode(401)) + + val found = discovery.fromPrincipal(server.url("/principals/me/")) as CalDavDiscovery.Outcome.Found + assertThat(found.collections.map { it.url.encodedPath }).containsExactly("/dav/one/tasks/") + assertThat(found.failedHomeSets).hasSize(1) + assertThat(found.failedHomeSets.single().needsAuthentication).isTrue() + } + + @Test + fun `every home set failing is a server error, not an empty account`() { + // Returning Found with no collections says "connected, no task lists" for + // what is actually a 500 — a bug report nobody can act on. + server.enqueue(homeSetResponse("/dav/one/")) + server.enqueue(MockResponse().setResponseCode(500)) + + assertThat(discovery.fromPrincipal(server.url("/principals/me/"))) + .isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java) + } + + @Test + fun `when every home set wants credentials, it names the hosts to allow`() { + server.enqueue(homeSetResponse("/dav/one/")) + server.enqueue(MockResponse().setResponseCode(401)) + + val outcome = discovery.fromPrincipal(server.url("/principals/me/")) + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java) + // Without the host, the caller can never widen the credential allowlist — + // the cross-host home set stays permanently unreachable. + assertThat((outcome as CalDavDiscovery.Outcome.NeedsAuthentication).hosts) + .containsExactly(server.hostName) + } + + @Test + fun `a typed http URL is refused with the scheme as the reason`() { + // Credentials are never sent over cleartext, so this could only ever + // answer "not authorised" — which the user reads as a wrong password. + val outcome = discovery.discover("http://cloud.example.com/dav/") + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java) + val failed = outcome as CalDavDiscovery.Outcome.Failed + // The cause is what the UI renders; the detail is for logs only. + assertThat(failed.cause).isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE) + assertThat(failed.detail).contains("http://") + } + + @Test + fun `a colour comes back as packed ARGB, not as a decimal string`() { + val collections = discoverCollections( + listing( + collection( + "/dav/calendars/me/tasks/", + "", + extraProps = """#FF0000FF""", + ), + ), + ) + assertThat(collections.single().color).isEqualTo(0xFFFF0000.toInt()) + } + + @Test + fun `a share is flagged, because Nextcloud rewrites bodies fetched from one`() { + val collections = discoverCollections( + listing( + collection("/dav/calendars/me/shared/", ""), + ), + ) + assertThat(collections.single().isShared).isTrue() + } + + @Test + fun `a principal with no home set is a failure worth naming`() { + server.enqueue(homeSetResponse()) + val outcome = discovery.fromPrincipal(server.url("/principals/me/")) + assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java) + } + + // --------------------------------------------------- the two filters + + @Test + fun `an absent supported-calendar-component-set means supports everything`() { + // RFC 4791 section 5.2.3 says it SHOULD NOT come back from allprop, so it + // is legitimately missing. Requiring VTODO to be listed silently drops + // every server that does not advertise it. + val collections = discoverCollections( + listing(collection("/dav/calendars/me/tasks/", "")), + ) + assertThat(collections.map { it.url.encodedPath }).containsExactly("/dav/calendars/me/tasks/") + } + + @Test + fun `an empty component set is treated as everything, not as nothing`() { + // The grammar is (comp+) so this is non-conformant, and dav4jvm's parser + // starts all-false — arriving indistinguishable from "no VTODO". + val collections = discoverCollections( + listing( + collection( + "/dav/calendars/me/tasks/", + "", + componentSet = "", + ), + ), + ) + assertThat(collections).hasSize(1) + } + + @Test + fun `a VEVENT-only collection is excluded`() { + val collections = discoverCollections( + listing( + collection( + "/dav/calendars/me/events/", + "", + componentSet = """""", + ), + ), + ) + assertThat(collections).isEmpty() + } + + @Test + fun `SOGo's personal calendar survives, because the test is positive not exclusionary`() { + // SOGo reports collection + calendar + schedule-outbox simultaneously for + // every non-Apple client, i.e. for us. Excluding schedule-outbox — the + // obvious rule — would drop the user's main calendar. + val collections = discoverCollections( + listing( + collection( + "/SOGo/dav/me/Calendar/personal/", + "", + ), + ), + ) + assertThat(collections).hasSize(1) + } + + @Test + fun `a shared calendar is kept`() { + val collections = discoverCollections( + listing( + collection("/dav/calendars/me/shared/", ""), + ), + ) + assertThat(collections).hasSize(1) + } + + @Test + fun `Nextcloud's trashed calendar is dropped, because it strips caldav-calendar`() { + val collections = discoverCollections( + listing( + collection("/dav/calendars/me/deleted/", ""), + ), + ) + assertThat(collections).isEmpty() + } + + @Test + fun `inboxes, outboxes and notification collections are not task lists`() { + val collections = discoverCollections( + listing( + collection("/dav/calendars/me/inbox/", ""), + collection("/dav/calendars/me/outbox/", ""), + collection("/dav/calendars/me/notifications/", ""), + collection("/dav/calendars/me/tasks/", ""), + ), + ) + assertThat(collections.map { it.url.encodedPath }).containsExactly("/dav/calendars/me/tasks/") + } + + // ------------------------------------------------------- privileges + + @Test + fun `an absent privilege set means writable`() { + // RFC 3744 section 3.7 lets a server withhold it, and assuming read-only + // hides collections the user can perfectly well write to. + val collections = discoverCollections( + listing(collection("/dav/calendars/me/tasks/", "")), + ) + assertThat(collections.single().readOnly).isFalse() + } + + @Test + fun `a read-only share is reported as read-only`() { + val collections = discoverCollections( + listing( + collection( + "/dav/calendars/me/shared/", + "", + extraProps = "", + ), + ), + ) + assertThat(collections.single().readOnly).isTrue() + } + + @Test + fun `sync-collection support is reported when advertised`() { + val collections = discoverCollections( + listing( + collection( + "/dav/calendars/me/tasks/", + "", + extraProps = "", + ), + ), + ) + assertThat(collections.single().supportsSyncCollection).isTrue() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavHttpTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavHttpTest.kt new file mode 100644 index 0000000..95d8398 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavHttpTest.kt @@ -0,0 +1,235 @@ +package de.jeanlucmakiola.caldav + +import at.bitfire.dav4jvm.BasicDigestAuthHandler +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Test + +class CalDavHttpTest { + + private val origin = "https://cloud.example.com/remote.php/dav/".toHttpUrl() + + @Test + fun `the auth handler is scoped to the registrable domain, not the host`() { + // ⚠️ The handler derives the registrable domain of each request host and + // compares. Passing the full host means the comparison is + // "cloud.example.com" == "example.com" — never true — and the credential + // is withheld from every single request. That is every self-hosted + // Nextcloud, silently answering 401 forever. + val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + val handler = client.networkInterceptors.filterIsInstance().single() + assertThat(handler.domain).isEqualTo("example.com") + } + + @Test + fun `so the credential actually reaches the host it was made for`() { + val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + val handler = client.networkInterceptors.filterIsInstance().single() + + val authorised = handler.authenticateRequest( + Request.Builder().url("https://cloud.example.com/remote.php/dav/").build(), + null, + ) + assertThat(authorised?.header("Authorization")).isNotNull() + } + + @Test + fun `and reaches a sibling host in the same domain, which is what iCloud needs`() { + // The principal is on caldav.icloud.com and the home set on + // pNN-caldav.icloud.com. An exact-host allowlist refuses the second. + val client = CalDavHttp.authenticated( + "Agendula", + "user", + "pw", + "https://caldav.icloud.com/".toHttpUrl(), + ) + val handler = client.networkInterceptors.filterIsInstance().single() + + val authorised = handler.authenticateRequest( + Request.Builder().url("https://p42-caldav.icloud.com/1234/calendars/").build(), + null, + ) + assertThat(authorised?.header("Authorization")).isNotNull() + } + + @Test + fun `a multi-label public suffix is not mistaken for a domain`() { + // ⚠️ A last-two-labels split scopes this to "co.uk" and then offers the + // password preemptively to any host under it — one an attacker can buy a + // certificate for. This is the headline case. + val client = CalDavHttp.authenticated( + "Agendula", + "user", + "pw", + "https://cloud.example.co.uk/dav/".toHttpUrl(), + ) + val handler = client.networkInterceptors.filterIsInstance().single() + assertThat(handler.domain).isEqualTo("example.co.uk") + + val stranger = handler.authenticateRequest( + Request.Builder().url("https://attacker.co.uk/dav/").build(), + null, + ) + assertThat(stranger).isNull() + + val ours = handler.authenticateRequest( + Request.Builder().url("https://cloud.example.co.uk/dav/").build(), + null, + ) + assertThat(ours?.header("Authorization")).isNotNull() + } + + @Test + fun `a free-subdomain host does not trust its neighbours`() { + // The PSL private section covers the dynamic-DNS providers self-hosters + // actually use, where the neighbour is a stranger with a free account. + val client = CalDavHttp.authenticated( + "Agendula", + "user", + "pw", + "https://myhome.duckdns.org/dav/".toHttpUrl(), + ) + val handler = client.networkInterceptors.filterIsInstance().single() + assertThat(handler.domain).isEqualTo("myhome.duckdns.org") + + val neighbour = handler.authenticateRequest( + Request.Builder().url("https://evil.duckdns.org/dav/").build(), + null, + ) + assertThat(neighbour).isNull() + } + + @Test + fun `a bare address is scoped to itself, not to its last two labels`() { + // ⚠️ topPrivateDomain() is null here, and null means *no restriction* to + // the handler — so the fallback has to be the exact host. A split would + // scope 192.168.1.10 to "1.10". + val client = CalDavHttp.authenticated( + "Agendula", + "user", + "pw", + "https://192.168.1.10/dav/".toHttpUrl(), + ) + val handler = client.networkInterceptors.filterIsInstance().single() + assertThat(handler.domain).isEqualTo("192.168.1.10") + + val ours = handler.authenticateRequest( + Request.Builder().url("https://192.168.1.10/dav/").build(), + null, + ) + assertThat(ours?.header("Authorization")).isNotNull() + + val other = handler.authenticateRequest( + Request.Builder().url("https://10.20.1.10/dav/").build(), + null, + ) + assertThat(other).isNull() + } + + @Test + fun `a single-label host is scoped to itself`() { + val client = CalDavHttp.authenticated( + "Agendula", + "user", + "pw", + "https://localhost:8443/dav/".toHttpUrl(), + ) + val handler = client.networkInterceptors.filterIsInstance().single() + assertThat(handler.domain).isEqualTo("localhost") + + val elsewhere = handler.authenticateRequest( + Request.Builder().url("https://notlocalhost/dav/").build(), + null, + ) + assertThat(elsewhere).isNull() + } + + @Test + fun `an unrelated domain gets nothing`() { + val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + val handler = client.networkInterceptors.filterIsInstance().single() + + assertThat( + handler.authenticateRequest( + Request.Builder().url("https://evil.example.org/dav/").build(), + null, + ), + ).isNull() + } + + @Test + fun `cleartext never carries a preemptive credential`() { + val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + val handler = client.networkInterceptors.filterIsInstance().single() + + assertThat( + handler.authenticateRequest( + Request.Builder().url("http://cloud.example.com/dav/").build(), + null, + )?.header("Authorization"), + ).isNull() + } + + @Test + fun `cleartext carries no credential even when the server asks for one`() { + val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + val handler = client.networkInterceptors.filterIsInstance().single() + val request = Request.Builder().url("http://cloud.example.com/dav/").build() + + // ⚠️ Gating only the preemptive path leaves this open: the server just + // has to ask. Basic is the password, in a header every hop can read. + assertThat(handler.authenticateRequest(request, challenge(request, "Basic"))).isNull() + } + + @Test + fun `a challenge over TLS is still answered`() { + val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + val handler = client.networkInterceptors.filterIsInstance().single() + val request = Request.Builder().url("https://cloud.example.com/dav/").build() + + val authorised = handler.authenticateRequest(request, challenge(request, "Basic")) + assertThat(authorised?.header("Authorization")).startsWith("Basic") + } + + private fun challenge(request: Request, scheme: String) = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(401) + .message("Authentication required") + .header("WWW-Authenticate", "$scheme realm=\"dav\"") + .build() + + @Test + fun `the shared client bounds a whole call, not just each read`() { + // ⚠️ readTimeout is per-read, so a server trickling a byte every 119 + // seconds satisfies it for ever and holds a sequential sync for the + // entire WorkManager window. The ceiling is wide enough for a multiget + // of a full batch over a slow homelab link and far below that window; + // the revocation, which must return in seconds, still sets its own. + assertThat(CalDavHttp.anonymous("Agendula").callTimeoutMillis) + .isEqualTo(3 * 60 * 1000) + } + + @Test + fun `derived clients share one connection pool`() { + // A fresh OkHttpClient per probe gives each its own pool and dispatcher + // threads: every rung of the discovery ladder reopens TLS, and the + // abandoned clients linger until GC. + val a = CalDavHttp.anonymous("Agendula") + val b = CalDavHttp.authenticated("Agendula", "user", "pw", origin) + assertThat(a.connectionPool).isSameInstanceAs(b.connectionPool) + assertThat(a.dispatcher).isSameInstanceAs(b.dispatcher) + } + + @Test + fun `redirects are never followed automatically`() { + // DavResource requires it and asserts on it: redirects are followed by + // hand so a HTTPS-to-HTTP downgrade can be refused and a permanent move + // reported. + assertThat(CalDavHttp.anonymous("Agendula").followRedirects).isFalse() + assertThat(CalDavHttp.authenticated("Agendula", "u", "p", origin).followRedirects).isFalse() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavProviderTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavProviderTest.kt new file mode 100644 index 0000000..66d479d --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalDavProviderTest.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Test + +class CalDavProviderTest { + + @Test + fun `a hosted service is known by its host`() { + assertThat(CalDavProvider.forInput("me@fastmail.com")).isEqualTo(CalDavProvider.FASTMAIL) + assertThat(CalDavProvider.forHost("caldav.icloud.com")).isEqualTo(CalDavProvider.ICLOUD) + assertThat(CalDavProvider.forHost("dav.mailbox.org")).isEqualTo(CalDavProvider.MAILBOX_ORG) + assertThat(CalDavProvider.forHost("cloud.example.de")).isNull() + } + + @Test + fun `self-hosted software is known by its principal path`() { + val nextcloud = "https://cloud.example.de/remote.php/dav/principals/users/jo/".toHttpUrl() + assertThat(CalDavProvider.forPrincipal(nextcloud)).isEqualTo(CalDavProvider.NEXTCLOUD) + + val baikal = "https://dav.example.de/dav.php/principals/jo/".toHttpUrl() + assertThat(CalDavProvider.forPrincipal(baikal)).isEqualTo(CalDavProvider.BAIKAL) + + val unknown = "https://dav.example.de/jo/".toHttpUrl() + assertThat(CalDavProvider.forPrincipal(unknown)).isNull() + } + + @Test + fun `the host wins over the path`() { + // Fastmail's principals sit under a path we know nothing about; the host + // already named the service, so nothing else is consulted. + val url = "https://caldav.fastmail.com/dav/principals/user/me@fastmail.com/".toHttpUrl() + assertThat(CalDavProvider.forPrincipal(url)).isEqualTo(CalDavProvider.FASTMAIL) + } + + @Test + fun `hosted services are titled by brand, self-hosted ones by host`() { + assertThat(CalDavProvider.FASTMAIL.hosted).isTrue() + assertThat(CalDavProvider.NEXTCLOUD.hosted).isFalse() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarChangesTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarChangesTest.kt new file mode 100644 index 0000000..4ad1871 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarChangesTest.kt @@ -0,0 +1,184 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** RFC 6578. Every case here is a documented failure of a real client. */ +class CalendarChangesTest { + + private val server = MockWebServer() + private val httpClient = OkHttpClient.Builder().followRedirects(false).build() + private lateinit var collection: CalendarCollection + + @Before fun start() { + server.start() + collection = CalendarCollection(httpClient, server.url("/dav/tasks/")) + } + + @After fun stop() = server.shutdown() + + @Test fun `changed and removed are told apart`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/kept.ics + "e1" + HTTP/1.1 200 OK + + + /dav/tasks/gone.ics + HTTP/1.1 404 Not Found + + urn:x:2 + """, + ), + ) + + val page = collection.changes("urn:x:1") as ChangeSet.Page + + // ⚠️ The deletion status is at DAV:response level, not inside a propstat. + assertThat(page.changed.map { it.href.encodedPath }) + .containsExactly("/dav/tasks/kept.ics") + assertThat(page.removed.map { it.encodedPath }) + .containsExactly("/dav/tasks/gone.ics") + assertThat(page.token).isEqualTo("urn:x:2") + assertThat(page.truncated).isFalse() + } + + @Test fun `a status that is neither success nor removal is carried as a change`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/refused.ics + HTTP/1.1 403 Forbidden + + urn:x:2 + """, + ), + ) + + val page = collection.changes("urn:x:1") as ChangeSet.Page + + // Dropping it would report the resource as unchanged and leave the row + // stale until the next full reconciliation. With no validator on it the + // multiget is forced, and that is what grades the refusal. + assertThat(page.changed.map { it.href.encodedPath }) + .containsExactly("/dav/tasks/refused.ics") + assertThat(page.changed.single().eTag).isNull() + assertThat(page.removed).isEmpty() + } + + @Test fun `no DAV limit is ever sent`() { + server.enqueue(multistatus("urn:x:2")) + collection.changes(null) + + val body = server.takeRequest().body.readUtf8() + // Nextcloud regressed DAV:limit to a localised HTML error page, so asking + // for a bounded page is how you get an unparseable response. + assertThat(body).doesNotContain("limit") + assertThat(body).contains("sync-level") + } + + @Test fun `a 507 on our own href is truncation, not failure`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + "e1" + HTTP/1.1 200 OK + + + /dav/tasks/ + HTTP/1.1 507 Insufficient Storage + + urn:x:2 + """, + ), + ) + + val page = collection.changes("urn:x:1") as ChangeSet.Page + + assertThat(page.truncated).isTrue() + assertThat(page.changed).hasSize(1) + assertThat(page.token).isEqualTo("urn:x:2") + } + + @Test fun `a 207 with no sync-token is not an error`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + "e1" + HTTP/1.1 200 OK + + """, + ), + ) + + val page = collection.changes("urn:x:1") as ChangeSet.Page + + // The changes are real; only the cursor is missing. + assertThat(page.changed).hasSize(1) + assertThat(page.token).isNull() + } + + // --------------------------------------------------- token invalidation + + @Test fun `invalidation is recognised on every status servers use for it`() { + // ⚠️ 403 sabre, 400 Google, 409 Radicale, 412 Evolution. Thunderbird + // matches 400 alone and never recovers from the 403 most self-hosted + // servers emit. + listOf(400, 403, 409, 412).forEach { code -> + server.enqueue( + MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + "", + ), + ) + assertThat(collection.changes("stale")).isEqualTo(ChangeSet.TokenInvalid) + } + } + + @Test fun `invalidation is recognised whatever prefix the server uses`() { + server.enqueue( + MockResponse() + .setResponseCode(403) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody(""), + ) + assertThat(collection.changes("stale")).isEqualTo(ChangeSet.TokenInvalid) + } + + @Test fun `a 4xx without the marker is not an invalidation`() { + server.enqueue(MockResponse().setResponseCode(403).setBody("nope")) + // Treating every 403 as invalidation would discard a working token on a + // permissions error and re-download the whole collection. + assertThat(collection.changes("good")).isInstanceOf(ChangeSet.Failed::class.java) + } + + @Test fun `an unimplemented report is reported as unsupported`() { + server.enqueue(MockResponse().setResponseCode(501)) + assertThat(collection.changes(null)).isEqualTo(ChangeSet.Unsupported) + } + + @Test fun `a 507 outer status is a failure rather than truncation`() { + server.enqueue(MockResponse().setResponseCode(507)) + assertThat(collection.changes("t")).isInstanceOf(ChangeSet.Failed::class.java) + } + + private fun multistatus(body: String) = MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("$body") +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt new file mode 100644 index 0000000..5f53406 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CalendarCollectionTest.kt @@ -0,0 +1,648 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockWebServer +import okio.buffer +import org.junit.After +import org.junit.Before +import org.junit.Test + +class CalendarCollectionTest { + + private val server = MockWebServer() + private val httpClient = OkHttpClient.Builder().followRedirects(false).build() + private lateinit var collection: CalendarCollection + + @Before fun start() { + server.start() + collection = CalendarCollection(httpClient, server.url("/dav/tasks/")) + } + + @After fun stop() = server.shutdown() + + // ------------------------------------------------------------------ list + + @Test fun `list reads hrefs and strong etags`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + "e1" + HTTP/1.1 200 OK + + + /dav/tasks/two.ics + W/"e2" + HTTP/1.1 200 OK + + """, + ), + ) + + val refs = collection.list().getOrThrow() + + assertThat(refs.map { it.href.encodedPath }) + .containsExactly("/dav/tasks/one.ics", "/dav/tasks/two.ics") + assertThat(refs[0].eTag).isEqualTo(ETag("e1", weak = false)) + assertThat(refs[1].eTag!!.usable).isFalse() + } + + @Test fun `list asks for no calendar-data and no time-range`() { + server.enqueue(multistatus("")) + collection.list().getOrThrow() + + val body = server.takeRequest().body.readUtf8() + // Bodies in a listing would download the whole collection every time, and + // would arrive without an ETag matched to them. + assertThat(body).doesNotContain("calendar-data") + // A VTODO without DTSTART or DUE is outside every range by construction, + // and some servers answer a ranged VTODO filter with nothing at all. + assertThat(body).doesNotContain("time-range") + assertThat(body).contains("VTODO") + } + + // ----------------------------------------------------------------- fetch + + @Test fun `fetch matches responses against the request`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + + /dav/tasks/somebody-elses.ics + + "e9" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow() + + assertThat(result.resources.map { it.href.encodedPath }) + .containsExactly("/dav/tasks/one.ics") + // Real servers answer with URLs nobody asked about. Applying one of those + // to a row keyed by another href corrupts the wrong task. + assertThat(result.unsolicited.map { it.encodedPath }) + .containsExactly("/dav/tasks/somebody-elses.ics") + assertThat(result.missing.map { it.encodedPath }).containsExactly("/dav/tasks/two.ics") + } + + @Test fun `a server that respells the path is still matched`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/my%40dav.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + + val asked = href("my@dav.ics") + val result = collection.fetch(listOf(asked)).getOrThrow() + + // ⚠️ Compared raw, one resource lands in unsolicited *and* missing, its + // body is dropped, and three such runs quarantine it out of the download + // for good — nothing on that side can refund the count. + assertThat(result.resources).hasSize(1) + assertThat(result.missing).isEmpty() + assertThat(result.unsolicited).isEmpty() + assertThat(result.failed).isEmpty() + // The href we asked for, not the server's spelling: apply is keyed on + // the local row's href. + assertThat(result.resources.single().href).isEqualTo(asked) + } + + @Test fun `an href stored before a redirect still matches the reply`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + + // ⚠️ The REPORT body carries only paths, so the reply resolves against + // the collection we asked — while a stored href can still carry the + // origin we held before a redirect moved us. Keying on the origin would + // put every resource of a redirected collection into both buckets. + val stored = "https://old.example.com/dav/tasks/one.ics".toHttpUrl() + val result = collection.fetch(listOf(stored)).getOrThrow() + + assertThat(result.resources.map { it.href }).containsExactly(stored) + assertThat(result.missing).isEmpty() + assertThat(result.unsolicited).isEmpty() + } + + @Test fun `the same path on another host is not our resource`() { + server.enqueue( + multistatus( + """ + + https://evil.example.com/dav/tasks/one.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"))).getOrThrow() + + // Matching on the path alone would apply one host's body to another + // host's row. + assertThat(result.resources).isEmpty() + assertThat(result.unsolicited.map { it.host }).containsExactly("evil.example.com") + assertThat(result.missing).hasSize(1) + } + + @Test fun `an ambiguous spelling matches nothing rather than the wrong row`() { + // A third spelling: matches neither request exactly, decodes like both. + server.enqueue( + multistatus( + """ + + /dav/tasks/%6Dy@dav.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + + val plain = href("my@dav.ics") + val encoded = server.url("/dav/tasks/my%40dav.ics") + val result = collection.fetch(listOf(plain, encoded)).getOrThrow() + + // ⚠️ Two rows decode alike, so no answer can say which it means. Guessing + // applies one row's body to the other; both are reported instead, and the + // syncer will not spend a quarantine count on a stray of the same path. + assertThat(result.resources).isEmpty() + assertThat(result.unsolicited).hasSize(1) + assertThat(result.missing).containsExactly(plain, encoded) + } + + @Test fun `a trailing slash is still a different resource`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics/ + "e1"HTTP/1.1 200 OK + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"))).getOrThrow() + + // Deliberately not normalised, as UrlUtils.equals also refuses to. + assertThat(result.resources).isEmpty() + assertThat(result.missing).hasSize(1) + } + + @Test fun `an encoded slash is not the same as a real one`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/a/b.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + + val asked = server.url("/dav/tasks/a%2Fb.ics") + val result = collection.fetch(listOf(asked)).getOrThrow() + + // One segment "a/b" is not two segments "a" and "b" — which is why the + // key stays a list rather than a joined string. + assertThat(result.resources).isEmpty() + assertThat(result.missing).containsExactly(asked) + } + + @Test fun `a refused resource is reported as failed, not as missing`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + + /dav/tasks/two.ics + HTTP/1.1 403 Forbidden + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow() + + // The server did mention two.ics, so it is not missing. Merging the two + // costs the caller the difference between "refused" and "omitted", which + // are not the same fact and do not deserve the same answer. + assertThat(result.missing).isEmpty() + assertThat(result.failed.map { it.href.encodedPath to it.code }) + .containsExactly("/dav/tasks/two.ics" to 403) + // One bad member does not cost the batch its good ones. + assertThat(result.resources.map { it.href.encodedPath }) + .containsExactly("/dav/tasks/one.ics") + } + + @Test fun `a success carrying no calendar-data is reported as failed`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + + "e1" + HTTP/1.1 200 OK + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"))).getOrThrow() + + // No HTTP judgement to report: the server said yes and sent nothing. + assertThat(result.failed.map { it.href.encodedPath to it.code }) + .containsExactly("/dav/tasks/one.ics" to 0) + assertThat(result.resources).isEmpty() + assertThat(result.missing).isEmpty() + } + + @Test fun `a propstat refusal carries its own status, not a blank`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + + "e1" + HTTP/1.1 200 OK + + + HTTP/1.1 403 Forbidden + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"))).getOrThrow() + + // A per-property refusal is the usual shape for "you may not read this + // one object". Response.properties drops non-2xx propstats, so reporting + // 0 here would make a deterministic refusal look like a transient blank. + assertThat(result.failed.map { it.code }).containsExactly(403) + } + + @Test fun `a resource the server says is gone carries its status`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/one.ics + HTTP/1.1 404 Not Found + + """, + ), + ) + + val result = collection.fetch(listOf(href("one.ics"))).getOrThrow() + + // The code has to survive the trip: the engine treats 404 and 403 as + // opposite verdicts. + assertThat(result.failed.map { it.code }).containsExactly(404) + assertThat(result.missing).isEmpty() + } + + @Test fun `fetch batches`() { + repeat(2) { server.enqueue(multistatus("")) } + collection.fetch((1..3).map { href("$it.ics") }, batchSize = 2).getOrThrow() + assertThat(server.requestCount).isEqualTo(2) + } + + @Test fun `a batch that fails keeps the batches that already worked`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/1.ics + + "e1" + BEGIN:VCALENDAR +END:VCALENDAR + HTTP/1.1 200 OK + + """, + ), + ) + server.enqueue(MockResponse().setResponseCode(500)) + + val result = collection.fetch((1..3).map { href("$it.ics") }, batchSize = 1).getOrThrow() + + // ⚠️ Wrapping the whole loop threw away everything parsed so far and + // answered failure, so the engine recorded a collection failure and + // re-downloaded the lot next run — on a flaky link, for ever. + assertThat(result.resources.map { it.href.encodedPath }) + .containsExactly("/dav/tasks/1.ics") + // The rest were never asked, which is exactly what missing means; the + // engine counts those rather than acting on them as deletions. + assertThat(result.missing.map { it.encodedPath }) + .containsExactly("/dav/tasks/2.ics", "/dav/tasks/3.ics") + } + + @Test fun `a fetch that never got anything still fails`() { + server.enqueue(MockResponse().setResponseCode(500)) + + assertThat(collection.fetch(listOf(href("one.ics"))).isFailure).isTrue() + } + + // ---------------------------------------------------------------- create + + @Test fun `create with a strong etag is stored`() { + server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"new\"")) + + val outcome = collection.create("one.ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n") + + assertThat(outcome).isInstanceOf(PutOutcome.Stored::class.java) + assertThat((outcome as PutOutcome.Stored).eTag).isEqualTo(ETag("new", weak = false)) + assertThat(server.takeRequest().getHeader("If-None-Match")).isEqualTo("*") + } + + @Test fun `create with a weak etag needs a refetch`() { + server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "W/\"new\"")) + // A weak tag is worse than none: storing it makes every later write look + // conditional while silently not being one. + assertThat(collection.create("one.ics", "x")) + .isInstanceOf(PutOutcome.StoredNeedsRefetch::class.java) + } + + @Test fun `create with no etag needs a refetch`() { + server.enqueue(MockResponse().setResponseCode(201)) + assertThat(collection.create("one.ics", "x")) + .isInstanceOf(PutOutcome.StoredNeedsRefetch::class.java) + } + + @Test fun `create answered 412 means the name is taken`() { + server.enqueue(MockResponse().setResponseCode(412)) + val outcome = collection.create("one.ics", "x") + assertThat(outcome).isInstanceOf(PutOutcome.NameTaken::class.java) + assertThat((outcome as PutOutcome.NameTaken).href.encodedPath) + .isEqualTo("/dav/tasks/one.ics") + } + + @Test fun `create answered 415 is rejected and not retried`() { + server.enqueue(MockResponse().setResponseCode(415)) + val outcome = collection.create("one.ics", "x") + assertThat(outcome).isInstanceOf(PutOutcome.Rejected::class.java) + assertThat((outcome as PutOutcome.Rejected).code).isEqualTo(415) + } + + // ---------------------------------------------------------------- update + + @Test fun `update answered 412 with the resource still there is a conflict`() { + server.enqueue(MockResponse().setResponseCode(412)) + server.enqueue(MockResponse().setResponseCode(200)) + + assertThat(collection.update(href("one.ics"), "old", "x")) + .isEqualTo(PutOutcome.ServerNewer) + assertThat(server.takeRequest().getHeader("If-Match")).isEqualTo("\"old\"") + assertThat(server.takeRequest().method).isEqualTo("HEAD") + } + + @Test fun `update answered 412 with the resource gone is not a conflict`() { + server.enqueue(MockResponse().setResponseCode(412)) + server.enqueue(MockResponse().setResponseCode(404)) + + // RFC 9110 §13.1.1 requires 412 once the precondition is evaluated, so a + // deleted resource and a changed one are the same status code. + assertThat(collection.update(href("one.ics"), "old", "x")) + .isEqualTo(PutOutcome.Vanished) + } + + @Test fun `update without an etag writes unconditionally`() { + server.enqueue(MockResponse().setResponseCode(204).setHeader("ETag", "\"new\"")) + collection.update(href("one.ics"), null, "x") + assertThat(server.takeRequest().getHeader("If-Match")).isNull() + } + + // ---------------------------------------------------------------- delete + + @Test fun `delete counts 404 and 410 as success`() { + server.enqueue(MockResponse().setResponseCode(404)) + assertThat(collection.delete(href("one.ics"), "e")).isEqualTo(DeleteOutcome.Deleted) + + server.enqueue(MockResponse().setResponseCode(410)) + assertThat(collection.delete(href("one.ics"), "e")).isEqualTo(DeleteOutcome.Deleted) + } + + @Test fun `delete sends If-Match and reports a conflict`() { + server.enqueue(MockResponse().setResponseCode(412)) + server.enqueue(MockResponse().setResponseCode(200)) + + assertThat(collection.delete(href("one.ics"), "old")) + .isEqualTo(DeleteOutcome.ServerNewer) + assertThat(server.takeRequest().getHeader("If-Match")).isEqualTo("\"old\"") + } + + @Test fun `delete answered 403 is rejected`() { + // Nextcloud's trashbin renames a deleted resource, so delete, recreate and + // delete again on the same href answers 403. + server.enqueue(MockResponse().setResponseCode(403)) + val outcome = collection.delete(href("one.ics"), null) + assertThat(outcome).isInstanceOf(DeleteOutcome.Rejected::class.java) + assertThat((outcome as DeleteOutcome.Rejected).code).isEqualTo(403) + } + + // --------------------------------------------------------------- headers + + @Test fun `writes carry Prefer strict and identity encoding`() { + val client = CalDavHttp.anonymous("Agendula test") + val withHeaders = CalendarCollection(client, server.url("/dav/tasks/")) + server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e\"")) + + withHeaders.create("one.ics", "x") + + val request = server.takeRequest() + // Without this, sabre runs vobject REPAIR over the body and then withholds + // the ETag because the stored bytes are no longer ours. + assertThat(request.getHeader("Prefer")).isEqualTo("handling=strict") + // Without this, a gzip-compressing proxy weakens every ETag it touches. + assertThat(request.getHeader("Accept-Encoding")).isEqualTo("identity") + } + + @Test fun `a caller's own Prefer survives beside ours`() { + val client = CalDavHttp.anonymous("Agendula test") + server.enqueue(MockResponse().setResponseCode(200)) + + client.newCall( + okhttp3.Request.Builder() + .url(server.url("/dav/tasks/one.ics")) + .header("Prefer", "return=minimal") + .put("x".toRequestBody()) + .build(), + ).execute().close() + + // Replacing it would silently drop whatever the caller asked for. + assertThat(server.takeRequest().getHeader("Prefer")) + .isEqualTo("return=minimal, handling=strict") + } + + @Test fun `a body gzipped despite identity is still readable`() { + val client = CalDavHttp.anonymous("Agendula test") + val body = okio.Buffer() + okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") } + server.enqueue( + MockResponse().setResponseCode(200) + .setHeader("Content-Encoding", "gzip") + .setBody(body), + ) + + val response = client.newCall( + okhttp3.Request.Builder().url(server.url("/dav/tasks/one.ics")).build(), + ).execute() + + // ⚠️ Asking for identity takes OkHttp's transparent gunzip out of the + // loop, so a proxy that compresses anyway hands raw deflate to the + // iCalendar parser and a good resource is quarantined as unreadable. + assertThat(response.use { it.body!!.string() }).isEqualTo("BEGIN:VCALENDAR") + } + + // ----------------------------------------------------------------- state + + @Test fun `state re-reads read-only and shared`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/ + + + + + + + + + + ct-1 + HTTP/1.1 200 OK + + """, + ), + ) + + val state = collection.state().getOrThrow() + + assertThat(state.collection.readOnly).isTrue() + assertThat(state.collection.isShared).isTrue() + assertThat(state.ctag).isEqualTo("ct-1") + } + + @Test fun `a member reported alongside self never becomes the collection state`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/member.ics + + + + + + + HTTP/1.1 200 OK + + + /dav/tasks/ + + + + + + mine + HTTP/1.1 200 OK + + """, + ), + ) + + // Some servers answer a Depth-0 PROPFIND with a member as well. Taking the + // first classifiable response would read that member's ACL as the + // collection's own — here, read-only when it is not. + val state = collection.state().getOrThrow() + assertThat(state.ctag).isEqualTo("mine") + assertThat(state.collection.readOnly).isFalse() + } + + @Test fun `state fails rather than guessing when the collection is no longer one`() { + server.enqueue( + multistatus( + """ + + /dav/tasks/ + + HTTP/1.1 200 OK + + """, + ), + ) + assertThat(collection.state().isFailure).isTrue() + } + + // ----------------------------------------------------------------- setup + + private fun href(name: String): HttpUrl = server.url("/dav/tasks/$name") + + private fun multistatus(responses: String) = MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("$responses") +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt new file mode 100644 index 0000000..7bba58d --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/CollectionAdminTest.kt @@ -0,0 +1,181 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The collection-management side, against a server rather than a mock of one. + * + * The three things worth pinning are the three that fail silently: which method + * gets used, whether the request says `VTODO`, and whether a colour goes out in + * a spelling anything can read back. + */ +class CollectionAdminTest { + + private lateinit var server: MockWebServer + private lateinit var admin: DavCollectionAdmin + + @Before fun setUp() { + server = MockWebServer().apply { start() } + admin = DavCollectionAdmin(CalDavHttp.anonymous("test")) + } + + @After fun tearDown() = server.shutdown() + + private fun homeSet() = server.url("/dav/calendars/me/") + + @Test + fun `MKCALENDAR is read out of Allow and extended MKCOL out of DAV`() { + // The normal shape for an extended-MKCOL server: plain MKCOL in Allow, + // and the class advertised only in DAV. Reading Allow alone reports + // iCloud as unable to create anything at all. + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Allow", "OPTIONS, GET, PROPFIND, MKCOL") + .setHeader("DAV", "1, 2, 3, calendar-access, extended-mkcol"), + ) + + val support = admin.support(homeSet()) + + assertThat(support.mkCalendar).isFalse() + assertThat(support.extendedMkCol).isTrue() + assertThat(support.canCreate).isTrue() + } + + @Test + fun `a server offering neither offers nothing`() { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Allow", "OPTIONS, GET, PROPFIND") + .setHeader("DAV", "1, 2, calendar-access"), + ) + + // Posteo disables collection creation and Google has neither. The + // affordance has to be gone rather than answering 405 at the end of a + // form the user has already filled in. + assertThat(admin.support(homeSet()).canCreate).isFalse() + } + + @Test + fun `an OPTIONS that never answers reads as no`() { + server.shutdown() + + assertThat(admin.support(homeSet())).isEqualTo(CollectionSupport.NONE) + } + + @Test + fun `creating asks for VTODO, by MKCALENDAR where it exists`() { + server.enqueue(MockResponse().setResponseCode(201)) + + val outcome = admin.create( + homeSet = homeSet(), + name = "shopping", + displayName = "Shopping", + color = 0xFF4C6FFF.toInt(), + support = CollectionSupport(mkCalendar = true, extendedMkCol = true), + ) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("MKCALENDAR") + // ⚠️ The trailing slash is load-bearing: without it every later + // `resolve` against this URL lands in the parent collection. + assertThat(request.path).isEqualTo("/dav/calendars/me/shopping/") + val body = request.body.readUtf8() + // A calendar created without the component set gets the server's + // default, which on several is events-only — a task list that refuses + // tasks. + assertThat(body).contains("VTODO") + assertThat(body).contains("Shopping") + // #RRGGBBAA, not the packed Int, which prints as "-11702273". + assertThat(body).contains("#4C6FFFFF") + assertThat(outcome).isInstanceOf(CollectionOutcome.Created::class.java) + } + + @Test + fun `a server with only extended MKCOL gets a resourcetype instead`() { + server.enqueue(MockResponse().setResponseCode(201)) + + admin.create( + homeSet = homeSet(), + name = "shopping", + displayName = "Shopping", + color = null, + support = CollectionSupport(mkCalendar = false, extendedMkCol = true), + ) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("MKCOL") + // The whole difference between the two: here the request says what the + // collection is, rather than the method. + assertThat(request.body.readUtf8()).contains("resourcetype") + } + + @Test + fun `a server with neither is refused before a request is made`() { + val outcome = admin.create( + homeSet = homeSet(), + name = "shopping", + displayName = "Shopping", + color = null, + support = CollectionSupport.NONE, + ) + + assertThat(outcome).isEqualTo(CollectionOutcome.Unsupported) + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `a refusal carries its status rather than becoming a failure`() { + // Nextcloud's trashbin renames a deleted collection instead of removing + // it, so re-creating one under a used name answers 403 for ever. The + // caller retries under a fresh segment, which it can only do if it can + // tell "refused" from "unreachable". + server.enqueue(MockResponse().setResponseCode(403)) + + val outcome = admin.create( + homeSet = homeSet(), + name = "shopping", + displayName = "Shopping", + color = null, + support = CollectionSupport(mkCalendar = true, extendedMkCol = false), + ) + + assertThat((outcome as CollectionOutcome.Refused).code).isEqualTo(403) + } + + @Test + fun `a rename and a recolour go out as one PROPPATCH`() { + server.enqueue(MockResponse().setResponseCode(207).setBody("")) + + val outcome = admin.updateProperties( + url = server.url("/dav/calendars/me/shopping/"), + displayName = "Groceries", + color = 0xFF00FF00.toInt(), + ) + + val request = server.takeRequest() + val body = request.body.readUtf8() + assertThat(request.method).isEqualTo("PROPPATCH") + // One request, not two: a rename and a recolour are one edit as far as + // the user is concerned, and half of one landing is the worse outcome. + assertThat(body).contains("Groceries") + assertThat(body).contains("#00FF00FF") + assertThat(outcome).isEqualTo(CollectionOutcome.Updated) + } + + @Test + fun `a collection that is already gone counts as deleted`() { + server.enqueue(MockResponse().setResponseCode(404)) + + // The point of a DELETE is for the thing to be absent, and it is. + // Treating "already gone" as a failure leaves a row nothing can remove. + assertThat(admin.delete(server.url("/dav/calendars/me/shopping/"))) + .isEqualTo(CollectionOutcome.Updated) + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ETagTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ETagTest.kt new file mode 100644 index 0000000..ff6e6ee --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ETagTest.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ETagTest { + + @Test fun `strong tag is unquoted and usable`() { + val tag = ETag.parse("\"abc123\"") + assertThat(tag).isEqualTo(ETag("abc123", weak = false)) + assertThat(tag!!.usable).isTrue() + } + + @Test fun `weak tag keeps its value but is never usable`() { + val tag = ETag.parse("W/\"abc123\"") + assertThat(tag).isEqualTo(ETag("abc123", weak = true)) + // RFC 9110 §13.1.1 — a weak tag cannot be used with If-Match, so anything + // that treats this as a validator writes unconditionally without saying so. + assertThat(tag!!.usable).isFalse() + } + + @Test fun `unquoted tag is accepted`() { + assertThat(ETag.parse("abc123")).isEqualTo(ETag("abc123", weak = false)) + } + + @Test fun `absent and empty are both null`() { + assertThat(ETag.parse(null)).isNull() + assertThat(ETag.parse("")).isNull() + assertThat(ETag.parse(" ")).isNull() + assertThat(ETag.parse("\"\"")).isNull() + } + + @Test fun `a parsed property is not re-parsed`() { + // GetETag has already stripped the marker and recorded it separately, so + // running its value back through parse() reports every weak tag as strong. + val property = at.bitfire.dav4jvm.property.GetETag("W/\"abc\"") + assertThat(ETag.from(property)).isEqualTo(ETag("abc", weak = true)) + assertThat(ETag.parse(property.eTag)).isEqualTo(ETag("abc", weak = false)) + } + + @Test fun `a value of W is not a weak marker`() { + // "W/" needs something after it; the guard is length, not prefix alone. + assertThat(ETag.parse("\"W/\"")).isEqualTo(ETag("W/", weak = false)) + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/NextcloudLoginFlowTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/NextcloudLoginFlowTest.kt new file mode 100644 index 0000000..4ef8058 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/NextcloudLoginFlowTest.kt @@ -0,0 +1,399 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test + +class NextcloudLoginFlowTest { + + private val server = MockWebServer() + private lateinit var flow: NextcloudLoginFlow + + @Before fun start() { + server.start() + flow = NextcloudLoginFlow(OkHttpClient(), userAgent = "Agendula/1.0 (Pixel 8)") + } + + @After fun stop() = server.shutdown() + + private fun json(body: String) = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json; charset=utf-8") + .setBody(body) + + private fun startFlow(): NextcloudLoginFlow.Flow { + server.enqueue( + json( + """ + {"poll":{"token":"tok-123","endpoint":"${server.url("/index.php/login/v2/poll")}"}, + "login":"${server.url("/index.php/login/v2/flow/abc")}"} + """.trimIndent(), + ), + ) + return flow.start(server.url("/"), now = 0).getOrThrow() + } + + /** As production builds it: DavResource requires no automatic redirects. */ + private fun noRedirectFlow() = NextcloudLoginFlow( + OkHttpClient.Builder().followRedirects(false).build(), + userAgent = "Agendula/1.0 (Pixel 8)", + ) + + @Test + fun `a redirected init is followed, and stays a POST`() { + // ⚠️ Apex to www, a trailing slash, a proxy — Nextcloud canonicalises + // with a 301 and the client cannot follow on its own. The method has to + // survive: this route answers 405 to a GET. + val redirecting = noRedirectFlow() + server.enqueue( + MockResponse().setResponseCode(301) + .setHeader("Location", server.url("/nc/index.php/login/v2").toString()), + ) + server.enqueue( + json( + """ + {"poll":{"token":"tok-123","endpoint":"${server.url("/nc/index.php/login/v2/poll")}"}, + "login":"${server.url("/nc/index.php/login/v2/flow/abc")}"} + """.trimIndent(), + ), + ) + + val started = redirecting.start(server.url("/"), now = 0).getOrThrow() + + assertThat(started.pollToken).isEqualTo("tok-123") + server.takeRequest() + val followed = server.takeRequest() + assertThat(followed.path).isEqualTo("/nc/index.php/login/v2") + assertThat(followed.method).isEqualTo("POST") + } + + @Test + fun `a redirected poll is followed rather than read as a server error`() { + // ⚠️ After approval. Reporting SERVER_ERROR here abandons a password the + // server has already minted and can never hand back again. + val redirecting = noRedirectFlow() + val started = NextcloudLoginFlow.Flow( + loginUrl = server.url("/index.php/login/v2/flow/abc"), + pollEndpoint = server.url("/index.php/login/v2/poll"), + pollToken = "tok-123", + deadlineEpochSeconds = 1_200, + ) + server.enqueue( + MockResponse().setResponseCode(308) + .setHeader("Location", server.url("/nc/login/v2/poll").toString()), + ) + server.enqueue( + json("""{"server":"${server.url("/")}","loginName":"me","appPassword":"secret-app-pw"}"""), + ) + + val result = redirecting.poll(started, now = 1) + + assertThat(result).isInstanceOf(NextcloudLoginFlow.PollResult.Approved::class.java) + assertThat((result as NextcloudLoginFlow.PollResult.Approved).credentials.appPassword) + .isEqualTo("secret-app-pw") + server.takeRequest() + val followed = server.takeRequest() + assertThat(followed.method).isEqualTo("POST") + assertThat(followed.body.readUtf8()).contains("token=tok-123") + } + + @Test + fun `init posts, and carries a User-Agent the user can recognise`() { + startFlow() + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + // The User-Agent becomes the app password's *name* in Settings → Security + // → Devices & sessions. With OkHttp's default the user sees + // "okhttp/4.12.0" and cannot tell what to revoke. + assertThat(request.getHeader("User-Agent")).isEqualTo("Agendula/1.0 (Pixel 8)") + } + + @Test + fun `polling is a POST - a GET gets 405`() { + val started = startFlow() + server.enqueue(MockResponse().setResponseCode(404)) + flow.poll(started, now = 1) + + server.takeRequest() // the init request + val poll = server.takeRequest() + assertThat(poll.method).isEqualTo("POST") + assertThat(poll.body.readUtf8()).contains("token=tok-123") + } + + @Test + fun `404 means pending`() { + val started = startFlow() + server.enqueue(MockResponse().setResponseCode(404)) + assertThat(flow.poll(started, now = 1)).isEqualTo(NextcloudLoginFlow.PollResult.Pending) + } + + @Test + fun `approval yields the app password, and loginName is kept as a username only`() { + val started = startFlow() + server.enqueue( + json("""{"server":"${server.url("/")}","loginName":"Me@Example.COM","appPassword":"secret-app-pw"}"""), + ) + val result = flow.poll(started, now = 1) + assertThat(result).isInstanceOf(NextcloudLoginFlow.PollResult.Approved::class.java) + val credentials = (result as NextcloudLoginFlow.PollResult.Approved).credentials + assertThat(credentials.loginName).isEqualTo("Me@Example.COM") + assertThat(credentials.appPassword).isEqualTo("secret-app-pw") + } + + @Test + fun `an unreachable claimed origin is replaced, and reported`() { + val started = startFlow() + server.enqueue( + json("""{"server":"http://nextcloud:11000/","loginName":"me","appPassword":"pw"}"""), + ) + + val result = flow.poll(started, now = 1) as NextcloudLoginFlow.PollResult.Approved + + // We talk to the host that just answered us, not the one it named. + assertThat(result.credentials.server.host).isEqualTo(server.hostName) + // And the user is told which setting is wrong, rather than being sent + // away with "no task lists" for what is a DNS failure. + assertThat(result.hostMismatch?.actual).isEqualTo("nextcloud") + } + + @Test + fun `429 is rate limiting, not pending`() { + // "Anything that isn't 200 is pending" turns Nextcloud's brute-force + // protection into a twenty-minute spinner. + val started = startFlow() + server.enqueue(MockResponse().setResponseCode(429)) + assertThat(flow.poll(started, now = 1)) + .isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java) + } + + @Test + fun `503 is maintenance, not pending`() { + val started = startFlow() + server.enqueue(MockResponse().setResponseCode(503)) + assertThat(flow.poll(started, now = 1)) + .isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java) + } + + @Test + fun `a 200 that is not JSON is a captive portal, not an approval`() { + // A Cloudflare challenge is a 200 carrying HTML. + val started = startFlow() + server.enqueue( + MockResponse().setResponseCode(200) + .setHeader("Content-Type", "text/html") + .setBody("Checking your browser…"), + ) + assertThat(flow.poll(started, now = 1)) + .isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java) + } + + @Test + fun `the deadline is tracked locally, because 404 also means expired`() { + val started = startFlow() + assertThat(started.deadlineEpochSeconds).isEqualTo(1200L) + // No response enqueued: an expired flow must not even reach the network. + assertThat(flow.poll(started, now = 1201)) + .isInstanceOf(NextcloudLoginFlow.PollResult.Expired::class.java) + } + + @Test + fun `a URL that downgrades to http is refused`() { + // The poll token is exchanged for a long-lived app password, and the login + // URL takes the account password — both are credential-grade. + val https = "https://cloud.example.com/".toHttpUrl() + val http = "http://cloud.example.com/poll".toHttpUrl() + val failure = runCatching { flow.requireSecureOrigin(https, http) }.exceptionOrNull() + assertThat(failure).isNotNull() + assertThat(failure!!.message).contains("http://") + } + + @Test + fun `the browser login URL gets the same scheme check as the poll endpoint`() { + // It is where the user types their *account* password, so leaving it + // unchecked is the more dangerous of the two omissions. `start` applies + // requireSecureOrigin to both; this asserts the rule itself, because + // reaching the branch over the wire would need a TLS MockWebServer and a + // test that merely fails to connect would pass for the wrong reason. + val typed = "https://cloud.example.com/".toHttpUrl() + val harvester = "http://evil.example.com/harvest".toHttpUrl() + val failure = runCatching { flow.requireSecureOrigin(typed, harvester) }.exceptionOrNull() + assertThat(failure).isNotNull() + assertThat(failure!!.message).contains("evil.example.com") + } + + @Test + fun `a host change is carried, not refused - reverse proxies are legitimate`() { + // overwrite.cli.url pointing somewhere other than what the user typed is + // ordinary on self-hosted installs. Throwing would make the flow unusable + // for them; the UI confirms it instead. + server.enqueue( + json( + """ + {"poll":{"token":"t","endpoint":"https://cloud.example.com/poll"}, + "login":"https://cloud.example.com/flow"} + """.trimIndent(), + ), + ) + val started = flow.start(server.url("/"), now = 0) + assertThat(started.isSuccess).isTrue() + val mismatch = started.getOrThrow().hostMismatch + assertThat(mismatch).isNotNull() + assertThat(mismatch!!.actual).isEqualTo("cloud.example.com") + assertThat(mismatch.message).contains("overwrite.cli.url") + } + + @Test fun `a downgraded server URL in the poll response is coerced, not refused`() { + // ⚠️ The credential has already been issued and the 200 comes exactly + // once — the row is deleted inside poll() before it answers. Throwing + // here destroys a live app password and leaves it dangling in the user's + // device list, for no protection at all. + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl() + val downgraded = "http://cloud.example.com".toHttpUrl() + + val coerced = flow.secureOrigin(expected, downgraded) + + assertThat(coerced.scheme).isEqualTo("https") + assertThat(coerced.host).isEqualTo("cloud.example.com") + } + + @Test fun `a cleartext port does not survive the coercion`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl() + val downgraded = "http://cloud.example.com:8080/".toHttpUrl() + + // ⚠️ OkHttp only drops a *default* port across a scheme change, so + // :8080 would be carried into https — a port that almost certainly + // speaks cleartext, turning the promised coercion into a handshake + // failure. The port that answered the poll is the one known to work. + val coerced = flow.secureOrigin(expected, downgraded) + + assertThat(coerced.scheme).isEqualTo("https") + assertThat(coerced.port).isEqualTo(443) + } + + @Test fun `an already-secure server URL is left alone`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl() + val actual = "https://dav.example.com".toHttpUrl() + + // secureOrigin keeps its narrow job: the scheme. Which origin we then + // actually talk to is reachableOrigin's decision. + assertThat(flow.secureOrigin(expected, actual)).isEqualTo(actual) + } + + @Test fun `an origin outside the polled domain is replaced by the one that answered`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl() + + // ⚠️ The ordinary reverse-proxied install: the poll endpoint comes from + // overwrite.cli.url and is right, while `server` is built from the + // approving request's own Host header and is an internal name the phone + // cannot resolve. Following it strands the user with a spent password. + val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl()) + + assertThat(reachable.host).isEqualTo("cloud.example.com") + assertThat(reachable.isHttps).isTrue() + assertThat(reachable.encodedPath).isEqualTo("/") + } + + @Test fun `a sibling host is used verbatim and not reported as a mismatch`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val polled = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl() + val claimed = "https://nc.example.com/".toHttpUrl() + + // ⚠️ hostMismatchOf compares hosts exactly; reachableOrigin substitutes + // only across registrable domains. Reporting the raw comparison tells the + // user we replaced an address we in fact used, and blames a correct + // setting — stickily, for the rest of the flow. + val used = flow.reachableOrigin(polled, claimed) + assertThat(used).isEqualTo(claimed) + assertThat(flow.substitutedMismatch(polled, claimed, used)).isNull() + } + + @Test fun `a substituted host is reported`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val polled = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl() + val claimed = "http://nextcloud:11000/".toHttpUrl() + + val used = flow.reachableOrigin(polled, claimed) + assertThat(flow.substitutedMismatch(polled, claimed, used)?.actual).isEqualTo("nextcloud") + } + + @Test fun `a subdirectory install without index_php keeps its prefix`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + // htaccess.IgnoreFrontController drops index.php from generated routes. + val expected = "https://cloud.example.com/nc/login/v2/poll".toHttpUrl() + + val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl()) + + assertThat(reachable.encodedPath).isEqualTo("/nc/") + } + + @Test fun `a sibling host in the same domain is still honoured`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl() + val actual = "https://dav.example.com/".toHttpUrl() + + // A real deployment shape, and the credential is scoped to that + // registrable domain anyway. + assertThat(flow.reachableOrigin(expected, actual)).isEqualTo(actual) + } + + @Test fun `a subdirectory install keeps its prefix when the host is replaced`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = + "https://cloud.example.com/nextcloud/index.php/login/v2/poll".toHttpUrl() + + // ⚠️ The container's own webroot is empty, so the claimed URL carries no + // prefix at all. Keeping the claimed path alongside the polled host would + // drop /nextcloud and send discovery to the wrong base — with the app + // password already spent. + val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl()) + + assertThat(reachable.host).isEqualTo("cloud.example.com") + assertThat(reachable.encodedPath).isEqualTo("/nextcloud/") + } + + @Test fun `a webroot install is rebuilt at the root`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl() + + val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/whatever".toHttpUrl()) + + // The claimed path goes with the claimed host: both came from the + // generator we just decided not to trust. + assertThat(reachable.encodedPath).isEqualTo("/") + } + + @Test fun `a single-label host is compared exactly, not by a null domain`() { + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val expected = "https://localhost:8443/index.php/login/v2/poll".toHttpUrl() + + // topPrivateDomain() is null for both sides here; falling back to the + // exact host is what keeps a homelab install working. + assertThat(flow.reachableOrigin(expected, "https://localhost:8443/".toHttpUrl()).host) + .isEqualTo("localhost") + assertThat(flow.reachableOrigin(expected, "https://elsewhere/".toHttpUrl()).host) + .isEqualTo("localhost") + } + + @Test fun `the login URL is still refused outright when downgraded`() { + // Before approval there is nothing to lose by refusing, and the login URL + // is where the *account* password gets typed. + val flow = NextcloudLoginFlow(OkHttpClient(), "test") + val failure = runCatching { + flow.requireSecureOrigin( + "https://cloud.example.com".toHttpUrl(), + "http://cloud.example.com/login".toHttpUrl(), + ) + }.exceptionOrNull() + + assertThat(failure).isNotNull() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ResourceNamesTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ResourceNamesTest.kt new file mode 100644 index 0000000..e53c965 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ResourceNamesTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ResourceNamesTest { + + @Test fun `an ordinary uid becomes itself`() { + assertThat(ResourceNames.forUid("a1b2c3")).isEqualTo("a1b2c3.ics") + } + + @Test fun `path separators never survive`() { + // The whole point: a UID is opaque text, an href is a path segment. + assertThat(ResourceNames.forUid("../../etc/passwd")).doesNotContain("/") + assertThat(ResourceNames.forUid("a/b")).isEqualTo("a-b.ics") + } + + @Test fun `at signs are excluded even though they are legal in a path`() { + assertThat(ResourceNames.forUid("task@example.com")).isEqualTo("task-example.com.ics") + } + + @Test fun `the basename is capped`() { + val name = ResourceNames.forUid("x".repeat(500)) + assertThat(name.removeSuffix(".ics")).hasLength(ResourceNames.MAX_BASENAME_BYTES) + } + + @Test fun `a uid with nothing usable in it falls back to a uuid`() { + val name = ResourceNames.forUid("///") + assertThat(name).endsWith(".ics") + assertThat(name.removeSuffix(".ics")).matches("[0-9a-f-]{36}") + } + + @Test fun `dots alone do not become a relative path`() { + assertThat(ResourceNames.forUid("..")).doesNotContain("..") + assertThat(ResourceNames.forUid(".")).endsWith(".ics") + } + + @Test fun `random names do not repeat`() { + assertThat(ResourceNames.random()).isNotEqualTo(ResourceNames.random()) + } + + @Test + fun `a collection name keeps no extension`() { + // A collection is a directory, not a file. `.ics` on the end of one is + // wrong in a way that only shows up when someone looks at the server. + assertThat(ResourceNames.forCollection("Shopping")).isEqualTo("Shopping") + } + + @Test + fun `a name that does not survive sanitising gets a random segment`() { + // "Einkäufe 🛒" is an ordinary thing to call a list, and none of it is + // in the safe class. A random segment is the honest answer; the display + // name the user sees is unaffected. + val name = ResourceNames.forCollection("🛒") + + assertThat(name).isNotEmpty() + assertThat(name).doesNotContain("🛒") + assertThat(name).doesNotContain(ResourceNames.EXTENSION) + } + + @Test + fun `two random collection segments differ`() { + // The retry after Nextcloud's trashbin 403 depends on this: re-creating + // under a segment used before answers 403 for ever. + assertThat(ResourceNames.randomCollection()) + .isNotEqualTo(ResourceNames.randomCollection()) + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerMatrixTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerMatrixTest.kt new file mode 100644 index 0000000..e7b53aa --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerMatrixTest.kt @@ -0,0 +1,180 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Test + +/** + * The per-server trap matrix. + * + * Everything reproducible from the protocol alone runs here against + * MockWebServer. The handful that genuinely need a live server are `@Ignore`d + * with the reason spelled out — they are named so they can be turned on by hand, + * not deleted and rediscovered. + */ +class ServerMatrixTest { + + private val server = MockWebServer() + private val httpClient = OkHttpClient.Builder().followRedirects(false).build() + private lateinit var collection: CalendarCollection + + @Before fun start() { + server.start() + collection = CalendarCollection(httpClient, server.url("/dav/tasks/")) + } + + @After fun stop() = server.shutdown() + + // ------------------------------------------------------------- Nextcloud + + @Test fun `Nextcloud per-calendar UID uniqueness is a rejection, not a retry`() { + // 409 no-uid-conflict: another resource in this collection already has + // that UID. Retrying the same body reproduces it exactly. + server.enqueue( + MockResponse().setResponseCode(409) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + "" + + "", + ), + ) + + val outcome = collection.create("one.ics", "x") + + assertThat(outcome).isInstanceOf(PutOutcome.Rejected::class.java) + assertThat((outcome as PutOutcome.Rejected).code).isEqualTo(409) + } + + @Test fun `Nextcloud's trashbin makes a reused href answer 403`() { + // The trashbin renames a deleted resource to `-deleted.ics`, so + // delete, recreate and delete again at the same href returns 403. Task + // apps hit this constantly because they reuse hrefs. + server.enqueue(MockResponse().setResponseCode(403)) + + val outcome = collection.delete(server.url("/dav/tasks/one.ics"), "e") + + // Not "already gone" and not a conflict — a refusal that must be + // quarantined rather than retried forever. + assertThat(outcome).isInstanceOf(DeleteOutcome.Rejected::class.java) + assertThat((outcome as DeleteOutcome.Rejected).code).isEqualTo(403) + } + + @Test fun `a shared Nextcloud calendar is recognised as shared`() { + // ⚠️ The whole mutilation guard hangs off this bit. `CalendarObject::get()` + // serves a whitelist-reduced body from a share while leaving the ETag + // untouched, so an engine that thinks the collection is not shared will + // write that reduced body back over the owner's task. + server.enqueue( + MockResponse().setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + """ + + + /dav/tasks/ + + + + + HTTP/1.1 200 OK + + + """.trimIndent(), + ), + ) + + assertThat(collection.state().getOrThrow().collection.isShared).isTrue() + } + + // ------------------------------------------------------------------ SOGo + + @Test fun `SOGo's second-granularity token is carried verbatim`() { + // SOGo's tokens are second-granularity integers rather than URIs. Nothing + // may parse, normalise or compare them as anything but opaque text. + server.enqueue( + MockResponse().setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + "1730000000", + ), + ) + + val page = collection.changes("1729999999") as ChangeSet.Page + + assertThat(page.token).isEqualTo("1730000000") + assertThat(server.takeRequest().body.readUtf8()).contains("1729999999") + } + + @Test fun `SOGo's main calendar survives classification`() { + // ⚠️ SOGo reports collection + calendar + schedule-outbox together for + // every non-Apple client. The obvious "exclude schedule-outbox" rule + // drops the user's only calendar. + server.enqueue( + MockResponse().setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + """ + + + /dav/tasks/ + + + + + HTTP/1.1 200 OK + + + """.trimIndent(), + ), + ) + + assertThat(collection.state().isSuccess).isTrue() + } + + // -------------------------------------------------------------- Radicale + + @Test fun `Radicale advertising sync-collection without meaning it degrades`() { + // Radicale advertised the report for years without implementing it, so a + // 501 must fall back rather than fail the collection. + server.enqueue(MockResponse().setResponseCode(501)) + assertThat(collection.changes("t")).isEqualTo(ChangeSet.Unsupported) + } + + // ---------------------------------------------------- needs a real server + + @Ignore( + "Needs a live Baikal on dav_auth_type = Digest. OkHttp has no Digest of " + + "its own (square/okhttp#205), so this exercises the vendored " + + "BasicDigestAuthHandler against a real challenge/nonce cycle, which " + + "MockWebServer cannot reproduce faithfully.", + ) + @Test fun `Baikal on Digest authenticates`() = Unit + + @Ignore( + "Needs a live Nextcloud with a calendar shared read-write from another " + + "account, holding a CLASS:CONFIDENTIAL task. Verifies that we never " + + "write back the whitelist-reduced body CalendarObject::get() serves " + + "— the single most destructive bug available here, and one no mock " + + "can prove absent because the reduction happens server-side.", + ) + @Test fun `a confidential task on a shared Nextcloud calendar survives a round trip`() = Unit + + @Ignore( + "Needs a live SOGo. Its ETag is a row-version counter and the body is " + + "regenerated per principal, so the same ETag can accompany different " + + "bytes. Proving we do not silently keep a stale body needs the real " + + "server's regeneration behaviour.", + ) + @Test fun `an ETag-unchanged body change on SOGo is detected`() = Unit + + @Ignore( + "Needs a live Nextcloud. MKCALENDAR is rate-limited to 10 per hour, " + + "which is the behaviour under test and cannot be mocked usefully.", + ) + @Test fun `Nextcloud rate-limits collection creation`() = Unit +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerQuirksTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerQuirksTest.kt new file mode 100644 index 0000000..35026bc --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServerQuirksTest.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ServerQuirksTest { + + @Test + fun `the three providers whose real error is not wrong password`() { + assertThat(ServerQuirk.forInput("me@fastmail.com")) + .isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD) + assertThat(ServerQuirk.forInput("me@icloud.com")) + .isEqualTo(ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD) + assertThat(ServerQuirk.forInput("me@gmail.com")) + .isEqualTo(ServerQuirk.GOOGLE_UNSUPPORTED) + } + + @Test + fun `Google is fatal - it supports neither VTODO nor MKCALENDAR`() { + assertThat(ServerQuirk.GOOGLE_UNSUPPORTED.isFatal).isTrue() + assertThat(ServerQuirk.FASTMAIL_APP_PASSWORD.isFatal).isFalse() + } + + @Test + fun `subdomains count, and a lookalike domain does not`() { + assertThat(ServerQuirk.forHost("mail.icloud.com")).isEqualTo(ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD) + assertThat(ServerQuirk.forHost("noticloud.com")).isNull() + assertThat(ServerQuirk.forHost("icloud.com.example.org")).isNull() + } + + @Test + fun `a service that cannot be synced is offered last, not omitted`() { + val offered = CalDavProvider.selectable + // Listed, because people go looking for it and an absent row reads as + // the app being unfinished rather than as Google's own limitation. + assertThat(offered).contains(CalDavProvider.GOOGLE) + assertThat(offered.last()).isEqualTo(CalDavProvider.GOOGLE) + // Everything else keeps declaration order — the sort only moves the + // dead rows, so Nextcloud stays the first thing offered. + assertThat(offered.first()).isEqualTo(CalDavProvider.NEXTCLOUD) + assertThat(offered.none { ServerQuirk.forProvider(it)?.isFatal == true && it != CalDavProvider.GOOGLE }) + .isTrue() + } + + @Test + fun `a self-hosted server has no quirk`() { + assertThat(ServerQuirk.forInput("https://cloud.example.de/remote.php/dav/")).isNull() + assertThat(ServerQuirk.forInput("me@example.de")).isNull() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt new file mode 100644 index 0000000..3c8c07e --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/ServiceDiscoveryTest.kt @@ -0,0 +1,223 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The discovery trap table, made executable. Every case here was + * live-probed against a real provider; each one breaks the obvious + * implementation. + */ +class ServiceDiscoveryTest { + + private class FakeDns( + val srv: Map> = emptyMap(), + val txt: Map> = emptyMap(), + ) : DnsResolver { + override fun srv(name: String) = srv[name].orEmpty() + override fun txt(name: String) = txt[name].orEmpty() + } + + private fun urls(input: String, dns: DnsResolver = DnsResolver.None) = + ServiceDiscovery.candidatesFor(input, dns).map { it.url.toString() } + + @Test + fun `a typed base URL is used as typed and never triggers DNS`() { + val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 8443, "dav.example.com")))) + val candidates = urls("https://cloud.example.com/remote.php/dav/", dns) + + // The typed path is tried first and the SRV target is never consulted: + // a user who gave us an address meant it. + assertThat(candidates.first()).isEqualTo("https://cloud.example.com/remote.php/dav/") + assertThat(candidates).doesNotContain("https://dav.example.com:8443/") + // The well-known follows as a fallback, because a typed path may have + // been a guess — see `a typed deep URL is tried as typed, first`. + assertThat(candidates) + .containsExactly( + "https://cloud.example.com/remote.php/dav/", + "https://cloud.example.com/.well-known/caldav", + ).inOrder() + } + + @Test + fun `the well-known ladder ends at the root, not at well-known`() { + // The draft stopped at /.well-known/caldav; a server that 404s there and + // serves DAV from / would be undiscoverable. + assertThat(urls("me@example.com")).containsExactly( + "https://example.com/.well-known/caldav", + "https://example.com/", + ).inOrder() + } + + @Test + fun `Posteo is SRV-only on port 8443, and its well-known 404s`() { + val dns = FakeDns( + srv = mapOf("_caldavs._tcp.posteo.de" to listOf(SrvRecord(0, 0, 8443, "posteo.de."))), + txt = mapOf("_caldavs._tcp.posteo.de" to listOf("path=/")), + ) + // Hardcoding 443 fails Posteo outright. + assertThat(urls("me@posteo.de", dns)).containsExactly( + "https://posteo.de:8443/", + "https://posteo.de:8443/.well-known/caldav", + ).inOrder() + } + + @Test + fun `GMX publishes a TXT path that discovery cannot skip`() { + val dns = FakeDns( + txt = mapOf("_caldavs._tcp.gmx.net" to listOf("path=/begenda/dav/users/")), + ) + // Skip the TXT lookup and GMX lands on the wrong path, finding nothing. + assertThat(urls("me@gmx.net", dns)).contains("https://gmx.net/begenda/dav/users/") + assertThat(urls("me@gmx.net", dns).first()).isEqualTo("https://gmx.net/begenda/dav/users/") + } + + @Test + fun `a null SRV target means explicitly unavailable, not no record`() { + // RFC 2782. _caldav._tcp.fastmail.com and runbox.com both answer `0 0 0 .` + val dns = FakeDns(srv = mapOf("_caldavs._tcp.runbox.com" to listOf(SrvRecord(0, 0, 0, ".")))) + assertThat(urls("me@runbox.com", dns)).containsExactly( + "https://runbox.com/.well-known/caldav", + "https://runbox.com/", + ).inOrder() + } + + @Test + fun `Google's SRV record points at something that is not a DAV server`() { + // calendar.google.com answers 405 to PROPFIND. A strict RFC 6764 client + // follows it into a dead end for every @gmail.com address. + val dns = FakeDns( + srv = mapOf("_caldavs._tcp.gmail.com" to listOf(SrvRecord(5, 0, 443, "calendar.google.com."))), + ) + assertThat(urls("me@gmail.com", dns)).doesNotContain("https://calendar.google.com/") + } + + @Test + fun `an SRV target outside the queried domain is refused`() { + // Plain UDP DNS, no DNSSEC, and the ladder is walked again after a 401 + // with the authenticated client. The credential is scoped to the typed + // address's registrable domain anyway, so this target could only ever + // answer 401. + val dns = FakeDns( + srv = mapOf( + "_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "dav.attacker.test.")), + ), + ) + val candidates = urls("me@example.com", dns) + + assertThat(candidates).doesNotContain("https://dav.attacker.test/.well-known/caldav") + // The typed domain's own ladder still runs. + assertThat(candidates).contains("https://example.com/.well-known/caldav") + } + + @Test + fun `a subdomain SRV target is still followed`() { + val dns = FakeDns( + srv = mapOf( + "_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "caldav.example.com.")), + ), + ) + assertThat(urls("me@example.com", dns)) + .contains("https://caldav.example.com/.well-known/caldav") + } + + @Test + fun `SRV priority wins, then weight`() { + val dns = FakeDns( + srv = mapOf( + "_caldavs._tcp.example.com" to listOf( + SrvRecord(priority = 20, weight = 0, port = 443, target = "backup.example.com"), + SrvRecord(priority = 10, weight = 5, port = 443, target = "low.example.com"), + SrvRecord(priority = 10, weight = 90, port = 443, target = "main.example.com"), + ), + ), + ) + assertThat(urls("me@example.com", dns).first()).startsWith("https://main.example.com/") + assertThat(urls("me@example.com", dns)).containsAtLeast( + "https://main.example.com/.well-known/caldav", + "https://low.example.com/.well-known/caldav", + "https://backup.example.com/.well-known/caldav", + ).inOrder() + } + + @Test + fun `port 443 stays implicit so URLs compare equal`() { + val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "dav.example.com")))) + assertThat(urls("me@example.com", dns).first()).isEqualTo("https://dav.example.com/.well-known/caldav") + } + + @Test + fun `mailto and a bare domain both resolve`() { + assertThat(ServiceDiscovery.domainOf("mailto:me@example.com")).isEqualTo("example.com") + assertThat(ServiceDiscovery.domainOf("me@example.com")).isEqualTo("example.com") + assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com") + } + + @Test + fun `something that is neither an address nor a URL yields nothing`() { + assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty() + assertThat(ServiceDiscovery.candidatesFor("")).isEmpty() + } + + @Test + fun `every candidate carries where it came from`() { + val dns = FakeDns(txt = mapOf("_caldavs._tcp.gmx.net" to listOf("path=/begenda/dav/users/"))) + assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin }) + .contains("domain as typed + TXT path=/begenda/dav/users/") + } + + // --- a typed URL still gets the RFC 6764 probe ------------------------- + + @Test fun `a typed bare origin still tries well-known`() { + // ⚠️ The case every user actually types. The origin is the *web UI*, and + // PROPFIND on it returns the 405 any web server answers — which reads as + // "not a CalDAV server" about a working Nextcloud. Found against a real + // server, not by reading. + val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com") + .map { it.url.encodedPath } + + assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder() + } + + @Test fun `a trailing slash is still a bare origin`() { + val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/") + .map { it.url.encodedPath } + + assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder() + } + + @Test fun `a typed deep URL is tried as typed, first`() { + // A deep URL may be the DAV root itself, where one PROPFIND can return + // principal, home-set and collection together. + val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/remote.php/dav/") + .map { it.url.encodedPath } + + assertThat(paths.first()).isEqualTo("/remote.php/dav/") + // …but the path may have been a guess, so the probe stays as a fallback. + assertThat(paths).contains("/.well-known/caldav") + } + + @Test fun `a non-default port survives the origin rebuild`() { + val candidates = ServiceDiscovery.candidatesFor("https://cloud.example.com:8443") + + assertThat(candidates.map { it.url.toString() }) + .containsExactly( + "https://cloud.example.com:8443/.well-known/caldav", + "https://cloud.example.com:8443/", + ).inOrder() + } + + @Test fun `an IPv6 literal keeps its brackets through the origin rebuild`() { + // ⚠️ `HttpUrl.host` hands back "fd00::1", not "[fd00::1]", so an origin + // built by interpolating it is a string OkHttp will not parse — both + // candidates were silently dropped and a homelab address reported as + // "not an address". + val candidates = ServiceDiscovery.candidatesFor("https://[fd00::1]:8443") + + assertThat(candidates.map { it.url.toString() }) + .containsExactly( + "https://[fd00::1]:8443/.well-known/caldav", + "https://[fd00::1]:8443/", + ).inOrder() + } +} diff --git a/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/WebDavPushTest.kt b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/WebDavPushTest.kt new file mode 100644 index 0000000..cc75541 --- /dev/null +++ b/caldav/src/test/kotlin/de/jeanlucmakiola/caldav/WebDavPushTest.kt @@ -0,0 +1,269 @@ +package de.jeanlucmakiola.caldav + +import com.google.common.truth.Truth.assertThat +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.time.Instant + +class WebDavPushTest { + + private val server = MockWebServer() + private val httpClient = OkHttpClient.Builder().followRedirects(false).build() + + @Before fun start() = server.start() + + @After fun stop() = server.shutdown() + + private fun collectionUrl() = server.url("/dav/tasks/") + + private val requested = Instant.parse("2026-10-01T10:00:00Z") + + // ------------------------------------------------------------ discovery + + @Test fun `state reads the topic and VAPID key`() { + server.enqueue(stateResponse(push = PUSH_PROPS)) + + val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push + + assertThat(push).isEqualTo(PushSupport(topic = "O7M1nQ7cKkKTKsoS_j6Z3w", vapidPublicKey = VAPID)) + } + + @Test fun `state asks for the push properties by name`() { + server.enqueue(stateResponse(push = "")) + CalendarCollection(httpClient, collectionUrl()).state().getOrThrow() + + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("https://bitfire.at/webdav-push") + assertThat(body).contains("transports") + assertThat(body).contains("topic") + } + + @Test fun `a topic without a web-push transport is no support`() { + server.enqueue(stateResponse(push = """t1""")) + + val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push + + assertThat(push).isNull() + } + + @Test fun `a web-push transport without a topic is no support`() { + server.enqueue(stateResponse(push = """""")) + + val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push + + assertThat(push).isNull() + } + + @Test fun `web-push without a VAPID key is still support`() { + server.enqueue( + stateResponse(push = """t1"""), + ) + + val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push + + assertThat(push).isEqualTo(PushSupport(topic = "t1", vapidPublicKey = null)) + } + + // ---------------------------------------------------------- registration + + @Test fun `register posts the subscription and reads Location and Expires`() { + server.enqueue( + MockResponse() + .setResponseCode(201) + .setHeader("Location", "/dav/subscriptions/io6Efei4ooph") + .setHeader("Expires", "Sat, 03 Oct 2026 07:28:00 GMT"), + ) + + val outcome = WebDavPush.register( + httpClient, collectionUrl(), "https://up.example.net/abc", "PUBKEY", "AUTH", requested, + ) + + assertThat(outcome).isEqualTo( + WebDavPush.Registration.Registered( + url = server.url("/dav/subscriptions/io6Efei4ooph"), + expires = Instant.parse("2026-10-03T07:28:00Z"), + ), + ) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/dav/tasks/") + val body = request.body.readUtf8() + assertThat(body).contains("push-register") + assertThat(body).contains("https://up.example.net/abc") + assertThat(body).contains("aes128gcm") + assertThat(body).contains("PUBKEY") + assertThat(body).contains("AUTH") + assertThat(body).contains("content-update") + assertThat(body).contains("Thu, 01 Oct 2026 10:00:00 GMT") + } + + @Test fun `register without Expires keeps the requested date`() { + server.enqueue(MockResponse().setResponseCode(204).setHeader("Location", "/sub/1")) + + val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", null, null, requested) + + assertThat(outcome).isEqualTo(WebDavPush.Registration.Registered(server.url("/sub/1"), requested)) + } + + @Test fun `register leaves the keys out when there are none`() { + server.enqueue(MockResponse().setResponseCode(204).setHeader("Location", "/sub/1")) + + WebDavPush.register(httpClient, collectionUrl(), "https://up/1", null, null, requested) + + val body = server.takeRequest().body.readUtf8() + assertThat(body).doesNotContain("content-encoding") + assertThat(body).doesNotContain("subscription-public-key") + } + + @Test fun `a 403 is a refusal`() { + server.enqueue(MockResponse().setResponseCode(403)) + + val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested) + + assertThat(outcome).isEqualTo(WebDavPush.Registration.Refused(403)) + } + + @Test fun `a 500 is a failure, not a refusal`() { + server.enqueue(MockResponse().setResponseCode(500)) + + val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested) + + assertThat(outcome).isInstanceOf(WebDavPush.Registration.Failed::class.java) + } + + @Test fun `a 500 carries sabre's exception message`() { + server.enqueue( + MockResponse().setResponseCode(500) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + """ + + TypeError + Call to a member function getTimestamp() on bool + """, + ), + ) + + val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested) + + assertThat(outcome).isEqualTo( + WebDavPush.Registration.Failed("HTTP 500: Call to a member function getTimestamp() on bool"), + ) + } + + @Test fun `unregister counts an already expired subscription as removed`() { + server.enqueue(MockResponse().setResponseCode(404)) + + assertThat(WebDavPush.unregister(httpClient, server.url("/sub/1"))).isTrue() + assertThat(server.takeRequest().method).isEqualTo("DELETE") + } + + @Test fun `unregister reports a server error as not removed`() { + server.enqueue(MockResponse().setResponseCode(500)) + + assertThat(WebDavPush.unregister(httpClient, server.url("/sub/1"))).isFalse() + } + + // ---------------------------------------------------------------- writes + + @Test fun `writes name our subscription in Push-Dont-Notify, reads do not`() { + val registration = server.url("/sub/1") + val collection = CalendarCollection(httpClient, collectionUrl(), pushRegistration = registration) + server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e1\"")) + server.enqueue(MockResponse().setResponseCode(204)) + server.enqueue(stateResponse(push = "")) + + collection.create("a.ics", "BEGIN:VCALENDAR") + collection.delete(collectionUrl().resolve("a.ics")!!, "e1") + collection.state() + + assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isEqualTo("\"$registration\"") + assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isEqualTo("\"$registration\"") + assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isNull() + } + + // -------------------------------------------------------------- messages + + @Test fun `a content update carries its topic and sync token`() { + val message = WebDavPush.parse( + """ + + + O7M1nQ7cKkKTKsoS_j6Z3w + + http://example.com/sync/10 + + + + """.trimIndent(), + ) + + assertThat(message).isEqualTo( + WebDavPush.Message( + topic = "O7M1nQ7cKkKTKsoS_j6Z3w", + syncToken = "http://example.com/sync/10", + keyRotation = false, + ), + ) + } + + @Test fun `a transports update without a topic is a key rotation`() { + val message = WebDavPush.parse( + """ + + + + + + + + + """.trimIndent(), + ) + + assertThat(message).isEqualTo(WebDavPush.Message(topic = null, syncToken = null, keyRotation = true)) + } + + @Test fun `anything that is not a push message is null`() { + assertThat(WebDavPush.parse("not xml")).isNull() + assertThat(WebDavPush.parse("")).isNull() + } + + private fun stateResponse(push: String) = MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + """ + + + /dav/tasks/ + + + $push + HTTP/1.1 200 OK + + + """.trimIndent(), + ) + + private companion object { + const val VAPID = + "BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs" + + const val PUSH_PROPS = """ + + + $VAPID + + + O7M1nQ7cKkKTKsoS_j6Z3w + + 1 + + """ + } +} diff --git a/dav/LICENSE b/dav/LICENSE new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/dav/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/dav/PROVENANCE.md b/dav/PROVENANCE.md new file mode 100644 index 0000000..ac6224b --- /dev/null +++ b/dav/PROVENANCE.md @@ -0,0 +1,311 @@ +# `:dav` — vendored dav4jvm + +**Upstream:** [bitfireAT/dav4jvm](https://github.com/bitfireAT/dav4jvm), +tag **2.2.1**, commit `f434c9d19b322228916c106beaebd8634b85ddb1`. +**Licence:** MPL-2.0 (`dav/LICENSE`, verbatim). Every file keeps its upstream +header, as §3.4 requires. Agendula's own code is MIT and unaffected — MPL is +file-level copyleft, which is why this lives in its own module rather than +inside `:app`. + +62 source files, ~4,200 lines, plus upstream's 15 test classes. + +--- + +## Why vendored, and why this version + +We *vendor rather than depend*. dav4jvm is +published on **JitPack only**, which conflicts with our `FAIL_ON_PROJECT_REPOS` +policy, does not sign artifacts, and rebuilds on demand — so a coordinate is not +immutable. Upstream also shipped two breaking majors nineteen days apart +(3.0.0 on 2026-07-08, 4.0.0 on 2026-07-27). + +**2.2.1 is the last OkHttp release.** 3.0.0 deleted the OkHttp package for Ktor, +and 4.x additionally requires **Java 21** bytecode while we target 17 across +`:app` and all of floret-kit. Taking 4.x would mean Ktor (~2.45 MB), the wrong +`guava` flavour, and a Java-target migration — and it would invalidate the whole +auth design, which is written in OkHttp terms throughout +(preemptive Basic via an `Interceptor`, OkHttp stripping `Authorization` on +cross-host redirects, `BasicDigestAuthHandler` because OkHttp has no Digest). + +Vendoring at 2.2.1 costs us upstream's later work and makes us responsible for +this tree. What it buys: our own Java target, no JitPack, no Ktor tail, no xpp3 +in the APK, and the freedom to fix the defects below rather than route around +them. + +**This is a plain JVM module, not an Android library.** The upstream tree has +zero Android imports and must not gain any: this layer is earmarked +for floret-kit's `core-dav`, and Calendula needs the same primitives, so +extraction should stay a file move. + +--- + +## Changes from upstream + +Upstream's own test suite is vendored with the code and passes unmodified — +that is what makes these changes safe to make. Our additions live in +`LocalChangesTest`; every other test file is upstream's, untouched. + +### 1. `commons-lang3` removed + +`HttpUtils.kt` imported `org.apache.commons.lang3.time.DateUtils` for exactly one +call — `DateUtils.parseDate(str, locale, patterns…)`, a loop over format strings. +Replaced with that loop. The dependency is gone; the format list is byte-for-byte +upstream's, comments included. + +⚠️ **The loop is not the whole of what `DateUtils` did.** It parsed with a +`ParsePosition` and rejected a pattern unless the *entire* string was consumed; +`SimpleDateFormat.parse(String)` accepts a prefix. Pattern 1 ends in the quoted +**literal** `'GMT'`, so `"Wed, 21 Oct 2015 07:28:00 GMT+02:00"` matches it as a +prefix and the offset is silently discarded — a two-hour error, and precisely the +failure change 2 exists to remove. The replacement requires full consumption. + +### 2. ⚠️ HTTP dates were parsed and formatted in the device's local zone + +**An upstream defect, not a porting artefact.** `httpDateFormatStr` is +`"EEE, dd MMM yyyy HH:mm:ss 'GMT'"` — the `GMT` is a **quoted literal**, so +`SimpleDateFormat` neither reads nor writes a zone from it, and +`httpDateFormat` never had `timeZone` set. So `formatDate` emitted local time +labelled `GMT`, and `parseDate` read `07:28:00 GMT` as 07:28 *local* — every +`getlastmodified` out by the device's UTC offset, in whichever direction the +user happens to live. + +Upstream's `HttpUtilsTest` covers only `fileName()` and never touches dates, +which is why this survived. Fixed by forcing GMT on the formatter and on every +parse attempt; patterns carrying a real `z` still take the zone from the input, +as they must. Covered by `LocalChangesTest`. + +### 3. Permanent redirects now reach the caller (`dav4jvm#209`) + +`followRedirects` mutated `location` in place for every 3xx and told the caller +nothing, so a caller could not distinguish *"this resource has moved, store the +new URL"* from *"follow this once"*. DAVx5 consequently never rewrites a stored +collection URL after a 301 and re-follows it on every sync; persisting the new +URL ourselves is the fix. + +Added `DavResource.permanentLocation`, set only along an unbroken chain of 301 / +308. A temporary hop ends the chain — `301 → 302` means the resource moved to the +301's target and is being served elsewhere *for now*, so persisting the 302's +target would be wrong. `location` still moves for every redirect, unchanged. + +It is **cleared at the start of every request**, so it describes the request just +made and never one made earlier through the same object. `DavResource` instances +are reused, and a stale value would have the caller persist a URL that a later +`move()` already superseded. + +### 4. `xpp3` is compile-time only + +Upstream declares `org.ogce:xpp3` as `api`. Android ships `org.xmlpull.v1` in the +framework, so the 371 KB jar is `compileOnly` here and never reaches the APK. The +unit tests run on a plain JVM, which has no framework, so they get the real +implementation via `testImplementation`. + +### 5. `httpDateFormat` is no longer a shared mutable formatter + +Upstream exposed a single public `SimpleDateFormat`. It is mutable and not +thread-safe: two workers formatting a header concurrently corrupt each other +through its `Calendar`, and any caller could `setTimeZone` on it and undo change 2 +for everyone else. It is now private and built per call. Nothing else in the tree +referenced it. + +### 6. `` is parsed instead of inferred + +`CurrentUserPrincipal.Factory` read only the `` child, so an +unauthenticated body (RFC 5397 §3 — a **200** whose content means the request was +not authenticated) arrived as "property present, href null" — identical to a +conformant-but-empty element, and to a server that omits the property entirely. +A caller inferring rejection from the null href therefore also fires on merely +non-conformant servers, and on a request that carried no credential at all. + +The factory now makes one pass over both children (`XmlUtils.processTag` consumes +to the end tag and so cannot be called twice) and reports `unauthenticated` +explicitly. Without it, a rejected credential is indistinguishable from a +successful discovery that found nothing. + +### 7. WebDAV-Push properties, backported + +2.2.1 predates the WebDAV-Push draft (`https://bitfire.at/webdav-push`). +`property/push/` adds what a client reads: the element names, `P:transports` +(Web Push only, with the server's VAPID key) and `P:topic`, both registered in +`PropertyRegistry`. Modelled on upstream's `property/push`, but cut down: the +request bodies and push messages are built and parsed in `:caldav`, which is the +only caller, so upstream's register/message/trigger property classes are left +out. + +### 8. HTTP dates are formatted in `Locale.US` + +Change 5 built the formatter with `Locale.ROOT`. A desktop JVM formats that in +English, so every test passed; Android's ICU root locale abbreviates months as +`M01`–`M12`, so on a device `formatDate` produced `Thu, 01 M10 2026 …`. Nothing +used it until WebDAV-Push sent it as ``, and Nextcloud's `dav_push` +app answered every registration with a 500. `parseDate` already used +`Locale.US`; formatting now matches it. + +--- + +## Build integration + +- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testDebugUnitTest`, + which exists only on Android variants, so `.forgejo/workflows/ci.yaml` names + `:dav:test` explicitly. Without that the vendored suite is compiled by nobody + and run by nobody, and the safety argument above is void. +- `:app` declares `testImplementation(libs.xpp3)`. `compileOnly` is not + transitive, `:app`'s unit tests run on a plain JVM with no framework, and + `isReturnDefaultValues = true` makes android.jar's stub factory return `null` — + so anything touching `XmlUtils` would fail with an unrelated-looking NPE. + +--- + +## What was *not* a defect + +Two dav4jvm defects were expected to carry over. Only one of them +exists in this version: + +- **"Handles 301/302/307/308 but not 303"** — does not hold for 2.2.1. + `followRedirects` gates on OkHttp's `Response.isRedirect`, which includes + `HTTP_SEE_OTHER`, and it re-sends the same method, which is what RFC 6764 §5 + asks for during discovery. Pinned by a test so a future resync cannot lose it + silently. +- **`dav4jvm#209`** — real, and fixed above as change 3. + +--- + +## Resyncing + +Fetch the new tag, diff against `f434c9d`, reapply changes 1–4, run +`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the +entire reason it is vendored alongside the code. + +## Change 7 — a same-host HTTPS→HTTP redirect is upgraded, not refused + +`DavResource.followRedirects` threw `DavException("Received redirect from HTTPS +to HTTP")` for any downgrade. That is right for a redirect to a *different* host, +which has no innocent reading. It is wrong for the same host, and the same host +is the case that actually occurs. + +⚠️ **A Nextcloud behind a TLS-terminating reverse proxy without +`overwriteprotocol` — or without `proxy_set_header X-Forwarded-Proto $scheme` — +builds every redirect with `http://`.** That includes the `/.well-known/caldav` +hop RFC 6764 discovery depends on. The server is entirely functional: +`/remote.php/dav/` answers 401 over HTTPS exactly as it should. But discovery +refuses the downgrade, falls back to a `PROPFIND` on the web root, gets the 405 +an ordinary web server returns, and reports "not a CalDAV server" about a working +CalDAV server. + +Now: when the redirect target's host matches the current one, the scheme is put +back to `https` and the hop continues. Re-issuing the same host and path over TLS +is *strictly safer* than obeying the redirect as sent, and it preserves the +invariant that matters — credentials never travel in cleartext. A cross-host +downgrade still throws. + +Found against a real server, not by reading: `cloud.jeanlucmakiola.de` returns +`301 → http://cloud.jeanlucmakiola.de/remote.php/dav/`. + +## Change 8 — the credential is scoped by the public-suffix list, not by a label split + +`BasicDigestAuthHandler` gated every request on +`domain.equals(UrlUtils.hostToDomain(request host))`, and `hostToDomain` is a +pure last-two-labels split with no public-suffix knowledge. So a server at +`cloud.example.co.uk` scoped the credential to `co.uk`, one at +`myhome.duckdns.org` to `duckdns.org`, and a self-hoster at `192.168.1.10` to +`1.10`. + +⚠️ **The handler adds `Authorization: Basic` preemptively**, before any +challenge, to the first HTTPS request to any host that passes that gate. So the +scope is not merely recorded — it is the set of hosts that receive the app +password unprompted. + +That is reachable. `ServiceDiscovery` builds candidate origins from SRV targets +without requiring the target to lie inside the queried domain, over plain UDP +DNS with no DNSSEC. Correct scoping forces an on-path attacker to obtain a +certificate for a name inside the victim's own registrable domain, which is +infeasible; `co.uk` scoping lets them point the SRV at a domain they own and +hold a legitimate certificate for. + +Now: `request.url.topPrivateDomain() ?: request.url.host`. OkHttp bundles the +public-suffix list including its private section, so the dynamic-DNS providers +self-hosters actually use are covered. `topPrivateDomain()` is null for an IP +literal, a single-label host, and a host that *is* a public suffix — and null +means *no restriction* to this handler, so it falls back to the exact host. + +The caller must derive the scope the same way, which is why +`CalDavHttp.authenticated` changed with it: a mismatch withholds the credential +from every request rather than leaking it. + +`UrlUtils.hostToDomain` and its test are left alone — after this it has no +production callers, and keeping it keeps the resync diff small. + +## Change 9 — Basic is refused over cleartext even when the server asks for it + +`insecurePreemptive` gated only the preemptive branch. A plain-HTTP server +answering 401 with a `Basic` challenge still got +`Authorization: Basic ` in the clear — the flag's name was +accurate and its coverage was not. + +The gate now sits on the Basic *emission*, so both paths are covered by one +condition, and the flag is renamed `insecureBasic` to say what it actually +permits. Digest is deliberately untouched: it never puts the password on the +wire, and refusing it would break a LAN server the day the documented +per-account cleartext opt-in ships. + +Not currently reachable in the app — `network_security_config.xml` sets +`cleartextTrafficPermitted="false"`, so OkHttp throws before the request is +written, and nothing passes `allowCleartext = true`. Fixed anyway: `:caldav` is +a plain JVM module earmarked for reuse, where the Android policy does not apply, +and the mitigation would evaporate silently the day that opt-in is wired up. + +A refused challenge is also not cached. Recording one we never answer leaves the +handler believing Basic is in play, so the preemptive block is skipped, the +refusal repeats, and the 401 after that reports "Basic credentials didn't work +last time" about a credential that never reached the wire. + +⚠️ **Upstream's `BasicDigestAuthHandlerTest.testBasic` was amended** — it +asserted exactly this behaviour, using `http://example.com` with a Basic +challenge and expecting the header. Its URL is now `https://`, and the +cleartext cases it used to cover are pinned explicitly by +`cleartextBasicIsRefusedEvenWhenChallenged`, +`cleartextBasicIsSentWhenExplicitlyAllowed` and `cleartextDigestIsStillAnswered`. +This is the one upstream test this port deliberately changes rather than +inherits. + +## Change 10 — a Basic challenge no longer hides the Digest one beside it + +The 401 branch scanned `response.challenges()` in a loop and did a non-local +`return null` the moment it saw a `Basic` challenge it had already tried. Because +the handler is installed as a **network interceptor**, `basicAuth` is always +primed preemptively over HTTPS — so that fired on the *first* 401, and a +`Digest` challenge later in the same header was never read at all. + +A Baïkal or Apache front end that advertises both and rejects Basic at the +application layer therefore never got a Digest answer, and `SyncEngine` marked +the account as needing sign-in permanently. + +The loop now reads every challenge before anything is decided; a scheme already +known not to work is simply not re-offered. Giving up is still the outcome when +nothing usable is left — it happens after the whole header has been looked at +rather than in the middle of it. + +## Change 11 — the digest counter is per handler, and per nonce + +`clientNonce` and `nonceCount` lived on the companion object while `SyncEngine` +builds **one handler per account**, so two accounts syncing at once interleaved +their `nc` values against each other's nonces. They are instance state now. + +The count was also never reset. RFC 7616 §3.4.1 defines `nc` as the count of +requests sent *with that nonce*, starting at 1, so a server that enforces it +(Apache `AuthDigestNcCheck On`, several NAS stacks) answers 401 for a rotated +nonce that arrives with a carried-over count. A new server nonce now restarts +it. The client nonce stays put: it is ours, one per handler, and pairing it with +a restarted count is what the RFC describes. + +The tell upstream left behind is that every digest test has to reset the counter +by hand — those four assignments now address the handler rather than the class, +which is the only change to that file. + +## Change 12 — `qop` list values are trimmed + +`paramValue.split(",")` with no `trim()`. HTTP list syntax allows space around +the separator, so `qop="auth, auth-int"` silently downgraded to `auth`, and a +single spaced value (`qop=" auth"`) matched nothing at all — dropping into the +RFC 2069 legacy branch, which emits a response with no `qop`, `nc` or `cnonce`. +An RFC 7616 server rejects that outright: a permanent 401 against a server +behaving perfectly legally. Values are trimmed and compared case-insensitively. diff --git a/dav/build.gradle.kts b/dav/build.gradle.kts new file mode 100644 index 0000000..ca95058 --- /dev/null +++ b/dav/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + // No version: AGP already puts the Kotlin plugin on the build classpath, and + // asking for one here fails resolution ("already on the classpath with an + // unknown version"). The version is the catalogue's `kotlin`, via AGP. + id("org.jetbrains.kotlin.jvm") +} + +// A plain JVM library on purpose: the vendored tree has no Android imports and +// must not gain any. That keeps it portable — this layer is earmarked for +// floret-kit's `core-dav`, and Calendula needs the same primitives. +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + api(libs.okhttp) + + // Android supplies org.xmlpull.v1 in the framework, so the 371 KB xpp3 jar is + // a compile-time stand-in only and never reaches the APK. The unit tests run + // on a plain JVM, which has no framework, so they get the real thing. + compileOnly(libs.xpp3) + testImplementation(libs.xpp3) + + // Upstream's own suite, vendored with the code. It is JUnit 4 while :app is + // JUnit 5; rewriting it would forfeit the regression coverage that makes + // vendoring safe, which is the same call provider/PROVENANCE.md made. + testImplementation(libs.junit4) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt new file mode 100644 index 0000000..be694f0 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandler.kt @@ -0,0 +1,361 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import okhttp3.* +import okhttp3.Response +import okio.Buffer +import okio.ByteString.Companion.toByteString +import java.io.IOException +import java.util.* +import java.util.concurrent.atomic.AtomicInteger + +/** + * Handler to manage authentication against a given service (may be limited to one domain). + * There's no domain-based cache, because the same user name and password will be used for + * all requests. + * + * Authentication methods/credentials found to be working will be cached for further requests + * (this is why the interceptor is needed). + * + * Usage: Set as authenticator *and* as network interceptor. + */ +class BasicDigestAuthHandler( + /** + * Authenticate only against hosts sharing this registrable domain, as + * [HttpUrl.topPrivateDomain] derives it — or, for a host that has none + * (an IP literal, `localhost`, a host that *is* a public suffix), only + * against that exact host. Null means no restriction. + */ + val domain: String?, + + val username: String, + val password: String, + + /** + * Allow Basic over cleartext — both preemptively and in answer to a + * challenge. Off by default: the header is the password. + */ + val insecureBasic: Boolean = false +): Authenticator, Interceptor { + + companion object { + private const val HEADER_AUTHORIZATION = "Authorization" + + fun quotedString(s: String) = "\"" + s.replace("\"", "\\\"") + "\"" + fun h(data: String) = data.toByteArray().toByteString().md5().hex() + + fun h(body: RequestBody): String { + val buffer = Buffer() + body.writeTo(buffer) + return buffer.readByteArray().toByteString().md5().hex() + } + + fun kd(secret: String, data: String) = h("$secret:$data") + } + + // cached authentication schemes + private var basicAuth: Challenge? = null + private var digestAuth: Challenge? = null + + /* + * ⚠️ Per handler, not per process. Upstream keeps these on the companion + * object while `SyncEngine` builds one handler per account, so two accounts + * syncing at once interleave their `nc` values against each other's nonces. + * RFC 7616 §3.4.1 defines `nc` as the count of requests sent *with that + * nonce*, starting at 1 — a server that enforces it (Apache + * `AuthDigestNcCheck On`, several NAS stacks) answers 401 and the account is + * marked as needing sign-in. The tell upstream left behind is that every + * digest test has to reset the counter by hand. + */ + var clientNonce = h(UUID.randomUUID().toString()) + val nonceCount = AtomicInteger(1) + + /** The server nonce [nonceCount] is counting against. */ + private var countedNonce: String? = null + + + fun authenticateRequest(request: Request, response: Response?): Request? { + domain?.let { + val host = request.url.host + // ⚠️ The public-suffix list, not a last-two-labels split. Splitting + // scopes `cloud.example.co.uk` to `co.uk` and `192.168.1.10` to + // `1.10`, handing the credential preemptively to any host that + // matches — including one an attacker can buy a certificate for. + // `topPrivateDomain()` is null for hosts with no registrable domain, + // and null here would mean *no* restriction, so it falls back to the + // exact host. + if (!domain.equals(request.url.topPrivateDomain() ?: host, true)) { + Dav4jvm.log.warning("Not authenticating against $host because it doesn't belong to $domain") + return null + } + } + + if (response == null) { + // we're not processing a 401 response + + if (basicAuth == null && digestAuth == null && (request.isHttps || insecureBasic)) { + Dav4jvm.log.fine("Trying Basic auth preemptively") + basicAuth = Challenge("Basic", "") + } + + } else { + // we're processing a 401 response + + // ⚠️ Every challenge is read before anything is decided. Upstream + // returned out of this loop the moment Basic was found to have + // failed — and because the handler is a network interceptor, + // `basicAuth` is *always* primed preemptively, so that fired on the + // very first 401. A server advertising `Basic` then `Digest` in one + // header therefore never had its Digest challenge read at all: a + // Baikal/Apache front end that offers both and rejects Basic at the + // app layer got no Digest answer, and the account was marked as + // needing sign-in permanently. Giving up is still the outcome when + // nothing usable is left — it just happens after the whole header + // has been looked at. + var newBasicAuth: Challenge? = null + var newDigestAuth: Challenge? = null + for (challenge in response.challenges()) + when { + "Basic".equals(challenge.scheme, true) -> + if (basicAuth != null) { + Dav4jvm.log.warning("Basic credentials didn't work last time -> not offering them again") + } else { + newBasicAuth = challenge + } + "Digest".equals(challenge.scheme, true) -> + if (digestAuth != null && !"true".equals(challenge.authParams["stale"], true)) { + Dav4jvm.log.warning("Digest credentials didn't work last time and server nonce has not expired -> not offering them again") + } else { + newDigestAuth = challenge + } + } + + // ⚠️ Not cached if we would refuse to answer it. Caching a challenge + // we never answer leaves the handler believing Basic is in play: the + // preemptive block is skipped, the refusal repeats, and the 401 after + // that reports "Basic credentials didn't work last time" about a + // credential that was never put on the wire. + basicAuth = newBasicAuth?.takeIf { request.isHttps || insecureBasic } + digestAuth = newDigestAuth + } + + // we MUST prefer Digest auth [https://tools.ietf.org/html/rfc2617#section-4.6] + when { + digestAuth != null -> { + Dav4jvm.log.fine("Adding Digest authorization request for ${request.url}") + return digestRequest(request, digestAuth) + } + + basicAuth != null -> { + // ⚠️ Gated here, not only on the preemptive path above. Basic + // *is* the password, in a header any hop can read, so a plain + // HTTP server answering 401 with a Basic challenge must not be + // able to ask for it. Digest is left alone: it never transmits + // the password, and refusing it would kill a LAN server the day + // the cleartext opt-in ships. + if (!request.isHttps && !insecureBasic) { + Dav4jvm.log.warning("Refusing to send Basic credentials over cleartext to ${request.url}") + return null + } + Dav4jvm.log.fine("Adding Basic authorization header for ${request.url}") + + /* In RFC 2617 (obsolete), there was no encoding for credentials defined, although + one can interpret it as "use ISO-8859-1 encoding". This has been clarified by RFC 7617, + which creates a new charset parameter for WWW-Authenticate, which always must be UTF-8. + So, UTF-8 encoding for credentials is compatible with all RFC 7617 servers and many, + but not all pre-RFC 7617 servers. */ + return request.newBuilder() + .header(HEADER_AUTHORIZATION, Credentials.basic(username, password, Charsets.UTF_8)) + .build() + } + + response != null -> + Dav4jvm.log.warning("No supported authentication scheme") + } + + return null + } + + fun digestRequest(request: Request, digest: Challenge?): Request? { + if (digest == null) + return null + + val realm = digest.authParams["realm"] + val opaque = digest.authParams["opaque"] + val nonce = digest.authParams["nonce"] + + val algorithm = Algorithm.determine(digest.authParams["algorithm"]) + val qop = Protection.selectFrom(digest.authParams["qop"]) + + // build response parameters + var response: String? = null + + val params = LinkedList() + params.add("username=${quotedString(username)}") + if (realm != null) + params.add("realm=${quotedString(realm)}") + else { + Dav4jvm.log.warning("No realm provided, aborting Digest auth") + return null + } + if (nonce != null) + params.add("nonce=${quotedString(nonce)}") + else { + Dav4jvm.log.warning("No nonce provided, aborting Digest auth") + return null + } + if (opaque != null) + params.add("opaque=${quotedString(opaque)}") + + if (algorithm != null) + params.add("algorithm=${quotedString(algorithm.algorithm)}") + + val method = request.method + val digestURI = request.url.encodedPath + params.add("uri=${quotedString(digestURI)}") + + if (qop != null) { + // ⚠️ A new server nonce restarts the count at 1: §3.4.1 counts + // requests sent with *that* nonce, so carrying the count over makes + // the first request against a rotated nonce look like the + // thousandth. The client nonce stays put — it is ours, one per + // handler, and pairing it with a restarted count is what the RFC + // describes. + if (nonce != countedNonce) { + countedNonce = nonce + nonceCount.set(1) + } + + params.add("qop=${qop.qop}") + params.add("cnonce=${quotedString(clientNonce)}") + + val nc = nonceCount.getAndIncrement() + val ncValue = String.format(Locale.ROOT, "%08x", nc) + params.add("nc=$ncValue") + + val a1: String? = when (algorithm) { + Algorithm.MD5 -> + "$username:$realm:$password" + Algorithm.MD5_SESSION -> + h("$username:$realm:$password") + ":$nonce:$clientNonce" + else -> + null + } + Dav4jvm.log.finer("A1=$a1") + + val a2: String? = when (qop) { + Protection.Auth -> + "$method:$digestURI" + Protection.AuthInt -> { + try { + val body = request.body + "$method:$digestURI:" + (if (body != null) h(body) else h("")) + } catch(e: IOException) { + Dav4jvm.log.warning("Couldn't get entity-body for hash calculation") + null + } + } + } + Dav4jvm.log.finer("A2=$a2") + + if (a1 != null && a2 != null) + response = kd(h(a1), "$nonce:$ncValue:$clientNonce:${qop.qop}:${h(a2)}") + + } else { + Dav4jvm.log.finer("Using legacy Digest auth") + + // legacy (backwards compatibility with RFC 2069) + if (algorithm == Algorithm.MD5) { + val a1 = "$username:$realm:$password" + val a2 = "$method:$digestURI" + response = kd(h(a1), nonce + ":" + h(a2)) + } + } + + return if (response != null) { + params.add("response=" + quotedString(response)) + request.newBuilder() + .header(HEADER_AUTHORIZATION, "Digest " + params.joinToString(", ")) + .build() + } else + null + } + + + private enum class Algorithm( + val algorithm: String + ) { + MD5("MD5"), + MD5_SESSION("MD5-sess"); + + companion object { + fun determine(paramValue: String?): Algorithm? { + return when { + paramValue == null || MD5.algorithm.equals(paramValue, true) -> + MD5 + MD5_SESSION.algorithm.equals(paramValue, true) -> + MD5_SESSION + else -> { + Dav4jvm.log.warning("Ignoring unknown hash algorithm: $paramValue") + null + } + } + } + } + } + + private enum class Protection( + val qop: String + ) { // quality of protection: + Auth("auth"), // authentication only + AuthInt("auth-int"); // authentication with integrity protection + + companion object { + fun selectFrom(paramValue: String?): Protection? { + paramValue?.let { + var qopAuth = false + var qopAuthInt = false + // ⚠️ Trimmed. HTTP list syntax allows space around the + // separator, so `qop="auth, auth-int"` silently downgraded + // to auth and a single spaced value matched nothing at all — + // which dropped into the RFC 2069 branch and emitted a + // response with no qop, nc or cnonce, for a permanent 401 + // against a server behaving legally. + for (qop in paramValue.split(",")) + when (qop.trim().lowercase()) { + "auth" -> qopAuth = true + "auth-int" -> qopAuthInt = true + } + + // prefer auth-int as it provides more protection + if (qopAuthInt) + return AuthInt + else if (qopAuth) + return Auth + } + return null + } + } + } + + + override fun authenticate(route: Route?, response: Response) = + authenticateRequest(response.request, response) + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + if (request.header(HEADER_AUTHORIZATION) == null) { + // try to apply cached authentication + val authRequest = authenticateRequest(request, null) + if (authRequest != null) + request = authRequest + } + return chain.proceed(request) + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt new file mode 100644 index 0000000..b31b9a1 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/CallbackInterfaces.kt @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +/** + * Callback for the OPTIONS request. + */ +fun interface CapabilitiesCallback { + fun onCapabilities(davCapabilities: Set, response: okhttp3.Response) +} + +/** + * Callback for 207 Multi-Status responses. + */ +fun interface MultiResponseCallback { + /** + * Called for every `` element in the `` body. For instance, + * in response to a `PROPFIND` request, this callback will be called once for every found + * member resource. + * + * @param response the parsed response (including URL) + * @param relation relation of the response to the called resource + */ + fun onResponse(response: Response, relation: Response.HrefRelation) +} + +/** + * Callback for HTTP responses. + */ +fun interface ResponseCallback { + /** + * Called for a HTTP response. Typically this is only called for successful/redirect + * responses because HTTP errors throw an exception before this callback is called. + */ + fun onResponse(response: okhttp3.Response) +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt new file mode 100644 index 0000000..f9dfb78 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Dav4jvm.kt @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import java.util.logging.Logger + +object Dav4jvm { + + var log = Logger.getLogger("dav4jvm")!! + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt new file mode 100644 index 0000000..1b4130e --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavAddressBook.kt @@ -0,0 +1,141 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.AddressData +import at.bitfire.dav4jvm.property.GetContentType +import at.bitfire.dav4jvm.property.GetETag +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.io.StringWriter +import java.util.logging.Logger + +class DavAddressBook @JvmOverloads constructor( + httpClient: OkHttpClient, + location: HttpUrl, + log: Logger = Dav4jvm.log +): DavCollection(httpClient, location, log) { + + companion object { + val MIME_JCARD = "application/vcard+json".toMediaType() + val MIME_VCARD3_UTF8 = "text/vcard;charset=utf-8".toMediaType() + val MIME_VCARD4 = "text/vcard;version=4.0".toMediaType() + + val ADDRESSBOOK_QUERY = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-query") + val ADDRESSBOOK_MULTIGET = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-multiget") + val FILTER = Property.Name(XmlUtils.NS_CARDDAV, "filter") + } + + /** + * Sends an addressbook-query REPORT request to the resource. + * + * @param callback called for every WebDAV response XML element in the result + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error + */ + fun addressbookQuery(callback: MultiResponseCallback): List { + /* + + */ + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", null) + serializer.setPrefix("", XmlUtils.NS_WEBDAV) + serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV) + serializer.insertTag(ADDRESSBOOK_QUERY) { + insertTag(PROP) { + insertTag(GetETag.NAME) + } + insertTag(FILTER) + } + serializer.endDocument() + + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("REPORT", writer.toString().toRequestBody(MIME_XML)) + .header("Depth", "1") + .build()).execute() + }.use { response -> + return processMultiStatus(response, callback) + } + } + + /** + * Sends an addressbook-multiget REPORT request to the resource. + * + * @param urls list of vCard URLs to be requested + * @param contentType MIME type of requested format; may be "text/vcard" for vCard or + * "application/vcard+json" for jCard. *null*: don't request specific representation type + * @param version vCard version subtype of the requested format. Should only be specified together with a [contentType] of "text/vcard". + * Currently only useful value: "4.0" for vCard 4. *null*: don't request specific version + * @param callback called for every WebDAV response XML element in the result + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error + */ + fun multiget(urls: List, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { + /* + */ + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", null) + serializer.setPrefix("", XmlUtils.NS_WEBDAV) + serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV) + serializer.insertTag(ADDRESSBOOK_MULTIGET) { + insertTag(PROP) { + insertTag(GetContentType.NAME) + insertTag(GetETag.NAME) + insertTag(AddressData.NAME) { + if (contentType != null) + attribute(null, AddressData.CONTENT_TYPE, contentType) + if (version != null) + attribute(null, AddressData.VERSION, version) + } + } + for (url in urls) + insertTag(HREF) { + text(url.encodedPath) + } + } + serializer.endDocument() + + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("REPORT", writer.toString().toRequestBody(MIME_XML)) + .header("Depth", "0") // "The request MUST include a Depth: 0 header [...]" + .build()).execute() + }.use { + return processMultiStatus(it, callback) + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt new file mode 100644 index 0000000..df6867b --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCalendar.kt @@ -0,0 +1,178 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.CalendarData +import at.bitfire.dav4jvm.property.GetContentType +import at.bitfire.dav4jvm.property.GetETag +import at.bitfire.dav4jvm.property.ScheduleTag +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.* +import java.util.logging.Logger + +class DavCalendar @JvmOverloads constructor( + httpClient: OkHttpClient, + location: HttpUrl, + log: Logger = Dav4jvm.log +): DavCollection(httpClient, location, log) { + + companion object { + val MIME_ICALENDAR = "text/calendar".toMediaType() + val MIME_ICALENDAR_UTF8 = "text/calendar;charset=utf-8".toMediaType() + + val CALENDAR_QUERY = Property.Name(XmlUtils.NS_CALDAV, "calendar-query") + val CALENDAR_MULTIGET = Property.Name(XmlUtils.NS_CALDAV, "calendar-multiget") + + val FILTER = Property.Name(XmlUtils.NS_CALDAV, "filter") + val COMP_FILTER = Property.Name(XmlUtils.NS_CALDAV, "comp-filter") + const val COMP_FILTER_NAME = "name" + val TIME_RANGE = Property.Name(XmlUtils.NS_CALDAV, "time-range") + const val TIME_RANGE_START = "start" + const val TIME_RANGE_END = "end" + + private val timeFormatUTC = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT) + init { + timeFormatUTC.timeZone = TimeZone.getTimeZone("Etc/UTC") + } + } + + + /** + * Sends a calendar-query REPORT to the resource. + * + * @param component requested component name (like VEVENT or VTODO) + * @param start time-range filter: start date (optional) + * @param end time-range filter: end date (optional) + * @param callback called for every WebDAV response XML element in the result + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error + */ + fun calendarQuery(component: String, start: Date?, end: Date?, callback: MultiResponseCallback): List { + /* + + + + name value: a calendar object or calendar component + type (e.g., VEVENT) + + */ + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", null) + serializer.setPrefix("", XmlUtils.NS_WEBDAV) + serializer.setPrefix("CAL", XmlUtils.NS_CALDAV) + serializer.insertTag(CALENDAR_QUERY) { + insertTag(PROP) { + insertTag(GetETag.NAME) + } + insertTag(FILTER) { + insertTag(COMP_FILTER) { + attribute(null, COMP_FILTER_NAME, "VCALENDAR") + insertTag(COMP_FILTER) { + attribute(null, COMP_FILTER_NAME, component) + if (start != null || end != null) { + insertTag(TIME_RANGE) { + if (start != null) + attribute(null, TIME_RANGE_START, timeFormatUTC.format(start)) + if (end != null) + attribute(null, TIME_RANGE_END, timeFormatUTC.format(end)) + } + } + } + } + } + } + serializer.endDocument() + + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("REPORT", writer.toString().toRequestBody(MIME_XML)) + .header("Depth", "1") + .build()).execute() + }.use { + return processMultiStatus(it, callback) + } + } + + /** + * Sends a calendar-multiget REPORT to the resource. Received responses are sent + * to the callback, whether they are successful (2xx) or not. + * + * @param urls list of iCalendar URLs to be requested + * @param contentType MIME type of requested format; may be "text/calendar" for iCalendar or + * "application/calendar+json" for jCard. *null*: don't request specific representation type + * @param version Version subtype of the requested format, like "2.0" for iCalendar 2. *null*: don't request specific version + * @param callback called for every WebDAV response XML element in the result + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error + */ + fun multiget(urls: List, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List { + /* + */ + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", null) + serializer.setPrefix("", XmlUtils.NS_WEBDAV) + serializer.setPrefix("CAL", XmlUtils.NS_CALDAV) + serializer.insertTag(CALENDAR_MULTIGET) { + insertTag(PROP) { + insertTag(GetContentType.NAME) // to determine the character set + insertTag(GetETag.NAME) + insertTag(ScheduleTag.NAME) + insertTag(CalendarData.NAME) { + if (contentType != null) + attribute(null, CalendarData.CONTENT_TYPE, contentType) + if (version != null) + attribute(null, CalendarData.VERSION, version) + } + } + for (url in urls) + insertTag(HREF) { + serializer.text(url.encodedPath) + } + } + serializer.endDocument() + + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("REPORT", writer.toString().toRequestBody(MIME_XML)) + .build()).execute() + }.use { + return processMultiStatus(it, callback) + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt new file mode 100644 index 0000000..5dfcfab --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavCollection.kt @@ -0,0 +1,123 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.SyncToken +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.io.StringWriter +import java.util.logging.Logger + +/** + * Represents a WebDAV collection. + */ +open class DavCollection @JvmOverloads constructor( + httpClient: OkHttpClient, + location: HttpUrl, + log: Logger = Dav4jvm.log +): DavResource(httpClient, location, log) { + + companion object { + val SYNC_COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "sync-collection") + val SYNC_LEVEL = Property.Name(XmlUtils.NS_WEBDAV, "sync-level") + val LIMIT = Property.Name(XmlUtils.NS_WEBDAV, "limit") + val NRESULTS = Property.Name(XmlUtils.NS_WEBDAV, "nresults") + } + + /** + * Sends a POST request. Primarily intended to be used with an Add-Member URL (RFC 5995). + */ + @Throws(IOException::class, HttpException::class) + fun post(body: RequestBody, ifNoneMatch: Boolean = false, callback: ResponseCallback) { + followRedirects { + val builder = Request.Builder() + .post(body) + .url(location) + + if (ifNoneMatch) + // don't overwrite anything existing + builder.header("If-None-Match", "*") + + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } + } + + /** + * Sends a REPORT sync-collection request. + * + * @param syncToken sync-token to be sent with the request + * @param infiniteDepth sync-level to be sent with the request: false = "1", true = "infinite" + * @param limit maximum number of results (may cause truncation) + * @param properties WebDAV properties to be requested + * @param callback called for every WebDAV response XML element in the result + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements (like `sync-token` which is returned as [SyncToken]) + * + * @throws java.io.IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error + */ + fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List { + /* + + + + + + + + + */ + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", null) + serializer.setPrefix("", XmlUtils.NS_WEBDAV) + serializer.insertTag(SYNC_COLLECTION) { + insertTag(SyncToken.NAME) { + if (syncToken != null) + text(syncToken) + } + insertTag(SYNC_LEVEL) { + text(if (infiniteDepth) "infinite" else "1") + } + if (limit != null) + insertTag(LIMIT) { + insertTag(NRESULTS) { + text(limit.toString()) + } + } + insertTag(PROP) { + for (prop in properties) + insertTag(prop) + } + } + serializer.endDocument() + + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("REPORT", writer.toString().toRequestBody(MIME_XML)) + .header("Depth", "0") + .build()).execute() + }.use { + return processMultiStatus(it, callback) + } + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt new file mode 100644 index 0000000..ea20945 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/DavResource.kt @@ -0,0 +1,802 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.XmlUtils.insertTag +import at.bitfire.dav4jvm.XmlUtils.propertyName +import at.bitfire.dav4jvm.exception.* +import at.bitfire.dav4jvm.property.SyncToken +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.io.EOFException +import java.io.IOException +import java.io.Reader +import java.io.StringWriter +import java.net.HttpURLConnection +import java.util.logging.Level +import java.util.logging.Logger +import at.bitfire.dav4jvm.Response as DavResponse + +/** + * Represents a WebDAV resource at the given location and allows WebDAV + * requests to be performed on this resource. + * + * Requests are executed synchronously (blocking). If no error occurs, the given + * callback will be called. Otherwise, an exception is thrown. *These callbacks + * don't need to close the response.* + * + * To cancel a request, interrupt the thread. This will cause the requests to + * throw `InterruptedException` or `InterruptedIOException`. + * + * @param httpClient [OkHttpClient] to access this object (must not follow redirects) + * @param location location of the WebDAV resource + * @param log will be used for logging + */ +open class DavResource @JvmOverloads constructor( + val httpClient: OkHttpClient, + location: HttpUrl, + val log: Logger = Dav4jvm.log +) { + + companion object { + const val MAX_REDIRECTS = 5 + + const val HTTP_MULTISTATUS = 207 + + /** 301 and 308 — the two redirects a caller should persist. */ + const val HTTP_MOVED_PERM = 301 + const val HTTP_PERM_REDIRECT = 308 + val MIME_XML = "application/xml; charset=utf-8".toMediaType() + + val PROPFIND = Property.Name(XmlUtils.NS_WEBDAV, "propfind") + val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate") + val SET = Property.Name(XmlUtils.NS_WEBDAV, "set") + val REMOVE = Property.Name(XmlUtils.NS_WEBDAV, "remove") + val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop") + val HREF = Property.Name(XmlUtils.NS_WEBDAV, "href") + + val XML_SIGNATURE = ", + removeProperties: List + ): String { + // build XML request body + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.setPrefix("d", XmlUtils.NS_WEBDAV) + serializer.startDocument("UTF-8", null) + serializer.insertTag(PROPERTYUPDATE) { + // DAV:set + if (setProperties.isNotEmpty()) { + serializer.insertTag(SET) { + for (prop in setProperties) { + serializer.insertTag(PROP) { + serializer.insertTag(prop.key) { + text(prop.value) + } + } + } + } + } + + // DAV:remove + if (removeProperties.isNotEmpty()) { + serializer.insertTag(REMOVE) { + for (prop in removeProperties) { + insertTag(PROP) { + insertTag(prop) + } + } + } + } + } + + serializer.endDocument() + return writer.toString() + } + + } + + /** + * URL of this resource (changes when being redirected by server) + */ + /** + * The location reached through an unbroken chain of **permanent** redirects + * (301, 308), or null if none was followed. + * + * Upstream mutates [location] in place for every redirect and tells the + * caller nothing, so a caller cannot distinguish "this resource has moved, + * store the new URL" from "follow this once". DAVx5 consequently never + * rewrites a stored collection URL after a 301 and re-follows it on every + * single sync — bitfireAT/dav4jvm#209. Persisting the new URL ourselves is + * the fix. Local addition; see dav/PROVENANCE.md + * change 3. + */ + var permanentLocation: HttpUrl? = null + private set + + var location: HttpUrl + private set // allow internal modification only (for redirects) + + init { + // Don't follow redirects (only useful for GET/POST). + // This means we have to handle 30x responses ourselves. + require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" } + + this.location = location + } + + override fun toString() = location.toString() + + + /** + * Gets the file name of this resource. See [HttpUtils.fileName] for details. + */ + fun fileName() = HttpUtils.fileName(location) + + + /** + * Sends an OPTIONS request to this resource without HTTP compression (because some servers have + * broken compression for OPTIONS). Doesn't follow redirects. + * + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class) + fun options(callback: CapabilitiesCallback) { + httpClient.newCall(Request.Builder() + .method("OPTIONS", null) + .header("Content-Length", "0") + .url(location) + .header("Accept-Encoding", "identity") // disable compression + .build()).execute().use { response -> + checkStatus(response) + callback.onCapabilities( + HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(), + response + ) + } + } + + /** + * Sends a MOVE request to this resource. Follows up to [MAX_REDIRECTS] redirects. + * Updates [location] on success. + * + * @param destination where the resource shall be moved to + * @param forceOverride whether resources are overwritten when they already exist in destination + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error or HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class, DavException::class) + fun move(destination: HttpUrl, forceOverride: Boolean, callback: ResponseCallback) { + val requestBuilder = Request.Builder() + .method("MOVE", null) + .header("Content-Length", "0") + .header("Destination", destination.toString()) + + if (forceOverride) requestBuilder.header("Overwrite", "F") + + followRedirects { + requestBuilder.url(location) + httpClient.newCall(requestBuilder + .build()) + .execute() + }.use { response -> + checkStatus(response) + if (response.code == HTTP_MULTISTATUS) + /* Multiple resources were to be affected by the MOVE, but errors on some + of them prevented the operation from taking place. + [_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */ + throw HttpException(response) + + // update location + location.resolve(response.header("Location") ?: destination.toString())?.let { + location = it + } + + callback.onResponse(response) + } + } + + /** + * Sends a COPY request for this resource. Follows up to [MAX_REDIRECTS] redirects. + * + * @param destination where the resource shall be copied to + * @param forceOverride whether resources are overwritten when they already exist in destination + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error or HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class, DavException::class) + fun copy(destination:HttpUrl, forceOverride: Boolean, callback: ResponseCallback) { + val requestBuilder = Request.Builder() + .method("COPY", null) + .header("Content-Length", "0") + .header("Destination", destination.toString()) + + if (forceOverride) requestBuilder.header("Overwrite", "F") + + followRedirects { + requestBuilder.url(location) + httpClient.newCall(requestBuilder + .build()) + .execute() + }.use{ response -> + checkStatus(response) + + if (response.code == HTTP_MULTISTATUS) + /* Multiple resources were to be affected by the COPY, but errors on some + of them prevented the operation from taking place. + [_] (RFC 4918 9.8.5. Status Codes for COPY Method) */ + throw HttpException(response) + + callback.onResponse(response) + } + } + + /** + * Sends a MKCOL request to this resource. Follows up to [MAX_REDIRECTS] redirects. + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class) + fun mkCol(xmlBody: String?, callback: ResponseCallback) { + val rqBody = xmlBody?.toRequestBody(MIME_XML) + + followRedirects { + httpClient.newCall(Request.Builder() + .method("MKCOL", rqBody) + .url(location) + .build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } + } + + /** + * Sends a HEAD request to the resource. + * + * Follows up to [MAX_REDIRECTS] redirects. + * + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on HTTPS -> HTTP redirect + */ + fun head(callback: ResponseCallback) { + followRedirects { + httpClient.newCall( + Request.Builder() + .head() + .url(location) + .build() + ).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } + } + + /** + * Sends a GET request to the resource. Sends `Accept-Encoding: identity` to disable + * compression, because compression might change the ETag. + * + * Follows up to [MAX_REDIRECTS] redirects. + * + * @param accept value of `Accept` header (always sent for clarity; use */* if you don't care) + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on HTTPS -> HTTP redirect + */ + @Deprecated("Use get(accept, headers, callback) with explicit Accept-Encoding instead") + @Throws(IOException::class, HttpException::class) + fun get(accept: String, callback: ResponseCallback) = + get(accept, Headers.headersOf("Accept-Encoding", "identity"), callback) + + /** + * Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects. + * + * Note: Add `Accept-Encoding: identity` to [headers] if you want to disable compression + * (compression might change the returned ETag). + * + * @param accept value of `Accept` header (always sent for clarity; use */* if you don't care) + * @param headers additional headers to send with the request + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on HTTPS -> HTTP redirect + */ + fun get(accept: String, headers: Headers?, callback: ResponseCallback) { + followRedirects { + val request = Request.Builder() + .get() + .url(location) + + if (headers != null) + request.headers(headers) + + // always Accept header + request.header("Accept", accept) + + httpClient.newCall(request.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } + } + + /** + * Sends a GET request to the resource for a specific byte range. Make sure to check the + * response code: servers may return the whole resource with 200 or partials with 206. + * + * Follows up to [MAX_REDIRECTS] redirects. + * + * @param accept value of `Accept` header (always sent for clarity; use */* if you don't care) + * @param offset zero-based index of first byte to request + * @param size number of bytes to request + * @param headers additional headers to send with the request + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on high-level errors + */ + @Throws(IOException::class, HttpException::class) + fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) { + followRedirects { + val request = Request.Builder() + .get() + .url(location) + + if (headers != null) + request.headers(headers) + + val lastIndex = offset + size - 1 + request + .header("Accept", accept) + .header("Range", "bytes=$offset-$lastIndex") + + httpClient.newCall(request.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } + } + + /** + * Sends a PUT request to the resource. Follows up to [MAX_REDIRECTS] redirects. + * + * When the server returns an ETag, it is stored in response properties. + * + * @param body new resource body to upload + * @param ifETag value of `If-Match` header to set, or null to omit + * @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit + * @param ifNoneMatch indicates whether `If-None-Match: *` ("don't overwrite anything existing") header shall be sent + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class) + fun put(body: RequestBody, ifETag: String? = null, ifScheduleTag: String? = null, ifNoneMatch: Boolean = false, callback: ResponseCallback) { + followRedirects { + val builder = Request.Builder() + .put(body) + .url(location) + + if (ifETag != null) + // only overwrite specific version + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + if (ifScheduleTag != null) + // only overwrite specific version + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + if (ifNoneMatch) + // don't overwrite anything existing + builder.header("If-None-Match", "*") + + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + callback.onResponse(response) + } + } + + /** + * Sends a DELETE request to the resource. Warning: Sending this request to a collection will + * delete the collection with all its contents! + * + * Follows up to [MAX_REDIRECTS] redirects. + * + * @param ifETag value of `If-Match` header to set, or null to omit + * @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit + * @param callback called with server response unless an exception is thrown + * + * @throws IOException on I/O error + * @throws HttpException on HTTP errors, or when 207 Multi-Status is returned + * (because then there was probably a problem with a member resource) + * @throws DavException on HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class) + fun delete(ifETag: String? = null, ifScheduleTag: String? = null, callback: ResponseCallback) { + followRedirects { + val builder = Request.Builder() + .delete() + .url(location) + if (ifETag != null) + builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag)) + if (ifScheduleTag != null) + builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag)) + + httpClient.newCall(builder.build()).execute() + }.use { response -> + checkStatus(response) + + if (response.code == HTTP_MULTISTATUS) + /* If an error occurs deleting a member resource (a resource other than + the resource identified in the Request-URI), then the response can be + a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */ + throw HttpException(response) + + callback.onResponse(response) + } + } + + /** + * Sends a PROPFIND request to the resource. Expects and processes a 207 Multi-Status response. + * + * Follows up to [MAX_REDIRECTS] redirects. + * + * @param depth "Depth" header to send (-1 for `infinity`) + * @param reqProp properties to request + * @param callback called for every XML response element in the Multi-Status response + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect + */ + @Throws(IOException::class, HttpException::class, DavException::class) + fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) { + // build XML request body + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.setPrefix("", XmlUtils.NS_WEBDAV) + serializer.setPrefix("CAL", XmlUtils.NS_CALDAV) + serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV) + serializer.startDocument("UTF-8", null) + serializer.insertTag(PROPFIND) { + insertTag(PROP) { + for (prop in reqProp) + insertTag(prop) + } + } + serializer.endDocument() + + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("PROPFIND", writer.toString().toRequestBody(MIME_XML)) + .header("Depth", if (depth >= 0) depth.toString() else "infinity") + .build()).execute() + }.use { + processMultiStatus(it, callback) + } + } + + /** + * Sends a PROPPATCH request to the server in order to set and remove properties. + * + * @param setProperties map of properties that shall be set (values currently have to be strings) + * @param removeProperties list of names of properties that shall be removed + * @param callback called for every XML response element in the Multi-Status response + * + * Follows up to [MAX_REDIRECTS] redirects. + * + * Currently expects a 207 Multi-Status response although servers are allowed to + * return other values, too. + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect + */ + fun proppatch( + setProperties: Map, + removeProperties: List, + callback: MultiResponseCallback + ) { + followRedirects { + val rqBody = createProppatchXml(setProperties, removeProperties) + + httpClient.newCall( + Request.Builder() + .url(location) + .method("PROPPATCH", rqBody.toRequestBody(MIME_XML)) + .build() + ).execute() + }.use { + // TODO handle not only 207 Multi-Status + // http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status + + processMultiStatus(it, callback) + } + } + + /** + * Sends a SEARCH request (RFC 5323) with the given body to the server. + * + * Follows up to [MAX_REDIRECTS] redirects. Expects a 207 Multi-Status response. + * + * @param search search request body (XML format, DAV:searchrequest or DAV:query-schema-discovery) + * @param callback called for every XML response element in the Multi-Status response + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect + */ + fun search(search: String, callback: MultiResponseCallback) { + followRedirects { + httpClient.newCall(Request.Builder() + .url(location) + .method("SEARCH", search.toRequestBody(MIME_XML)) + .build()).execute() + }.use { + processMultiStatus(it, callback) + } + } + + + // status handling + + /** + * Checks the status from an HTTP response and throws an exception in case of an error. + * + * @throws HttpException in case of an HTTP error + */ + protected fun checkStatus(response: Response) = + checkStatus(response.code, response.message, response) + + /** + * Checks the status from an HTTP response and throws an exception in case of an error. + * + * @throws HttpException (with XML error names, if available) in case of an HTTP error + */ + private fun checkStatus(code: Int, message: String?, response: Response?) { + if (code / 100 == 2) + // everything OK + return + + throw when (code) { + HttpURLConnection.HTTP_UNAUTHORIZED -> + if (response != null) UnauthorizedException(response) else UnauthorizedException(message) + HttpURLConnection.HTTP_FORBIDDEN -> + if (response != null) ForbiddenException(response) else ForbiddenException(message) + HttpURLConnection.HTTP_NOT_FOUND -> + if (response != null) NotFoundException(response) else NotFoundException(message) + HttpURLConnection.HTTP_CONFLICT -> + if (response != null) ConflictException(response) else ConflictException(message) + HttpURLConnection.HTTP_PRECON_FAILED -> + if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message) + HttpURLConnection.HTTP_UNAVAILABLE -> + if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message) + else -> + if (response != null) HttpException(response) else HttpException(code, message) + } + } + + /** + * Send a request and follows up to [MAX_REDIRECTS] redirects. + * + * @param sendRequest called to send the request (may be called multiple times) + * + * @return response of the last request (whether it is a redirect or not) + * + * @throws DavException on HTTPS -> HTTP redirect + */ + internal fun followRedirects(sendRequest: () -> Response): Response { + lateinit var response: Response + // Describes the request being made now, not one made earlier through the + // same object. DavResource instances are reused, and a stale value would + // have the caller persist a URL that a later move() already superseded. + permanentLocation = null + // A permanent target is only permanent while every hop so far has been + // permanent: 301 -> 302 means the resource moved to the 301's target and + // is being served from elsewhere temporarily. + var chainStillPermanent = true + for (attempt in 1..MAX_REDIRECTS) { + response = sendRequest() + if (response.isRedirect) + // handle 3xx Redirection + response.use { + var target = it.header("Location")?.let { location.resolve(it) } + if (target != null) { + log.fine("Redirected, new location = $target") + + if (location.isHttps && !target.isHttps) { + // ⚠️ A downgrade to the *same host* is a server + // misconfiguration, not an attack, and it is the + // single most common one in this space: a Nextcloud + // behind a TLS-terminating proxy without + // `overwriteprotocol` (or `X-Forwarded-Proto`) builds + // every redirect with http://, including the + // /.well-known/caldav hop that discovery depends on. + // Refusing it outright makes a perfectly good server + // undiscoverable, and the user cannot tell why. + // + // Re-issuing the same host and path over TLS is + // strictly safer than what we were asked to do, and + // preserves the invariant that actually matters: + // credentials never travel in the clear. A downgrade + // pointing at a *different* host has no innocent + // reading, so that still fails. + if (target.host == location.host) { + target = target.newBuilder().scheme("https").build() + log.fine("Upgraded same-host downgrade back to $target") + } else { + throw DavException("Received redirect from HTTPS to HTTP") + } + } + + if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT)) + permanentLocation = target + else + chainStillPermanent = false + + location = target + } else + throw DavException("Redirected without new Location") + } + else + break + } + return response + } + + /** + * Validates a 207 Multi-Status response. + * + * @param response will be checked for Multi-Status response + * + * @throws DavException if the response is not a Multi-Status response + */ + fun assertMultiStatus(response: Response) { + if (response.code != HTTP_MULTISTATUS) + throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response) + + val body = response.body ?: + throw DavException("Received 207 Multi-Status without body", httpResponse = response) + + body.contentType()?.let { mimeType -> + if (((mimeType.type != "application" && mimeType.type != "text")) || mimeType.subtype != "xml") { + /* Content-Type is not application/xml or text/xml although that is expected here. + Some broken servers return an XML response with some other MIME type. So we try to see + whether the response is maybe XML although the Content-Type is something else. */ + try { + val firstBytes = ByteArray(XML_SIGNATURE.size) + body.source().peek().readFully(firstBytes) + if (XML_SIGNATURE.contentEquals(firstBytes)) { + Dav4jvm.log.warning("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType") + + // response is OK, return and do not throw Exception below + return + } + } catch (e: Exception) { + Dav4jvm.log.log(Level.WARNING, "Couldn't scan for XML signature", e) + } + + throw DavException("Received non-XML 207 Multi-Status", httpResponse = response) + } + } ?: log.warning("Received 207 Multi-Status without Content-Type, assuming XML") + } + + + // Multi-Status handling + + /** + * Processes a Multi-Status response. + * + * @param response response which is expected to contain a Multi-Status response + * @param callback called for every XML response element in the Multi-Status response + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements (like `sync-token` which is returned as [SyncToken]) + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error (for instance, when the response is not a Multi-Status response) + */ + protected fun processMultiStatus(response: Response, callback: MultiResponseCallback): List { + checkStatus(response) + assertMultiStatus(response) + response.body!!.use { + return processMultiStatus(it.charStream(), callback) + } + } + + /** + * Processes a Multi-Status response. + * + * @param reader the Multi-Status response is read from this + * @param callback called for every XML response element in the Multi-Status response + * + * @return list of properties which have been received in the Multi-Status response, but + * are not part of response XML elements (like `sync-token` which is returned as [SyncToken]) + * + * @throws IOException on I/O error + * @throws HttpException on HTTP error + * @throws DavException on WebDAV error (like an invalid XML response) + */ + protected fun processMultiStatus(reader: Reader, callback: MultiResponseCallback): List { + val responseProperties = mutableListOf() + val parser = XmlUtils.newPullParser() + + fun parseMultiStatus(): List { + // + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) + when (parser.propertyName()) { + DavResponse.RESPONSE -> + at.bitfire.dav4jvm.Response.parse(parser, location, callback) + SyncToken.NAME -> + XmlUtils.readText(parser)?.let { + responseProperties += SyncToken(it) + } + } + eventType = parser.next() + } + + return responseProperties + } + + try { + parser.setInput(reader) + + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.depth == 1) + if (parser.propertyName() == DavResponse.MULTISTATUS) + return parseMultiStatus() + // ignore further elements + eventType = parser.next() + } + + throw DavException("Multi-Status response didn't contain multistatus XML element") + + } catch (e: EOFException) { + throw DavException("Incomplete multistatus XML element", e) + } catch (e: XmlPullParserException) { + throw DavException("Couldn't parse multistatus XML element", e) + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt new file mode 100644 index 0000000..c98e41a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Error.kt @@ -0,0 +1,55 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.bitfire.dav4jvm + +import org.xmlpull.v1.XmlPullParser +import java.io.Serializable + +/** + * Represents an XML precondition/postcondition error. Every error has a name, which is the XML element + * name. Subclassed errors may have more specific information available. + * + * At the moment, there is no logic for subclassing errors. + */ +class Error( + val name: Property.Name +): Serializable { + + companion object { + + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "error") + + fun parseError(parser: XmlPullParser): List { + val names = mutableSetOf() + + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) + names += Property.Name(parser.namespace, parser.name) + eventType = parser.next() + } + + return names.map { Error(it) } + } + + + // some pre-defined errors + + val NEED_PRIVILEGES = Error(Property.Name(XmlUtils.NS_WEBDAV, "need-privileges")) + val VALID_SYNC_TOKEN = Error(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token")) + + } + + override fun equals(other: Any?) = + (other is Error) && other.name == name + + override fun hashCode() = name.hashCode() + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt new file mode 100644 index 0000000..6fc337d --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/HttpUtils.kt @@ -0,0 +1,144 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import okhttp3.HttpUrl +import okhttp3.Response +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.* + + +object HttpUtils { + + const val httpDateFormatStr = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + + /** + * RFC 7231 §7.1.1.1: an HTTP-date is always GMT. + * + * ⚠️ Upstream never set this, and the `GMT` in [httpDateFormatStr] is a + * quoted *literal* — so both formatting and parsing silently used the + * device's default zone, putting every `getlastmodified` out by the local + * UTC offset. Upstream's suite does not test date handling at all. Local + * fix; see dav/PROVENANCE.md change 2. + */ + private val GMT: TimeZone = TimeZone.getTimeZone("GMT") + + /** + * Built per call rather than shared. `SimpleDateFormat` is mutable and not + * thread-safe, and upstream exposed one instance publicly: two sync workers + * formatting a header concurrently corrupt each other through its `Calendar`, + * and any caller could `setTimeZone` on it and undo the fix above for + * everyone. `:dav` is earmarked for floret-kit, where concurrent workers are + * the normal case. Local change; see dav/PROVENANCE.md change 5. + * + * ⚠️ `Locale.US`, not `Locale.ROOT`. On a desktop JVM the two agree, but + * Android's ICU root locale abbreviates months as `M10`, so every date + * formatted on a device was unparseable. See change 8. + */ + private fun httpDateFormat() = SimpleDateFormat(httpDateFormatStr, Locale.US).apply { + timeZone = GMT + } + + /** + * Gets the resource name (the last segment of the path) from an URL. + * Empty if the resource is the base directory. + * + * * `dir` for `https://example.com/dir/` + * * `file` for `https://example.com/file` + * * `` for `https://example.com` or `https://example.com/` + * + * @return resource name + */ + fun fileName(url: HttpUrl): String { + val pathSegments = url.pathSegments.dropLastWhile { it == "" } + return pathSegments.lastOrNull() ?: "" + } + + fun listHeader(response: Response, name: String): Array { + val value = response.headers(name).joinToString(",") + return value.split(',').filter { it.isNotEmpty() }.toTypedArray() + } + + + /** + * Formats a date for use in HTTP headers. + * + * @param date date to be formatted + * @return date in HTTP-date format + */ + fun formatDate(date: Date): String = httpDateFormat().format(date) + + /** + * Parses a HTTP-date. + * + * @param dateStr date with format specified by RFC 7231 section 7.1.1.1 + * or in one of the obsolete formats (copied from okhttp internal date-parsing class) + * + * @return date, or null if date could not be parsed + */ + /** + * The formats [parseDate] accepts, in order. RFC 7231 §7.1.1.1 first, then the + * obsolete ones, then the malformed-but-real ones — copied from OkHttp's + * internal date parser, which is where upstream took them from. + */ + private val dateFormats = arrayOf( + httpDateFormatStr, + "EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 822, updated by RFC 1123 with any TZ + "EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850, obsoleted by RFC 1036 with any TZ + "EEE MMM d HH:mm:ss yyyy", // ANSI C's asctime() format + // Alternative formats. + "EEE, dd-MMM-yyyy HH:mm:ss z", + "EEE, dd-MMM-yyyy HH-mm-ss z", + "EEE, dd MMM yy HH:mm:ss z", + "EEE dd-MMM-yyyy HH:mm:ss z", + "EEE dd MMM yyyy HH:mm:ss z", + "EEE dd-MMM-yyyy HH-mm-ss z", + "EEE dd-MMM-yy HH:mm:ss z", + "EEE dd MMM yy HH:mm:ss z", + "EEE,dd-MMM-yy HH:mm:ss z", + "EEE,dd-MMM-yyyy HH:mm:ss z", + "EEE, dd-MM-yyyy HH:mm:ss z", + /* RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com */ + "EEE MMM d yyyy HH:mm:ss z", + ) + + /** + * Parses an HTTP-date. + * + * @param dateStr date in the format of RFC 7231 §7.1.1.1, or one of the + * obsolete formats + * + * @return the date, or null if none of the formats matched + */ + fun parseDate(dateStr: String): Date? { + // Upstream used commons-lang3 DateUtils.parseDate for exactly this loop, + // and that import was the library's only use of the dependency. See + // dav/PROVENANCE.md change 1. + for (format in dateFormats) { + val parser = SimpleDateFormat(format, Locale.US).apply { + // GMT is the default for the formats that carry no zone — the + // quoted-literal 'GMT' one and ANSI C's asctime(). The patterns + // with a real `z` still take the zone from the input. + timeZone = GMT + // The obsolete formats carry two-digit years, and lenient parsing + // is what lets "dd MMM yy" resolve at all. + isLenient = true + } + val position = ParsePosition(0) + val parsed = parser.parse(dateStr, position) + // ⚠️ The whole string must be consumed. `DateUtils.parseDate` enforced + // this and `SimpleDateFormat.parse` does not: pattern 1 ends in the + // *literal* 'GMT', so "…07:28:00 GMT+02:00" matches it as a prefix and + // the offset is silently discarded — reintroducing the exact two-hour + // error change 2 exists to fix. + if (parsed != null && position.index == dateStr.length) return parsed + } + return null + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt new file mode 100644 index 0000000..010d7dd --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropStat.kt @@ -0,0 +1,69 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.Response.Companion.STATUS +import at.bitfire.dav4jvm.XmlUtils.propertyName +import okhttp3.Protocol +import okhttp3.internal.http.StatusLine +import org.xmlpull.v1.XmlPullParser +import java.net.ProtocolException +import java.util.* + +/** + * Represents a WebDAV propstat XML element. + * + * + */ +data class PropStat( + val properties: List, + val status: StatusLine, + val error: List? = null +) { + + companion object { + + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "propstat") + + private val ASSUMING_OK = StatusLine(Protocol.HTTP_1_1, 200, "Assuming OK") + private val INVALID_STATUS = StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line") + + fun parse(parser: XmlPullParser): PropStat { + val depth = parser.depth + + var status: StatusLine? = null + val prop = LinkedList() + + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) + when (parser.propertyName()) { + DavResource.PROP -> + prop.addAll(Property.parse(parser)) + STATUS -> + status = try { + StatusLine.parse(parser.nextText()) + } catch (e: ProtocolException) { + // invalid status line, treat as 500 Internal Server Error + INVALID_STATUS + } + } + eventType = parser.next() + } + + return PropStat(prop, status ?: ASSUMING_OK) + } + + } + + + fun isSuccess() = status.code/100 == 2 + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt new file mode 100644 index 0000000..6a7209a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Property.kt @@ -0,0 +1,59 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.Dav4jvm.log +import org.xmlpull.v1.XmlPullParser +import java.io.Serializable +import java.util.* + +/** + * Represents a WebDAV property. + * + * Every [Property] must define a static field (use `@JvmStatic`) called `NAME` of type [Property.Name], + * which will be accessed by reflection. + */ +interface Property { + + data class Name( + val namespace: String, + val name: String + ): Serializable { + + override fun toString() = "$namespace:$name" + + } + + companion object { + + fun parse(parser: XmlPullParser): List { + // + val depth = parser.depth + val properties = LinkedList() + + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) { + val depthBeforeParsing = parser.depth + val name = Property.Name(parser.namespace, parser.name) + val property = PropertyRegistry.create(name, parser) + assert(parser.depth == depthBeforeParsing) + + if (property != null) { + properties.add(property) + } else + log.fine("Ignoring unknown property $name") + } + eventType = parser.next() + } + + return properties + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt new file mode 100644 index 0000000..699e6f4 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyFactory.kt @@ -0,0 +1,26 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException + +interface PropertyFactory { + + /** + * Name of the Property the factory creates, + * e.g. Property.Name("DAV:", "displayname") if the factory creates DisplayName objects) + */ + fun getName(): Property.Name + + /** + * Parses XML of a property and returns its data class. + * @throws XmlPullParserException in case of parsing errors + */ + fun create(parser: XmlPullParser): Property? + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt new file mode 100644 index 0000000..8d7d978 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/PropertyRegistry.kt @@ -0,0 +1,100 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.property.* +import at.bitfire.dav4jvm.property.push.PushTransports +import at.bitfire.dav4jvm.property.push.Topic +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.util.logging.Level + +object PropertyRegistry { + + private val factories = mutableMapOf() + + init { + Dav4jvm.log.info("Registering DAV property factories") + registerDefaultFactories() + } + + private fun registerDefaultFactories() { + register(listOf( + AddMember.Factory, + AddressbookDescription.Factory, + AddressbookHomeSet.Factory, + AddressData.Factory, + CalendarColor.Factory, + CalendarData.Factory, + CalendarDescription.Factory, + CalendarHomeSet.Factory, + CalendarProxyReadFor.Factory, + CalendarProxyWriteFor.Factory, + CalendarTimezone.Factory, + CalendarUserAddressSet.Factory, + CreationDate.Factory, + CurrentUserPrincipal.Factory, + CurrentUserPrivilegeSet.Factory, + DisplayName.Factory, + GetContentLength.Factory, + GetContentType.Factory, + GetCTag.Factory, + GetETag.Factory, + GetLastModified.Factory, + GroupMembership.Factory, + MaxICalendarSize.Factory, + MaxVCardSize.Factory, + Owner.Factory, + QuotaAvailableBytes.Factory, + QuotaUsedBytes.Factory, + ResourceType.Factory, + ScheduleTag.Factory, + Source.Factory, + SupportedAddressData.Factory, + SupportedCalendarComponentSet.Factory, + SupportedCalendarData.Factory, + SupportedReportSet.Factory, + SyncToken.Factory, + // WebDAV-Push, backported: see dav/PROVENANCE.md change 7. + PushTransports.Factory, + Topic.Factory + )) + } + + + /** + * Registers a property factory, so that objects for all WebDAV properties which are handled + * by this factory can be created. + * + * @param factory property factory to be registered + */ + fun register(factory: PropertyFactory) { + Dav4jvm.log.fine("Registering ${factory::class.java.name} for ${factory.getName()}") + factories[factory.getName()] = factory + } + + /** + * Registers some property factories, so that objects for all WebDAV properties which are handled + * by these factories can be created. + + * @param factories property factories to be registered + */ + fun register(factories: Iterable) { + factories.forEach { + register(it) + } + } + + fun create(name: Property.Name, parser: XmlPullParser) = + try { + factories[name]?.create(parser) + } catch (e: XmlPullParserException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse $name", e) + null + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt new file mode 100644 index 0000000..e3a516e --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/QuotedStringUtils.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +object QuotedStringUtils { + + fun asQuotedString(raw: String) = + "\"" + raw.replace("\\" ,"\\\\").replace("\"", "\\\"") + "\"" + + fun decodeQuotedString(quoted: String): String { + /* quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + qdtext = > + quoted-pair = "\" CHAR + */ + + val len = quoted.length + if (len >= 2 && quoted[0] == '"' && quoted[len-1] == '"') { + val result = StringBuffer(len) + var pos = 1 + while (pos < len-1) { + var c = quoted[pos] + if (c == '\\' && pos != len-2) + c = quoted[++pos] + result.append(c) + pos++ + } + return result.toString() + } else + return quoted + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt new file mode 100644 index 0000000..6dd4e1f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/Response.kt @@ -0,0 +1,222 @@ +/* + * Copyright © Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.Dav4jvm.log +import at.bitfire.dav4jvm.XmlUtils.propertyName +import at.bitfire.dav4jvm.property.ResourceType +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Protocol +import okhttp3.internal.http.StatusLine +import org.xmlpull.v1.XmlPullParser +import java.net.ProtocolException + +/** + * Represents a WebDAV response XML Element. + * + * + */ +data class Response( + /** + * URL of the requested resource. For instance, if `this` is a result + * of a PROPFIND request, the `requestedUrl` would be the URL where the + * PROPFIND request has been sent to (usually the collection URL). + */ + val requestedUrl: HttpUrl, + + /** + * URL of this response (`href` element) + */ + val href: HttpUrl, + + /** + * status of this response (`status` XML element) + */ + val status: StatusLine?, + + /** + * property/status elements (`propstat` XML elements) + */ + val propstat: List, + + /** + * list of precondition/postcondition elements (`error` XML elements) + */ + val error: List? = null, + + /** + * new location of this response (`location` XML element), used for redirects + */ + val newLocation: HttpUrl? = null +) { + + enum class HrefRelation { + SELF, MEMBER, OTHER + } + + /** + * All properties from propstat elements with empty status or status code 2xx. + */ + val properties: List by lazy { + if (isSuccess()) + propstat.filter { it.isSuccess() }.map { it.properties }.flatten() + else + emptyList() + } + + /** + * Convenience method to get a certain property with empty status or status code 2xx + * from the current response. + */ + operator fun get(clazz: Class) = + properties.filterIsInstance(clazz).firstOrNull() + + /** + * Returns whether the request was successful. + * + * @return true: no status XML element or status code 2xx; false: otherwise + */ + fun isSuccess() = status == null || status.code/100 == 2 + + /** + * Returns the name (last path segment) of the resource. + */ + fun hrefName() = HttpUtils.fileName(href) + + + companion object { + + val RESPONSE = Property.Name(XmlUtils.NS_WEBDAV, "response") + val MULTISTATUS = Property.Name(XmlUtils.NS_WEBDAV, "multistatus") + val STATUS = Property.Name(XmlUtils.NS_WEBDAV, "status") + val LOCATION = Property.Name(XmlUtils.NS_WEBDAV, "location") + + /** + * Parses an XML response element. + */ + fun parse(parser: XmlPullParser, location: HttpUrl, callback: MultiResponseCallback) { + val depth = parser.depth + + var href: HttpUrl? = null + var status: StatusLine? = null + val propStat = mutableListOf() + var error: List? = null + var newLocation: HttpUrl? = null + + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1) + when (parser.propertyName()) { + DavResource.HREF -> { + var sHref = parser.nextText() + if (!sHref.startsWith("/")) { + /* According to RFC 4918 8.3 URL Handling, only absolute paths are allowed as relative + URLs. However, some servers reply with relative paths. */ + val firstColon = sHref.indexOf(':') + if (firstColon != -1) { + /* There are some servers which return not only relative paths, but relative paths like "a:b.vcf", + which would be interpreted as scheme: "a", scheme-specific part: "b.vcf" normally. + For maximum compatibility, we prefix all relative paths which contain ":" (but not "://"), + with "./" to allow resolving by HttpUrl. */ + var hierarchical = false + try { + if (sHref.substring(firstColon, firstColon + 3) == "://") + hierarchical = true + } catch (e: IndexOutOfBoundsException) { + // no "://" + } + if (!hierarchical) + sHref = "./$sHref" + } + } + href = location.resolve(sHref) + } + STATUS -> + status = try { + StatusLine.parse(parser.nextText()) + } catch(e: ProtocolException) { + log.warning("Invalid status line, treating as HTTP error 500") + StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line") + } + PropStat.NAME -> + PropStat.parse(parser).let { propStat += it } + Error.NAME -> + error = Error.parseError(parser) + LOCATION -> + newLocation = parser.nextText().toHttpUrlOrNull() + } + eventType = parser.next() + } + + if (href == null) { + log.warning("Ignoring XML response element without valid href") + return + } + + // if we know this resource is a collection, make sure href has a trailing slash + // (for clarity and resolving relative paths) + propStat.filter { it.isSuccess() } + .map { it.properties } + .filterIsInstance(ResourceType::class.java) + .firstOrNull() + ?.let { type -> + if (type.types.contains(ResourceType.COLLECTION)) + href = UrlUtils.withTrailingSlash(href!!) + } + + //log.log(Level.FINE, "Received properties for $href", if (status != null) status else propStat) + + // Which resource does this represent? + val relation = when { + UrlUtils.equals(UrlUtils.omitTrailingSlash(href!!), UrlUtils.omitTrailingSlash(location)) -> + HrefRelation.SELF + else -> { + if (location.scheme == href!!.scheme && location.host == href!!.host && location.port == href!!.port) { + val locationSegments = location.pathSegments + val hrefSegments = href!!.pathSegments + + // don't compare trailing slash segment ("") + var nBasePathSegments = locationSegments.size + if (locationSegments[nBasePathSegments-1] == "") + nBasePathSegments-- + + /* example: locationSegments = [ "davCollection", "" ] + nBasePathSegments = 1 + hrefSegments = [ "davCollection", "aMember" ] + */ + var relation = HrefRelation.OTHER + if (hrefSegments.size > nBasePathSegments) { + val sameBasePath = (0 until nBasePathSegments).none { locationSegments[it] != hrefSegments[it] } + if (sameBasePath) + relation = HrefRelation.MEMBER + } + + relation + } else + HrefRelation.OTHER + } + } + + callback.onResponse( + Response( + location, + href!!, + status, + propStat, + error, + newLocation + ), + relation) + } + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt new file mode 100644 index 0000000..f6feed7 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/UrlUtils.kt @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import okhttp3.HttpUrl +import java.net.URI +import java.net.URISyntaxException + +object UrlUtils { + + /** + * Compares two URLs in WebDAV context. If two URLs are considered *equal*, both + * represent the same WebDAV resource (e.g. `http://host:80/folder1` and `http://HOST/folder1#somefragment`). + * + * It decodes %xx entities in the path, so `/my@dav` and `/my%40dav` are considered the same. + * This is important to process multi-status responses: some servers serve a multi-status + * response with href `/my@dav` when you request `/my%40dav` and vice versa. + * + * This method does not deal with trailing slashes, so if you want to compare collection URLs, + * make sure they both (don't) have a trailing slash before calling this method, for instance + * with [omitTrailingSlash] or [withTrailingSlash]. + * + * @param url1 the first URL to be compared + * @param url2 the second URL to be compared + * + * @return whether [url1] and [url2] (usually) represent the same WebDAV resource + */ + fun equals(url1: HttpUrl, url2: HttpUrl): Boolean { + // if okhttp thinks the two URLs are equal, they're in any case + // (and it's a simple String comparison) + if (url1 == url2) + return true + + // drop #fragment parts and convert to URI + val uri1 = url1.newBuilder().fragment(null).build().toUri() + val uri2 = url2.newBuilder().fragment(null).build().toUri() + + return try { + val decoded1 = URI(uri1.scheme, uri1.schemeSpecificPart, uri1.fragment) + val decoded2 = URI(uri2.scheme, uri2.schemeSpecificPart, uri2.fragment) + decoded1 == decoded2 + } catch (e: URISyntaxException) { + false + } + } + + /** + * Gets the first-level domain name (without subdomains) from a host name. + * Also removes trailing dots. + * + * @param host name (e.g. `www.example.com.`) + * + * @return domain name (e.g. `example.com`) + */ + fun hostToDomain(host: String?): String? { + if (host == null) + return null + + // remove optional dot at end + val withoutTrailingDot = host.removeSuffix(".") + + // split into labels + val labels = withoutTrailingDot.split('.') + return if (labels.size >= 2) { + labels[labels.size - 2] + "." + labels[labels.size - 1] + } else + withoutTrailingDot + } + + /** + * Ensures that a given URL doesn't have a trailing slash after member names. + * If the path is the root path (`/`), the slash is preserved. + * + * @param url URL to process (e.g. 'http://host/test1/') + * + * @return URL without trailing slash (except when the path is the root path), e.g. `http://host/test1` + */ + fun omitTrailingSlash(url: HttpUrl): HttpUrl { + val idxLast = url.pathSize - 1 + val hasTrailingSlash = url.pathSegments[idxLast] == "" + + return if (hasTrailingSlash) + url.newBuilder().removePathSegment(idxLast).build() + else + url + } + + /** + * Ensures that a given URL has a trailing slash after member names. + * + * @param url URL to process (e.g. 'http://host/test1') + * + * @return URL with trailing slash, e.g. `http://host/test1/` + */ + fun withTrailingSlash(url: HttpUrl): HttpUrl { + val idxLast = url.pathSize - 1 + val hasTrailingSlash = url.pathSegments[idxLast] == "" + + return if (hasTrailingSlash) + url + else + url.newBuilder().addPathSegment("").build() + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt new file mode 100644 index 0000000..d128e6a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/XmlUtils.kt @@ -0,0 +1,102 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import org.xmlpull.v1.XmlPullParserFactory +import org.xmlpull.v1.XmlSerializer +import java.io.IOException + +object XmlUtils { + + const val NS_WEBDAV = "DAV:" + const val NS_CALDAV = "urn:ietf:params:xml:ns:caldav" + const val NS_CARDDAV = "urn:ietf:params:xml:ns:carddav" + const val NS_APPLE_ICAL = "http://apple.com/ns/ical/" + const val NS_CALENDARSERVER = "http://calendarserver.org/ns/" + + private val factory: XmlPullParserFactory + init { + try { + factory = XmlPullParserFactory.newInstance() + factory.isNamespaceAware = true + } catch (e: XmlPullParserException) { + throw RuntimeException("Couldn't create XmlPullParserFactory", e) + } + } + + fun newPullParser() = factory.newPullParser()!! + fun newSerializer() = factory.newSerializer()!! + + + @Throws(IOException::class, XmlPullParserException::class) + fun processTag(parser: XmlPullParser, name: Property.Name, processor: () -> Unit) { + val depth = parser.depth + var eventType = parser.eventType + while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name) + processor() + eventType = parser.next() + } + } + + @Throws(IOException::class, XmlPullParserException::class) + fun readText(parser: XmlPullParser): String? { + var text: String? = null + + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.TEXT && parser.depth == depth) + text = parser.text + eventType = parser.next() + } + + return text + } + + @Throws(IOException::class, XmlPullParserException::class) + fun readTextProperty(parser: XmlPullParser, name: Property.Name): String? { + val depth = parser.depth + var eventType = parser.eventType + var result: String? = null + while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name) + result = parser.nextText() + eventType = parser.next() + } + return result + } + + @Throws(IOException::class, XmlPullParserException::class) + fun readTextPropertyList(parser: XmlPullParser, name: Property.Name, list: MutableCollection) { + val depth = parser.depth + var eventType = parser.eventType + while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name) + list.add(parser.nextText()) + eventType = parser.next() + } + } + + + fun XmlSerializer.insertTag(name: Property.Name, contentGenerator: XmlSerializer.() -> Unit = {}) { + startTag(name.namespace, name.name) + contentGenerator(this) + endTag(name.namespace, name.name) + } + + fun XmlPullParser.propertyName(): Property.Name { + val propNs = namespace + val propName = name + if (propNs == null || propName == null) + throw IllegalStateException("Current event must be START_TAG or END_TAG") + return Property.Name(propNs, propName) + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt new file mode 100644 index 0000000..2477a77 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ConflictException.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response +import java.net.HttpURLConnection + +class ConflictException: HttpException { + + constructor(response: Response): super(response) + constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt new file mode 100644 index 0000000..60d129f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/DavException.kt @@ -0,0 +1,144 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Error +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.propertyName +import okhttp3.MediaType +import okhttp3.Response +import okio.Buffer +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.Serializable +import java.lang.Long.min +import java.util.logging.Level + +/** + * Signals that an error occurred during a WebDAV-related operation. + * + * This could be a logical error like when a required ETag has not been + * received, but also an explicit HTTP error. + */ +open class DavException @JvmOverloads constructor( + message: String, + ex: Throwable? = null, + + /** + * An associated HTTP [Response]. Will be closed after evaluation. + */ + httpResponse: Response? = null +): Exception(message, ex), Serializable { + + companion object { + + const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB + + fun isPlainText(type: MediaType) = + type.type == "text" || + (type.type == "application" && type.subtype in arrayOf("html", "xml")) + + } + + var request: String? = null + + /** + * Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available + * if the HTTP request body was textual content and could be read again. + */ + var requestBody: String? = null + private set + + val response: String? + + /** + * Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available + * if the HTTP response body was textual content. + */ + var responseBody: String? = null + private set + + /** + * Precondition/postcondition XML elements which have been found in the XML response. + */ + var errors: List = listOf() + private set + + + init { + if (httpResponse != null) { + response = httpResponse.toString() + + try { + request = httpResponse.request.toString() + + httpResponse.request.body?.let { body -> + body.contentType()?.let { type -> + if (isPlainText(type)) { + val buffer = Buffer() + body.writeTo(buffer) + + val baos = ByteArrayOutputStream() + buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong())) + + requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name()) + } + } + } + } catch (e: Exception) { + Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP request", e) + requestBody = "Couldn't read HTTP request: ${e.message}" + } + + try { + // save response body excerpt + if (httpResponse.body?.source() != null) { + // response body has a source + + httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body -> + body.contentType()?.let { mimeType -> + if (isPlainText(mimeType)) + responseBody = body.string() + } + } + + httpResponse.body?.use { body -> + body.contentType()?.let { + if (it.type in arrayOf("application", "text") && it.subtype == "xml") { + // look for precondition/postcondition XML elements + try { + val parser = XmlUtils.newPullParser() + parser.setInput(body.charStream()) + + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.depth == 1) + if (parser.propertyName() == Error.NAME) + errors = Error.parseError(parser) + eventType = parser.next() + } + } catch (e: XmlPullParserException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse XML response", e) + } + } + } + } + } + } catch (e: IOException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP response", e) + responseBody = "Couldn't read HTTP response: ${e.message}" + } finally { + httpResponse.body?.close() + } + } else + response = null + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt new file mode 100644 index 0000000..23580a8 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ForbiddenException.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response +import java.net.HttpURLConnection + +class ForbiddenException: HttpException { + + constructor(response: Response): super(response) + constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt new file mode 100644 index 0000000..01460f5 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/GoneException.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response +import java.net.HttpURLConnection + +class GoneException: HttpException { + + constructor(response: Response): super(response) + constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt new file mode 100644 index 0000000..97422fa --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/HttpException.kt @@ -0,0 +1,29 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response + +/** + * Signals that a HTTP error was sent by the server. + */ +open class HttpException: DavException { + + var code: Int + + constructor(response: Response): super( + "HTTP ${response.code} ${response.message}", + httpResponse = response + ) { + code = response.code + } + + constructor(code: Int, message: String?): super("HTTP $code $message") { + this.code = code + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt new file mode 100644 index 0000000..1277530 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/NotFoundException.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response +import java.net.HttpURLConnection + +class NotFoundException: HttpException { + + constructor(response: Response): super(response) + constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt new file mode 100644 index 0000000..9e980e6 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/PreconditionFailedException.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response +import java.net.HttpURLConnection + +class PreconditionFailedException: HttpException { + + constructor(response: Response): super(response) + constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt new file mode 100644 index 0000000..af2cd62 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableException.kt @@ -0,0 +1,42 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.HttpUtils +import okhttp3.Response +import java.net.HttpURLConnection +import java.util.* + +class ServiceUnavailableException: HttpException { + + var retryAfter: Date? = null + + constructor(message: String?): super(HttpURLConnection.HTTP_UNAVAILABLE, message) + + constructor(response: Response): super(response) { + // Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds ) + // HTTP-date = rfc1123-date | rfc850-date | asctime-date + + response.header("Retry-After")?.let { after -> + retryAfter = HttpUtils.parseDate(after) ?: + // not a HTTP-date, must be delta-seconds + try { + val seconds = Integer.parseInt(after) + + val cal = Calendar.getInstance() + cal.add(Calendar.SECOND, seconds) + cal.time + + } catch (ignored: NumberFormatException) { + Dav4jvm.log.warning("Received Retry-After which was not a HTTP-date nor delta-seconds: $after") + null + } + } + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt new file mode 100644 index 0000000..cd3d0eb --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/exception/UnauthorizedException.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.Response +import java.net.HttpURLConnection + +class UnauthorizedException: HttpException { + + constructor(response: Response): super(response) + constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message) + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt new file mode 100644 index 0000000..4fd5e80 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddMember.kt @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +/** + * Defined in RFC 5995 3.2.1 DAV:add-member Property (Protected). + */ +data class AddMember( + val href: String? +): Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "add-member") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + AddMember(XmlUtils.readTextProperty(parser, DavResource.HREF)) + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt new file mode 100644 index 0000000..e8cc5a2 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressData.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class AddressData( + val card: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CARDDAV, "address-data") + + // attributes + const val CONTENT_TYPE = "content-type" + const val VERSION = "version" + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + AddressData(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt new file mode 100644 index 0000000..6d892af --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookDescription.kt @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class AddressbookDescription( + var description: String? = null +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-description") + } + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + AddressbookDescription(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt new file mode 100644 index 0000000..3f89342 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/AddressbookHomeSet.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class AddressbookHomeSet: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-home-set") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, AddressbookHomeSet()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt new file mode 100644 index 0000000..38ffc74 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarColor.kt @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.logging.Level +import java.util.regex.Pattern + +data class CalendarColor( + val color: Int +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color") + + private val PATTERN = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?")!! + + /** + * Converts a WebDAV color from one of these formats: + * #RRGGBB (alpha = 0xFF) + * RRGGBB (alpha = 0xFF) + * #RRGGBBAA + * RRGGBBAA + * to an [Int] with alpha. + */ + @Throws(IllegalArgumentException::class) + fun parseARGBColor(davColor: String): Int { + val m = PATTERN.matcher(davColor) + if (m.find()) { + val color_rgb = Integer.parseInt(m.group(1), 16) + val color_alpha = m.group(2)?.let { Integer.parseInt(m.group(2), 16) and 0xFF } ?: 0xFF + return (color_alpha shl 24) or color_rgb + } else + throw IllegalArgumentException("Couldn't parse color value: $davColor") + } + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): CalendarColor? { + XmlUtils.readText(parser)?.let { + try { + return CalendarColor(parseARGBColor(it)) + } catch (e: IllegalArgumentException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse color, ignoring", e) + } + } + return null + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt new file mode 100644 index 0000000..59fdecb --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarData.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class CalendarData( + val iCalendar: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-data") + + // attributes + const val CONTENT_TYPE = "content-type" + const val VERSION = "version" + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + CalendarData(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt new file mode 100644 index 0000000..c8b9e5f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarDescription.kt @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class CalendarDescription( + val description: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-description") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + CalendarDescription(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt new file mode 100644 index 0000000..4ca776e --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarHomeSet.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class CalendarHomeSet: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-home-set") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, CalendarHomeSet()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt new file mode 100644 index 0000000..2a74dee --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyReadFor.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class CalendarProxyReadFor: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-read-for") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, CalendarProxyReadFor()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt new file mode 100644 index 0000000..dcd3834 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarProxyWriteFor.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class CalendarProxyWriteFor: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-write-for") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, CalendarProxyWriteFor()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt new file mode 100644 index 0000000..c86d524 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarTimezone.kt @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class CalendarTimezone( + val vTimeZone: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-timezone") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + CalendarTimezone(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt new file mode 100644 index 0000000..5c2c128 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CalendarUserAddressSet.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class CalendarUserAddressSet: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-user-address-set") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, CalendarUserAddressSet()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt new file mode 100644 index 0000000..e64da0c --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CreationDate.kt @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class CreationDate( + var creationDate: String +): Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "creationdate") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): CreationDate? { + XmlUtils.readText(parser)?.let { it -> + return CreationDate(it) + } + return null + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt new file mode 100644 index 0000000..0ee5fab --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrincipal.kt @@ -0,0 +1,66 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.propertyName +import org.xmlpull.v1.XmlPullParser + +// see RFC 5397: WebDAV Current Principal Extension + +data class CurrentUserPrincipal( + val href: String?, + /** + * RFC 5397 §3: the server answered 200 but the request was **not** + * authenticated. Upstream parsed only the `` child, so this arrived + * indistinguishable from a conformant-but-empty element and from a server + * that simply omits the property — and a rejected credential then looks + * like a successful discovery that found nothing. Local addition; see + * dav/PROVENANCE.md change 6. + */ + val unauthenticated: Boolean = false +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal") + + val UNAUTHENTICATED = Property.Name(XmlUtils.NS_WEBDAV, "unauthenticated") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): CurrentUserPrincipal { + // + // One pass over both children: XmlUtils.processTag consumes to the end + // tag, so it cannot be called twice on the same element. + var href: String? = null + var unauthenticated = false + + val depth = parser.depth + var eventType = parser.eventType + while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && + parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) + when (parser.propertyName()) { + DavResource.HREF -> href = XmlUtils.readText(parser) + UNAUTHENTICATED -> unauthenticated = true + } + eventType = parser.next() + } + return CurrentUserPrincipal(href, unauthenticated) + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt new file mode 100644 index 0000000..53a5431 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/CurrentUserPrivilegeSet.kt @@ -0,0 +1,88 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.propertyName +import org.xmlpull.v1.XmlPullParser + +data class CurrentUserPrivilegeSet( + // not all privileges from RFC 3744 are implemented by now + // feel free to add more if you need them for your project + var mayRead: Boolean = false, + var mayWriteProperties: Boolean = false, + var mayWriteContent: Boolean = false, + var mayBind: Boolean = false, + var mayUnbind: Boolean = false +): Property { + + companion object { + + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-privilege-set") + + val PRIVILEGE = Property.Name(XmlUtils.NS_WEBDAV, "privilege") + val READ = Property.Name(XmlUtils.NS_WEBDAV, "read") + val WRITE = Property.Name(XmlUtils.NS_WEBDAV, "write") + val WRITE_PROPERTIES = Property.Name(XmlUtils.NS_WEBDAV, "write-properties") + val WRITE_CONTENT = Property.Name(XmlUtils.NS_WEBDAV, "write-content") + val BIND = Property.Name(XmlUtils.NS_WEBDAV, "bind") + val UNBIND = Property.Name(XmlUtils.NS_WEBDAV, "unbind") + val ALL = Property.Name(XmlUtils.NS_WEBDAV, "all") + + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): CurrentUserPrivilegeSet? { + // + // + val privs = CurrentUserPrivilegeSet() + + XmlUtils.processTag(parser, PRIVILEGE) { + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) + when (parser.propertyName()) { + READ -> + privs.mayRead = true + WRITE -> { + privs.mayBind = true + privs.mayUnbind = true + privs.mayWriteProperties = true + privs.mayWriteContent = true + } + WRITE_PROPERTIES -> + privs.mayWriteProperties = true + WRITE_CONTENT -> + privs.mayWriteContent = true + BIND -> + privs.mayBind = true + UNBIND -> + privs.mayUnbind = true + ALL -> { + privs.mayRead = true + privs.mayBind = true + privs.mayUnbind = true + privs.mayWriteProperties = true + privs.mayWriteContent = true + } + } + eventType = parser.next() + } + } + + return privs + } + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt new file mode 100644 index 0000000..1d2ed4a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/DisplayName.kt @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class DisplayName( + val displayName: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + DisplayName(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt new file mode 100644 index 0000000..941d90f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetCTag.kt @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class GetCTag( + val cTag: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "getctag") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + GetCTag(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt new file mode 100644 index 0000000..f10a4b8 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentLength.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.logging.Level + +data class GetContentLength( + val contentLength: Long +) : Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontentlength") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): GetContentLength? { + XmlUtils.readText(parser)?.let { valueStr -> + try { + return GetContentLength(valueStr.toLong()) + } catch(e: NumberFormatException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e) + } + } + return null + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt new file mode 100644 index 0000000..9ab0a22 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetContentType.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import org.xmlpull.v1.XmlPullParser + +data class GetContentType( + val type: MediaType? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontenttype") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + GetContentType(XmlUtils.readText(parser)?.toMediaTypeOrNull()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt new file mode 100644 index 0000000..7baf870 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetETag.kt @@ -0,0 +1,78 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.QuotedStringUtils +import at.bitfire.dav4jvm.XmlUtils +import okhttp3.Response +import org.xmlpull.v1.XmlPullParser + +/** + * The GetETag property. + * + * Can also be used to parse ETags from HTTP responses – just pass the raw ETag + * header value to the constructor and then use [eTag] and [weak]. + */ +class GetETag( + rawETag: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getetag") + + fun fromResponse(response: Response) = + response.header("ETag")?.let { GetETag(it) } + } + + /** + * The parsed eTag value. May be null if the tag is weak. + */ + val eTag: String? + + /** + * If the tag is weak. May be null if the tag passed is null. + */ + val weak: Boolean? + + init { + /* entity-tag = [ weak ] opaque-tag + weak = "W/" + opaque-tag = quoted-string + */ + var tag: String? = rawETag + if (tag != null) { + // remove trailing "W/" + if (tag.startsWith("W/") && tag.length >= 3) { + // entity tag is weak + tag = tag.substring(2) + weak = true + } else + weak = false + + tag = QuotedStringUtils.decodeQuotedString(tag) + } else + weak = null + + eTag = tag + } + + override fun toString() = eTag ?: "(null)" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + GetETag(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt new file mode 100644 index 0000000..2028e3d --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GetLastModified.kt @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.* +import org.xmlpull.v1.XmlPullParser + +data class GetLastModified( + var lastModified: Long +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getlastmodified") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): GetLastModified? { + // + XmlUtils.readText(parser)?.let { rawDate -> + val date = HttpUtils.parseDate(rawDate) + if (date != null) + return GetLastModified(date.time) + else + Dav4jvm.log.warning("Couldn't parse Last-Modified date") + } + return null + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt new file mode 100644 index 0000000..e876524 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/GroupMembership.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class GroupMembership: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "group-membership") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, GroupMembership()) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt new file mode 100644 index 0000000..6a7e154 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/HrefListProperty.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.* + +abstract class HrefListProperty: Property { + + val hrefs = LinkedList() + + val href + get() = hrefs.firstOrNull() + + override fun toString() = "href=[" + hrefs.joinToString(", ") + "]" + + + + abstract class Factory: PropertyFactory { + + fun create(parser: XmlPullParser, list: HrefListProperty): HrefListProperty { + XmlUtils.readTextPropertyList(parser, DavResource.HREF, list.hrefs) + return list + } + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt new file mode 100644 index 0000000..6fc6b22 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxICalendarSize.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.logging.Level + +data class MaxICalendarSize( + val maxSize: Long +) : Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "max-resource-size") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): MaxICalendarSize? { + XmlUtils.readText(parser)?.let { valueStr -> + try { + return MaxICalendarSize(valueStr.toLong()) + } catch(e: NumberFormatException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e) + } + } + return null + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt new file mode 100644 index 0000000..4a09501 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/MaxVCardSize.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.logging.Level + +data class MaxVCardSize( + val maxSize: Long +) : Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CARDDAV, "max-resource-size") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): MaxVCardSize? { + XmlUtils.readText(parser)?.let { valueStr -> + try { + return MaxVCardSize(valueStr.toLong()) + } catch(e: NumberFormatException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e) + } + } + return null + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt new file mode 100644 index 0000000..bd48b82 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Owner.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class Owner: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "owner") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, Owner()) + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt new file mode 100644 index 0000000..7423029 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaAvailableBytes.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.logging.Level + +data class QuotaAvailableBytes( + val quotaAvailableBytes: Long +) : Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-available-bytes") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): QuotaAvailableBytes? { + XmlUtils.readText(parser)?.let { valueStr -> + try { + return QuotaAvailableBytes(valueStr.toLong()) + } catch(e: NumberFormatException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e) + } + } + return null + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt new file mode 100644 index 0000000..b716389 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/QuotaUsedBytes.kt @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser +import java.util.logging.Level + +data class QuotaUsedBytes( + val quotaUsedBytes: Long +) : Property { + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-used-bytes") + } + + object Factory: PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): QuotaUsedBytes? { + XmlUtils.readText(parser)?.let { valueStr -> + try { + return QuotaUsedBytes(valueStr.toLong()) + } catch(e: NumberFormatException) { + Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e) + } + } + return null + } + } +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt new file mode 100644 index 0000000..4b8523f --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ResourceType.kt @@ -0,0 +1,63 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class ResourceType: Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype") + + val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection") // WebDAV + val PRINCIPAL = Property.Name(XmlUtils.NS_WEBDAV, "principal") // WebDAV ACL + val ADDRESSBOOK = Property.Name(XmlUtils.NS_CARDDAV, "addressbook") // CardDAV + val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar") // CalDAV + val SUBSCRIBED = Property.Name(XmlUtils.NS_CALENDARSERVER, "subscribed") + } + + val types = mutableSetOf() + + override fun toString() = "[${types.joinToString(", ")}]" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): ResourceType? { + val type = ResourceType() + + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) { + // use static objects to allow types.contains() + var typeName = Property.Name(parser.namespace, parser.name) + when (typeName) { + COLLECTION -> typeName = COLLECTION + PRINCIPAL -> typeName = PRINCIPAL + ADDRESSBOOK -> typeName = ADDRESSBOOK + CALENDAR -> typeName = CALENDAR + SUBSCRIBED -> typeName = SUBSCRIBED + } + type.types.add(typeName) + } + eventType = parser.next() + } + assert(parser.depth == depth) + + return type + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt new file mode 100644 index 0000000..62e5d16 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/ScheduleTag.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.QuotedStringUtils +import at.bitfire.dav4jvm.XmlUtils +import okhttp3.Response +import org.xmlpull.v1.XmlPullParser + +class ScheduleTag( + rawScheduleTag: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "schedule-tag") + + fun fromResponse(response: Response) = + response.header("Schedule-Tag")?.let { ScheduleTag(it) } + } + + /* Value: opaque-tag + opaque-tag = quoted-string + */ + val scheduleTag: String? = rawScheduleTag?.let { QuotedStringUtils.decodeQuotedString(it) } + + override fun toString() = scheduleTag ?: "(null)" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + ScheduleTag(XmlUtils.readText(parser)) + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt new file mode 100644 index 0000000..8691df4 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/Source.kt @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class Source: HrefListProperty() { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "source") + } + + + object Factory: HrefListProperty.Factory() { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + create(parser, Source()) + + } + +} \ No newline at end of file diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt new file mode 100644 index 0000000..63c0b0d --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedAddressData.kt @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.util.logging.Level + +class SupportedAddressData: Property { + + companion object { + + @JvmField + val NAME = Property.Name(XmlUtils.NS_CARDDAV, "supported-address-data") + + val ADDRESS_DATA_TYPE = Property.Name(XmlUtils.NS_CARDDAV, "address-data-type") + const val CONTENT_TYPE = "content-type" + const val VERSION = "version" + + } + + val types = mutableSetOf() + + fun hasVCard4() = types.any { "text/vcard; version=4.0".equals(it.toString(), true) } + fun hasJCard() = types.any { "application".equals(it.type, true) && "vcard+json".equals(it.subtype, true) } + + override fun toString() = "[${types.joinToString(", ")}]" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): SupportedAddressData? { + val supported = SupportedAddressData() + + try { + XmlUtils.processTag(parser, ADDRESS_DATA_TYPE) { + parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType -> + var type = contentType + parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" } + type.toMediaTypeOrNull()?.let { supported.types.add(it) } + } + } + } catch(e: XmlPullParserException) { + Dav4jvm.log.log(Level.SEVERE, "Couldn't parse ", e) + return null + } + + return supported + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt new file mode 100644 index 0000000..c58e5ae --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarComponentSet.kt @@ -0,0 +1,67 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.propertyName +import org.xmlpull.v1.XmlPullParser + +data class SupportedCalendarComponentSet( + var supportsEvents: Boolean, + var supportsTasks: Boolean, + var supportsJournal: Boolean +): Property { + + companion object { + + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set") + + val ALLCOMP = Property.Name(XmlUtils.NS_CALDAV, "allcomp") + val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp") + + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): SupportedCalendarComponentSet? { + /* + + + */ + val components = SupportedCalendarComponentSet(false, false, false) + + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) { + when (parser.propertyName()) { + ALLCOMP -> { + components.supportsEvents = true + components.supportsTasks = true + components.supportsJournal = true + } + COMP -> + when (parser.getAttributeValue(null, "name")?.uppercase()) { + "VEVENT" -> components.supportsEvents = true + "VTODO" -> components.supportsTasks = true + "VJOURNAL" -> components.supportsJournal = true + } + } + } + eventType = parser.next() + } + + return components + } + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt new file mode 100644 index 0000000..ca11581 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedCalendarData.kt @@ -0,0 +1,64 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserException +import java.util.logging.Level + +class SupportedCalendarData: Property { + + companion object { + + @JvmField + val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-data") + + val CALENDAR_DATA_TYPE = Property.Name(XmlUtils.NS_CALDAV, "calendar-data") + const val CONTENT_TYPE = "content-type" + const val VERSION = "version" + + } + + val types = mutableSetOf() + + fun hasJCal() = types.any { "application".equals(it.type, true) && "calendar+json".equals(it.subtype, true) } + + override fun toString() = "[${types.joinToString(", ")}]" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): SupportedCalendarData? { + val supported = SupportedCalendarData() + + try { + XmlUtils.processTag(parser, CALENDAR_DATA_TYPE) { + parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType -> + var type = contentType + parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" } + type.toMediaTypeOrNull()?.let { supported.types.add(it) } + } + } + } catch(e: XmlPullParserException) { + Dav4jvm.log.log(Level.SEVERE, "Couldn't parse ", e) + return null + } + + return supported + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt new file mode 100644 index 0000000..f1bde0a --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SupportedReportSet.kt @@ -0,0 +1,58 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +class SupportedReportSet: Property { + + companion object { + + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "supported-report-set") + + val SUPPORTED_REPORT = Property.Name(XmlUtils.NS_WEBDAV, "supported-report") + val REPORT = Property.Name(XmlUtils.NS_WEBDAV, "report") + + const val SYNC_COLLECTION = "DAV:sync-collection" // collection synchronization (RFC 6578) + + } + + val reports = mutableSetOf() + + override fun toString() = "[${reports.joinToString(", ")}]" + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): SupportedReportSet? { + /* + + + */ + + val supported = SupportedReportSet() + XmlUtils.processTag(parser, SUPPORTED_REPORT) { + XmlUtils.processTag(parser, REPORT) { + parser.nextTag() + if (parser.eventType == XmlPullParser.TEXT) + supported.reports += parser.text + else if (parser.eventType == XmlPullParser.START_TAG) + supported.reports += "${parser.namespace}${parser.name}" + } + } + return supported + } + + } + +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt new file mode 100644 index 0000000..9310529 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/SyncToken.kt @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +data class SyncToken( + val token: String? +): Property { + + companion object { + @JvmField + val NAME = Property.Name(XmlUtils.NS_WEBDAV, "sync-token") + } + + + object Factory: PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + // + SyncToken(XmlUtils.readText(parser)) + + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/PushTransports.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/PushTransports.kt new file mode 100644 index 0000000..e639fbb --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/PushTransports.kt @@ -0,0 +1,73 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property.push + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.XmlUtils.propertyName +import org.xmlpull.v1.XmlPullParser + +/** + * `P:transports` — the push transports a resource offers. + * + * Only Web Push is modelled; any other transport is skipped. [webPush] is null + * when the server offers no Web Push at all. + */ +data class PushTransports( + val webPush: WebPush?, +) : Property { + + /** The `P:web-push` transport, with the server's VAPID key if it has one. */ + data class WebPush( + /** Uncompressed P-256 public key, base64url encoded. */ + val vapidPublicKey: String?, + ) + + companion object { + @JvmField + val NAME = WebDAVPush.Transports + } + + object Factory : PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser): PushTransports { + var webPush: WebPush? = null + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && + parser.propertyName() == WebDAVPush.WebPush + ) { + webPush = WebPush(vapidPublicKey = readVapidKey(parser)) + } + eventType = parser.next() + } + return PushTransports(webPush) + } + + /** Reads a `web-push` element to its end tag, returning its p256ecdsa key. */ + private fun readVapidKey(parser: XmlPullParser): String? { + var key: String? = null + val depth = parser.depth + var eventType = parser.eventType + while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) { + if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && + parser.propertyName() == WebDAVPush.VapidPublicKey + ) { + val type = parser.getAttributeValue(null, "type") + val text = XmlUtils.readText(parser)?.trim() + if (type == null || type == "p256ecdsa") key = text?.takeIf { it.isNotEmpty() } + } + eventType = parser.next() + } + return key + } + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/Topic.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/Topic.kt new file mode 100644 index 0000000..4b3fdb9 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/Topic.kt @@ -0,0 +1,31 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property.push + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +/** `P:topic` — the server-wide identifier push messages about a resource carry. */ +data class Topic( + val topic: String?, +) : Property { + + companion object { + @JvmField + val NAME = WebDAVPush.Topic + } + + object Factory : PropertyFactory { + + override fun getName() = NAME + + override fun create(parser: XmlPullParser) = + Topic(XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() }) + } +} diff --git a/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/WebDAVPush.kt b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/WebDAVPush.kt new file mode 100644 index 0000000..9c9e531 --- /dev/null +++ b/dav/src/main/kotlin/at/bitfire/dav4jvm/property/push/WebDAVPush.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.property.push + +import at.bitfire.dav4jvm.Property + +/** + * Element names of the WebDAV-Push draft (https://github.com/bitfireAT/webdav-push). + * + * Backported from upstream dav4jvm; see `dav/PROVENANCE.md` change 7. + */ +object WebDAVPush { + + const val NS_WEBDAV_PUSH = "https://bitfire.at/webdav-push" + + val AuthSecret = Property.Name(NS_WEBDAV_PUSH, "auth-secret") + val ContentEncoding = Property.Name(NS_WEBDAV_PUSH, "content-encoding") + val ContentUpdate = Property.Name(NS_WEBDAV_PUSH, "content-update") + val Expires = Property.Name(NS_WEBDAV_PUSH, "expires") + val PropertyUpdate = Property.Name(NS_WEBDAV_PUSH, "property-update") + val PushMessage = Property.Name(NS_WEBDAV_PUSH, "push-message") + val PushRegister = Property.Name(NS_WEBDAV_PUSH, "push-register") + val PushResource = Property.Name(NS_WEBDAV_PUSH, "push-resource") + val Subscription = Property.Name(NS_WEBDAV_PUSH, "subscription") + val SubscriptionPublicKey = Property.Name(NS_WEBDAV_PUSH, "subscription-public-key") + val Topic = Property.Name(NS_WEBDAV_PUSH, "topic") + val Transports = Property.Name(NS_WEBDAV_PUSH, "transports") + val Trigger = Property.Name(NS_WEBDAV_PUSH, "trigger") + val VapidPublicKey = Property.Name(NS_WEBDAV_PUSH, "vapid-public-key") + val WebPush = Property.Name(NS_WEBDAV_PUSH, "web-push") + val WebPushSubscription = Property.Name(NS_WEBDAV_PUSH, "web-push-subscription") +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt new file mode 100644 index 0000000..6660762 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/BasicDigestAuthHandlerTest.kt @@ -0,0 +1,352 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import okhttp3.Challenge +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.Response.Builder +import org.junit.Assert.* +import org.junit.Test + +class BasicDigestAuthHandlerTest { + + // ⚠️ Amended from upstream: https, not http. Basic over cleartext is now + // refused whether or not it was challenged — see PROVENANCE change 9. The + // cleartext behaviour this used to assert is pinned by + // `cleartextBasicIsRefusedEvenWhenChallenged` below. + @Test + fun testBasic() { + var authenticator = BasicDigestAuthHandler(null, "user", "password") + val original = Request.Builder() + .url("https://example.com") + .build() + var response = Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(401).message("Authentication required") + .header("WWW-Authenticate", "Basic realm=\"WallyWorld\"") + .build() + var request = authenticator.authenticateRequest(original, response) + assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization")) + + // special characters: always use UTF-8 (and don't crash on RFC 7617 charset header) + authenticator = BasicDigestAuthHandler(null, "username", "paßword") + response = response.newBuilder() + .header("WWW-Authenticate", "Basic realm=\"WallyWorld\",charset=UTF-8") + .build() + request = authenticator.authenticateRequest(original, response) + assertEquals("Basic dXNlcm5hbWU6cGHDn3dvcmQ=", request!!.header("Authorization")) + } + + @Test + fun cleartextBasicIsRefusedEvenWhenChallenged() { + val authenticator = BasicDigestAuthHandler(null, "user", "password") + val original = Request.Builder().url("http://example.com").build() + val response = Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(401).message("Authentication required") + .header("WWW-Authenticate", "Basic realm=\"WallyWorld\"") + .build() + + assertNull(authenticator.authenticateRequest(original, response)) + } + + @Test + fun aRefusedCleartextChallengeIsNotCached() { + val authenticator = BasicDigestAuthHandler(null, "user", "password") + val original = Request.Builder().url("http://example.com").build() + val response = Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(401).message("Authentication required") + .header("WWW-Authenticate", "Basic realm=\"WallyWorld\"") + .build() + + assertNull(authenticator.authenticateRequest(original, response)) + // Caching it would make the *next* 401 report that the credentials did + // not work, about a credential that was never sent. + assertNull(authenticator.authenticateRequest(original, response)) + } + + @Test + fun cleartextBasicIsSentWhenExplicitlyAllowed() { + val authenticator = BasicDigestAuthHandler(null, "user", "password", insecureBasic = true) + val original = Request.Builder().url("http://example.com").build() + val response = Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(401).message("Authentication required") + .header("WWW-Authenticate", "Basic realm=\"WallyWorld\"") + .build() + + val request = authenticator.authenticateRequest(original, response) + assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization")) + } + + @Test + fun cleartextDigestIsStillAnswered() { + // Digest never puts the password on the wire, so gating it would break a + // LAN server for nothing. + val authenticator = BasicDigestAuthHandler(null, "user", "password") + val original = Request.Builder().url("http://example.com").build() + val response = Builder() + .request(original) + .protocol(Protocol.HTTP_1_1) + .code(401).message("Authentication required") + .header( + "WWW-Authenticate", + "Digest realm=\"WallyWorld\", nonce=\"abc\", qop=\"auth\"", + ) + .build() + + val request = authenticator.authenticateRequest(original, response) + assertTrue(request!!.header("Authorization")!!.startsWith("Digest")) + } + + @Test + fun testDigestRFCExample() { + // use cnonce from example + val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life") + authenticator.clientNonce = "0a4f113b" + authenticator.nonceCount.set(1) + + // construct WWW-Authenticate + val authScheme = Challenge("Digest", mapOf( + Pair("realm", "testrealm@host.com"), + Pair("qop", "auth"), + Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"), + Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41") + )) + + val original = Request.Builder() + .get() + .url("http://www.nowhere.org/dir/index.html") + .build() + val request = authenticator.digestRequest(original, authScheme) + val auth = request!!.header("Authorization") + assertTrue(auth!!.contains("username=\"Mufasa\"")) + assertTrue(auth.contains("realm=\"testrealm@host.com\"")) + assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"")) + assertTrue(auth.contains("uri=\"/dir/index.html\"")) + assertTrue(auth.contains("qop=auth")) + assertTrue(auth.contains("nc=00000001")) + assertTrue(auth.contains("cnonce=\"0a4f113b\"")) + assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\"")) + assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")) + } + + @Test + fun testDigestRealWorldExamples() { + var authenticator = BasicDigestAuthHandler(null, "demo", "demo") + authenticator.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=" + authenticator.nonceCount.set(1) + + // example 1 + var authScheme = Challenge("Digest", mapOf( + Pair("realm", "Group-Office"), + Pair("qop", "auth"), + Pair("nonce", "56212407212c8"), + Pair("opaque", "df58bdff8cf60599c939187d0b5c54de") + )) + + var original = Request.Builder() + .method("PROPFIND", null) + .url("https://demo.group-office.eu/caldav/") + .build() + var request = authenticator.digestRequest(original, authScheme) + var auth = request!!.header("Authorization") + assertTrue(auth!!.contains("username=\"demo\"")) + assertTrue(auth.contains("realm=\"Group-Office\"")) + assertTrue(auth.contains("nonce=\"56212407212c8\"")) + assertTrue(auth.contains("uri=\"/caldav/\"")) + assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\"")) + assertTrue(auth.contains("nc=00000001")) + assertTrue(auth.contains("qop=auth")) + assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\"")) + assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\"")) + + // example 2 + authenticator = BasicDigestAuthHandler(null, "test", "test") + authScheme = Challenge("digest", mapOf( // lower case + Pair("nonce", "87c4c2aceed9abf30dd68c71"), + Pair("algorithm", "md5"), + Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="), + Pair("realm", "ieddy.ru") + )) + original = Request.Builder() + .method("OPTIONS", null) + .url("https://ieddy.ru/") + .build() + request = authenticator.digestRequest(original, authScheme) + auth = request!!.header("Authorization") + assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it + assertTrue(auth.contains("username=\"test\"")) + assertTrue(auth.contains("realm=\"ieddy.ru\"")) + assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\"")) + assertTrue(auth.contains("uri=\"/\"")) + assertFalse(auth.contains("cnonce=")) + assertFalse(auth.contains("nc=00000001")) + assertFalse(auth.contains("qop=")) + assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\"")) + assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\"")) + } + + @Test + fun testDigestMD5Sess() { + val authenticator = BasicDigestAuthHandler(null, "admin", "12345") + authenticator.clientNonce = "hxk1lu63b6c7vhk" + authenticator.nonceCount.set(1) + + val authScheme = Challenge("Digest", mapOf( + Pair("realm", "MD5-sess Example"), + Pair("qop", "auth"), + Pair("algorithm", "MD5-sess"), + Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"), + Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41") + )) + + /* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk = + 4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686 + A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed + + h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") = + af2a72145775cfd08c36ad2676e89446 + */ + + val original = Request.Builder() + .method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType())) + .url("http://example.com/plain.txt") + .build() + val request = authenticator.digestRequest(original, authScheme) + val auth = request!!.header("Authorization") + assertTrue(auth!!.contains("username=\"admin\"")) + assertTrue(auth.contains("realm=\"MD5-sess Example\"")) + assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"")) + assertTrue(auth.contains("uri=\"/plain.txt\"")) + assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\"")) + assertTrue(auth.contains("nc=00000001")) + assertTrue(auth.contains("qop=auth")) + assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\"")) + assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")) + } + + @Test + fun testDigestMD5AuthInt() { + val authenticator = BasicDigestAuthHandler(null, "admin", "12435") + authenticator.clientNonce = "hxk1lu63b6c7vhk" + authenticator.nonceCount.set(1) + + val authScheme = Challenge("Digest", mapOf( + Pair("realm", "AuthInt Example"), + Pair("qop", "auth-int"), + Pair("nonce", "367sj3265s5"), + Pair("opaque", "87aaxcval4gba36") + )) + + /* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34 + + h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9 + A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0 + + h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) = + 81d07cb3b8d412b34144164124c970cb + */ + + val original = Request.Builder() + .method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType())) + .url("http://example.com/plain.txt") + .build() + val request = authenticator.digestRequest(original, authScheme) + val auth = request!!.header("Authorization") + assertTrue(auth!!.contains("username=\"admin\"")) + assertTrue(auth.contains("realm=\"AuthInt Example\"")) + assertTrue(auth.contains("nonce=\"367sj3265s5\"")) + assertTrue(auth.contains("uri=\"/plain.txt\"")) + assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\"")) + assertTrue(auth.contains("nc=00000001")) + assertTrue(auth.contains("qop=auth-int")) + assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\"")) + assertTrue(auth.contains("opaque=\"87aaxcval4gba36\"")) + } + + @Test + fun testDigestLegacy() { + val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife") + + // construct WWW-Authenticate + val authScheme = Challenge("Digest", mapOf( + Pair("realm", "testrealm@host.com"), + Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"), + Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41") + )) + + val original = Request.Builder() + .get() + .url("http://www.nowhere.org/dir/index.html") + .build() + val request = authenticator.digestRequest(original, authScheme) + val auth = request!!.header("Authorization") + assertTrue(auth!!.contains("username=\"Mufasa\"")) + assertTrue(auth.contains("realm=\"testrealm@host.com\"")) + assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\"")) + assertTrue(auth.contains("uri=\"/dir/index.html\"")) + assertFalse(auth.contains("qop=")) + assertFalse(auth.contains("nc=")) + assertFalse(auth.contains("cnonce=")) + assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\"")) + assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"")) + } + + @Test + fun testIncompleteAuthenticationRequests() { + val authenticator = BasicDigestAuthHandler(null, "demo", "demo") + + val original = Request.Builder() + .get() + .url("http://www.nowhere.org/dir/index.html") + .build() + + assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf()))) + + assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( + Pair("realm", "Group-Office") + )))) + + assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( + Pair("realm", "Group-Office"), + Pair("qop", "auth") + )))) + + assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf( + Pair("realm", "Group-Office"), + Pair("qop", "auth"), + Pair("nonce", "56212407212c8") + )))) + } + + @Test + fun testAuthenticateNull() { + val authenticator = BasicDigestAuthHandler(null, "demo", "demo") + // must not crash (route may be null) + val request = Request.Builder() + .get() + .url("http://example.com") + .build() + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_2) + .code(200).message("OK") + .build() + authenticator.authenticate(null, response) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt new file mode 100644 index 0000000..2ca5cdd --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavCollectionTest.kt @@ -0,0 +1,257 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.property.GetETag +import at.bitfire.dav4jvm.property.SyncToken +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.net.HttpURLConnection + +class DavCollectionTest { + + private val sampleText = "SAMPLE RESPONSE" + + private val httpClient = OkHttpClient.Builder() + .followRedirects(false) + .build() + private val mockServer = MockWebServer() + private fun sampleUrl() = mockServer.url("/dav/") + + @Before + fun startServer() = mockServer.start() + + @After + fun stopServer() = mockServer.shutdown() + + + /** + * Test sample response for an initial sync-collection report from RFC 6578 3.8. + */ + @Test + fun testInitialSyncCollectionReport() { + val url = sampleUrl() + val collection = DavCollection(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "text/xml; charset=\"utf-8\"") + .setBody("\n" + + " \n" + + " \n" + + " ${sampleUrl()}test.doc\n" + + " \n" + + " \n" + + " \"00001-abcd1\"\n" + + " \n" + + " Box type A\n" + + " \n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}vcard.vcf\n" + + " \n" + + " \n" + + " \"00002-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " HTTP/1.1 404 Not Found\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}calendar.ics\n" + + " \n" + + " \n" + + " \"00003-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " HTTP/1.1 404 Not Found\n" + + " \n" + + " \n" + + " http://example.com/ns/sync/1234\n" + + " ") + ) + var nrCalled = 0 + val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation -> + when (response.href) { + url.resolve("/dav/test.doc") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00001-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + url.resolve("/dav/vcard.vcf") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00002-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + url.resolve("/dav/calendar.ics") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00003-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + } + } + assertEquals(3, nrCalled) + assertEquals("http://example.com/ns/sync/1234", result.filterIsInstance(SyncToken::class.java).first().token) + } + + /** + * Test sample response for an initial sync-collection report with truncation from RFC 6578 3.10. + */ + @Test + fun testInitialSyncCollectionReportWithTruncation() { + val url = sampleUrl() + val collection = DavCollection(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "text/xml; charset=\"utf-8\"") + .setBody("\n" + + " \n" + + " \n" + + " ${sampleUrl()}test.doc\n" + + " \n" + + " \n" + + " \"00001-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}vcard.vcf\n" + + " \n" + + " \n" + + " \"00002-abcd1\"\n" + + " \n" + + " HTTP/1.1 200 OK\n" + + " \n" + + " \n" + + " \n" + + " ${sampleUrl()}removed.txt\n" + + " HTTP/1.1 404 Not Found\n" + + " " + + " \n" + + " ${sampleUrl()}\n" + + " HTTP/1.1 507 Insufficient Storage\n" + + " \n" + + " " + + " http://example.com/ns/sync/1233\n" + + " ") + ) + var nrCalled = 0 + val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation -> + when (response.href) { + url.resolve("/dav/test.doc") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00001-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + url.resolve("/dav/vcard.vcf") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + val eTag = response[GetETag::class.java] + assertEquals("00002-abcd1", eTag?.eTag) + assertTrue(eTag?.weak == false) + nrCalled++ + } + url.resolve("/dav/removed.txt") -> { + assertFalse(response.isSuccess()) + assertEquals(404, response.status?.code) + assertEquals(Response.HrefRelation.MEMBER, relation) + nrCalled++ + } + url.resolve("/dav/") -> { + assertFalse(response.isSuccess()) + assertEquals(507, response.status?.code) + assertEquals(Response.HrefRelation.SELF, relation) + nrCalled++ + } + } + } + assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance(SyncToken::class.java).first().token) + assertEquals(4, nrCalled) + } + + /** + * Test sample response for a sync-collection report with unsupported limit from RFC 6578 3.12. + */ + @Test + fun testSyncCollectionReportWithUnsupportedLimit() { + val url = sampleUrl() + val collection = DavCollection(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(507) + .setHeader("Content-Type", "text/xml; charset=\"utf-8\"") + .setBody("\n" + + " \n" + + " \n" + + " ") + ) + + try { + collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(507, e.code) + assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "number-of-matches-within-limits") }) + assertEquals(1, e.errors.size) + } + } + + @Test + fun testPost() { + val url = sampleUrl() + val dav = DavCollection(httpClient, url) + + // 201 Created + mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_CREATED)) + var called = false + dav.post(sampleText.toRequestBody("text/plain".toMediaType())) { response -> + assertEquals("POST", mockServer.takeRequest().method) + assertEquals(response.request.url, dav.location) + called = true + } + assertTrue(called) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt new file mode 100644 index 0000000..be6c1bd --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/DavResourceTest.kt @@ -0,0 +1,971 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.PreconditionFailedException +import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.GetContentType +import at.bitfire.dav4jvm.property.GetETag +import at.bitfire.dav4jvm.property.ResourceType +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.net.HttpURLConnection + +class DavResourceTest { + + private val sampleText = "SAMPLE RESPONSE" + + private val httpClient = OkHttpClient.Builder() + .followRedirects(false) + .build() + private val mockServer = MockWebServer() + + + @Before + fun startServer() { + mockServer.start() + } + + @After + fun stopServer() { + mockServer.shutdown() + } + + private fun sampleUrl() = mockServer.url("/dav/") + + + @Test + fun testCopy() { + val url = sampleUrl() + val destination = url.resolve("test")!! + + /* POSITIVE TEST CASES */ + + // no preconditions, 201 Created, resulted in the creation of a new resource + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_CREATED)) + var called = false + DavResource(httpClient, url).let { dav -> + dav.copy(destination, false) { + called = true + } + assertTrue(called) + } + + var rq = mockServer.takeRequest() + assertEquals("COPY", rq.method) + assertEquals(url.encodedPath, rq.path) + assertEquals(destination.toString(), rq.getHeader("Destination")) + assertNull(rq.getHeader("Overwrite")) + + // no preconditions, 204 No content, resource successfully copied to a preexisting + // destination resource + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_NO_CONTENT)) + called = false + DavResource(httpClient, url).let { dav -> + dav.copy(destination, true) { + called = true + } + assertTrue(called) + } + + rq = mockServer.takeRequest() + assertEquals("COPY", rq.method) + assertEquals(url.encodedPath, rq.path) + assertEquals(destination.toString(), rq.getHeader("Destination")) + assertEquals("F", rq.getHeader("Overwrite")) + + /* NEGATIVE TEST CASES */ + + // 207 multi-status (e.g. errors on some of resources affected by + // the COPY prevented the operation from taking place) + + mockServer.enqueue(MockResponse() + .setResponseCode(207)) + try { + called = false + DavResource(httpClient, url).let { dav -> + dav.copy(destination, false) { called = true } + fail("Expected HttpException") + } + } catch(e: HttpException) { + assertFalse(called) + } + } + + @Test + fun testDelete() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + /* POSITIVE TEST CASES */ + + // no preconditions, 204 No Content + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_NO_CONTENT)) + var called = false + dav.delete { + called = true + } + assertTrue(called) + + var rq = mockServer.takeRequest() + assertEquals("DELETE", rq.method) + assertEquals(url.encodedPath, rq.path) + assertNull(rq.getHeader("If-Match")) + + // precondition: If-Match / If-Schedule-Tag-Match, 200 OK + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody("Resource has been deleted.")) + called = false + dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") { + called = true + } + assertTrue(called) + + rq = mockServer.takeRequest() + assertEquals("\"DeleteOnlyThisETag\"", rq.getHeader("If-Match")) + assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.getHeader("If-Schedule-Tag-Match")) + + // 302 Moved Temporarily + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP) + .setHeader("Location", "/new-location") + ) + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + called = false + dav.delete(null) { + called = true + } + assertTrue(called) + + /* NEGATIVE TEST CASES */ + + // 207 multi-status (e.g. single resource couldn't be deleted when DELETEing a collection) + mockServer.enqueue(MockResponse() + .setResponseCode(207)) + try { + called = false + dav.delete(null) { called = true } + fail("Expected HttpException") + } catch(e: HttpException) { + assertFalse(called) + } + } + + @Test + fun testFollowRedirects_302() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + var i = 0 + dav.followRedirects { + if (i++ == 0) + okhttp3.Response.Builder() + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://to.com/") + .request(Request.Builder() + .get() + .url("http://from.com/") + .build()) + .body("New location!".toResponseBody()) + .build() + else + okhttp3.Response.Builder() + .protocol(Protocol.HTTP_1_1) + .code(204) + .message("No Content") + .request(Request.Builder() + .get() + .url("http://to.com/") + .build()) + .build() + }.let { response -> + assertEquals(204, response.code) + assertEquals("http://to.com/".toHttpUrl(), dav.location) + } + } + + @Test(expected = DavException::class) + fun testFollowRedirects_HttpsToHttp() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.followRedirects { + okhttp3.Response.Builder() + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Found") + .header("Location", "http://to.com/") + .request(Request.Builder() + .get() + .url("https://from.com/") + .build()) + .body("New location!".toResponseBody()) + .build() + } + } + + @Test + fun testGet() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + /* POSITIVE TEST CASES */ + + // 200 OK + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setHeader("ETag", "W/\"My Weak ETag\"") + .setHeader("Content-Type", "application/x-test-result") + .setBody(sampleText)) + var called = false + dav.get("*/*", null) { response -> + called = true + assertEquals(sampleText, response.body!!.string()) + + val eTag = GetETag.fromResponse(response) + assertEquals("My Weak ETag", eTag!!.eTag) + assertTrue(eTag.weak!!) + assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body!!.contentType()!!).type) + } + assertTrue(called) + + var rq = mockServer.takeRequest() + assertEquals("GET", rq.method) + assertEquals(url.encodedPath, rq.path) + assertEquals("*/*", rq.getHeader("Accept")) + + // 302 Moved Temporarily + 200 OK + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP) + .setHeader("Location", "/target") + .setBody("This resource was moved.")) + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setHeader("ETag", "\"StrongETag\"") + .setBody(sampleText)) + called = false + dav.get("*/*", null) { response -> + called = true + assertEquals(sampleText, response.body!!.string()) + val eTag = GetETag(response.header("ETag")) + assertEquals("StrongETag", eTag.eTag) + assertFalse(eTag.weak!!) + } + assertTrue(called) + + mockServer.takeRequest() + rq = mockServer.takeRequest() + assertEquals("GET", rq.method) + assertEquals("/target", rq.path) + + // 200 OK without ETag in response + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setBody(sampleText)) + called = false + dav.get("*/*", null) { response -> + called = true + assertNull(response.header("ETag")) + } + assertTrue(called) + } + + @Test + fun testGetRange_Ok() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_PARTIAL)) + var called = false + dav.getRange("*/*", 100, 342) { response -> + assertEquals("bytes=100-441", response.request.header("Range")) + called = true + } + assertTrue(called) + } + + @Test + fun testMove() { + val url = sampleUrl() + val destination = url.resolve("test")!! + + /* POSITIVE TEST CASES */ + + // no preconditions, 201 Created, new URL mapping at the destination + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_CREATED)) + var called = false + DavResource(httpClient, url).let { dav -> + dav.move(destination, false) { + called = true + } + assertTrue(called) + assertEquals(destination, dav.location) + } + + var rq = mockServer.takeRequest() + assertEquals("MOVE", rq.method) + assertEquals(url.encodedPath, rq.path) + assertEquals(destination.toString(), rq.getHeader("Destination")) + assertNull(rq.getHeader("Overwrite")) + + // no preconditions, 204 No content, URL already mapped, overwrite + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_NO_CONTENT)) + called = false + DavResource(httpClient, url).let { dav -> + dav.move(destination, true) { + called = true + } + assertTrue(called) + assertEquals(destination, dav.location) + } + + rq = mockServer.takeRequest() + assertEquals("MOVE", rq.method) + assertEquals(url.encodedPath, rq.path) + assertEquals(destination.toString(), rq.getHeader("Destination")) + assertEquals("F", rq.getHeader("Overwrite")) + + /* NEGATIVE TEST CASES */ + + // 207 multi-status (e.g. errors on some of resources affected by + // the MOVE prevented the operation from taking place) + + mockServer.enqueue(MockResponse() + .setResponseCode(207)) + try { + called = false + DavResource(httpClient, url).let { dav -> + dav.move(destination, false) { called = true } + fail("Expected HttpException") + } + } catch(e: HttpException) { + assertFalse(called) + } + } + + @Test + fun testOptions() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .setHeader("DAV", " 1, 2 ,3,hyperactive-access")) + var called = false + dav.options { davCapabilities, _ -> + called = true + assertTrue(davCapabilities.contains("1")) + assertTrue(davCapabilities.contains("2")) + assertTrue(davCapabilities.contains("3")) + assertTrue(davCapabilities.contains("hyperactive-access")) + } + assertTrue(called) + + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK)) + called = false + dav.options { davCapabilities, _ -> + called = true + assertTrue(davCapabilities.isEmpty()) + } + assertTrue(called) + } + + @Test + fun testPropfindAndMultiStatus() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + /*** NEGATIVE TESTS ***/ + + // test for non-multi-status responses: + // * 500 Internal Server Error + mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR)) + var called = false + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected HttpException") + } catch(e: HttpException) { + assertFalse(called) + } + // * 200 OK (instead of 207 Multi-Status) + mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)) + try { + called = false + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch(e: DavException) { + assertFalse(called) + } + + // test for invalid multi-status responses: + // * non-XML response + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "text/html") + .setBody("")) + try { + called = false + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch(e: DavException) { + assertFalse(called) + } + + // * malformed XML response + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("")) + try { + called = false + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch(e: DavException) { + assertFalse(called) + } + + // * response without root element + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("")) + try { + called = false + dav.propfind(0, ResourceType.NAME) { _, _ -> called = true } + fail("Expected DavException") + } catch(e: DavException) { + assertFalse(called) + } + + // * multi-status response with invalid in + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " Invalid Status Line" + + " " + + "")) + called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(500, response.status?.code) + called = true + } + assertTrue(called) + + // * multi-status response with / element indicating failure + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " HTTP/1.1 403 Forbidden" + + " " + + "")) + called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(403, response.status?.code) + called = true + } + assertTrue(called) + + // * multi-status response with invalid in + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " " + + " " + + " Invalid Status Line" + + " " + + " " + + "")) + called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + called = true + assertEquals(Response.HrefRelation.SELF, relation) + assertTrue(response.properties.filterIsInstance(ResourceType::class.java).isEmpty()) + } + assertTrue(called) + + + /*** POSITIVE TESTS ***/ + + // multi-status response without elements + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("")) + dav.propfind(0, ResourceType.NAME) { _, _ -> + fail("Shouldn't be called") + } + + // multi-status response with / element indicating success + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " HTTP/1.1 200 OK" + + " " + + "")) + called = false + dav.propfind(0, ResourceType.NAME) { response, relation -> + called = true + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(0, response.properties.size) + } + assertTrue(called) + + // multi-status response with / element + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + "")) + called = false + dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation -> + called = true + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + } + assertTrue(called) + + // multi-status response for collection with several members; incomplete (not all s listed) + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " " + url.toString() + "" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /dav/subcollection" + + " " + + " " + + " " + + " A Subfolder" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /dav/uid@host:file" + + " " + + " " + + " Absolute path with @ and :" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " relative-uid@host.file" + + " " + + " " + + " Relative path with @" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " relative:colon.vcf" + + " " + + " " + + " Relative path with colon" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " /something-very/else" + + " " + + " " + + " Not requested" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + "")) + var nrCalled = 0 + dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation -> + when (response.href) { + url.resolve("/dav/") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) + assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + url.resolve("/dav/subcollection/") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) + assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + url.resolve("/dav/uid@host:file") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + url.resolve("/dav/relative-uid@host.file") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + url.resolve("/dav/relative:colon.vcf") -> { + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.MEMBER, relation) + assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName) + nrCalled++ + } + } + } + assertEquals(4, nrCalled) + + + /*** SPECIAL CASES ***/ + + // same property is sent as 200 OK and 404 Not Found in same (seen in iCloud) + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " " + url.toString() + "" + + " " + + " " + + " " + + " My DAV Collection" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " " + + " " + + " HTTP/1.1 404 Not Found" + + " " + + " " + + "")) + called = false + dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation -> + called = true + assertTrue(response.isSuccess()) + assertEquals(Response.HrefRelation.SELF, relation) + assertEquals(url.resolve("/dav/"), response.href) + assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION)) + assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName) + } + assertTrue(called) + + // multi-status response with that doesn't contain (=> assume 200 OK) + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " Without Status" + + " " + + " " + + " " + + "")) + called = false + dav.propfind(0, DisplayName.NAME) { response, _ -> + called = true + assertEquals(200, response.propstat.first().status.code) + assertEquals("Without Status", response[DisplayName::class.java]?.displayName) + } + assertTrue(called) + } + + @Test + fun testProppatch() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + // multi-status response with / elements + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " Some Value" + + " " + + " HTTP/1.1 200 OK" + + " " + + " " + + " " + + " " + + " " + + " HTTP/1.1 404 Not Found" + + " " + + " " + + "")) + + var called = false + dav.proppatch( + setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")), + removeProperties = listOf(Property.Name("sample", "removeThis")) + ) { response, hrefRelation -> + called = true + assertEquals(Response.HrefRelation.SELF, hrefRelation) + } + assertTrue(called) + } + + @Test + fun testProppatch_createProppatchXml() { + val xml = DavResource.createProppatchXml( + setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")), + removeProperties = listOf(Property.Name("sample", "removeThis")) + ) + assertEquals("" + + "" + + "Some Value" + + "" + + "", xml) + } + + @Test + fun testPut() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + /* POSITIVE TEST CASES */ + + // no preconditions, 201 Created + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_CREATED) + .setHeader("ETag", "W/\"Weak PUT ETag\"")) + var called = false + dav.put(sampleText.toRequestBody("text/plain".toMediaType())) { response -> + called = true + val eTag = GetETag.fromResponse(response)!! + assertEquals("Weak PUT ETag", eTag.eTag) + assertTrue(eTag.weak!!) + assertEquals(response.request.url, dav.location) + } + assertTrue(called) + + var rq = mockServer.takeRequest() + assertEquals("PUT", rq.method) + assertEquals(url.encodedPath, rq.path) + assertNull(rq.getHeader("If-Match")) + assertNull(rq.getHeader("If-None-Match")) + + // precondition: If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_MOVED_PERM) + .setHeader("Location", "/target")) + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_NO_CONTENT)) + called = false + dav.put(sampleText.toRequestBody("text/plain".toMediaType()), ifNoneMatch = true) { response -> + called = true + assertEquals(url.resolve("/target"), response.request.url) + val eTag = GetETag.fromResponse(response) + assertNull("Weak PUT ETag", eTag?.eTag) + assertNull(eTag?.weak) + } + assertTrue(called) + + mockServer.takeRequest() + rq = mockServer.takeRequest() + assertEquals("PUT", rq.method) + assertEquals("*", rq.getHeader("If-None-Match")) + + // precondition: If-Match, 412 Precondition Failed + mockServer.enqueue(MockResponse() + .setResponseCode(HttpURLConnection.HTTP_PRECON_FAILED)) + called = false + try { + dav.put(sampleText.toRequestBody("text/plain".toMediaType()), "ExistingETag") { + called = true + } + fail("Expected PreconditionFailedException") + } catch(_: PreconditionFailedException) {} + assertFalse(called) + rq = mockServer.takeRequest() + assertEquals("\"ExistingETag\"", rq.getHeader("If-Match")) + assertNull(rq.getHeader("If-None-Match")) + } + + @Test + fun testSearch() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("" + + " " + + " /dav" + + " " + + " " + + " Found something" + + " " + + " " + + " " + + "")) + var called = false + dav.search("") { response, hrefRelation, -> + assertEquals(Response.HrefRelation.SELF, hrefRelation) + assertEquals("Found something", response[DisplayName::class.java]?.displayName) + called = true + } + assertTrue(called) + + val rq = mockServer.takeRequest() + assertEquals("SEARCH", rq.method) + assertEquals(url.encodedPath, rq.path) + assertEquals("", rq.body.readString(Charsets.UTF_8)) + } + + + /** test helpers **/ + + @Test(expected = DavException::class) + fun testAssertMultiStatus_NoBody_NoXML() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(207).message("Multi-Status") + .build()) + } + + @Test(expected = DavException::class) + fun testAssertMultiStatus_NoBody_XML() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(207).message("Multi-Status") + .addHeader("Content-Type", "text/xml") + .build()) + } + + @Test + fun testAssertMultiStatus_NonXML_ButContentIsXML() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(207).message("Multi-Status") + .addHeader("Content-Type", "application/octet-stream") + .body("".toResponseBody()) + .build()) + } + + @Test + fun testAssertMultiStatus_NonXML_ReallyNotXML() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(207).message("Multi-Status") + .addHeader("Content-Type", "text/plain") + .body("Some error occurred".toResponseBody()) + .build()) + } + + @Test(expected = DavException::class) + fun testAssertMultiStatus_Not207() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(403).message("Multi-Status") + .addHeader("Content-Type", "application/xml") + .body("".toResponseBody()) + .build()) + } + + @Test + fun testAssertMultiStatus_Ok_ApplicationXml() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(207).message("Multi-Status") + .addHeader("Content-Type", "application/xml") + .body("".toResponseBody()) + .build()) + } + + @Test + fun testAssertMultiStatus_Ok_TextXml() { + val dav = DavResource(httpClient, "https://from.com".toHttpUrl()) + dav.assertMultiStatus(okhttp3.Response.Builder() + .request(Request.Builder().url(dav.location).build()) + .protocol(Protocol.HTTP_1_1) + .code(207).message("Multi-Status") + .addHeader("Content-Type", "text/xml") + .body("".toResponseBody()) + .build()) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt new file mode 100644 index 0000000..8fc47c6 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/ErrorTest.kt @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import org.junit.Assert.assertTrue +import org.junit.Test + +class ErrorTest { + + @Test + fun testEquals() { + val errors = listOf(Error(Property.Name("DAV:", "valid-sync-token"))) + assertTrue(errors.contains(Error.VALID_SYNC_TOKEN)) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt new file mode 100644 index 0000000..b7c8efa --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/HttpUtilsTest.kt @@ -0,0 +1,25 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Test + +class HttpUtilsTest { + + @Test + fun testFilename() { + assertEquals("", HttpUtils.fileName("https://example.com".toHttpUrl())) + assertEquals("", HttpUtils.fileName("https://example.com/".toHttpUrl())) + assertEquals("file1", HttpUtils.fileName("https://example.com/file1".toHttpUrl())) + assertEquals("dir1", HttpUtils.fileName("https://example.com/dir1/".toHttpUrl())) + assertEquals("file2", HttpUtils.fileName("https://example.com/dir1/file2".toHttpUrl())) + assertEquals("dir2", HttpUtils.fileName("https://example.com/dir1/dir2/".toHttpUrl())) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt new file mode 100644 index 0000000..c965aa9 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/LocalChangesTest.kt @@ -0,0 +1,424 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import at.bitfire.dav4jvm.property.CurrentUserPrincipal +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Locale +import java.util.TimeZone + +/** + * Covers what Agendula changed while vendoring, and the two upstream defects + * we were expected to inherit. Everything else in this directory is + * upstream's own suite, unmodified. See `dav/PROVENANCE.md`. + */ +class LocalChangesTest { + + private val httpClient = OkHttpClient.Builder().followRedirects(false).build() + private val mockServer = MockWebServer() + + @Before fun startServer() = mockServer.start() + @After fun stopServer() = mockServer.shutdown() + + private fun redirect(code: Int, to: String) = MockResponse() + .setResponseCode(code) + .setHeader("Location", to) + + // -- change 3: dav4jvm#209, permanent redirects never reached the caller --- + + @Test + fun permanentRedirectIsReportedSoTheCallerCanPersistIt() { + mockServer.enqueue(redirect(301, "/moved/")) + mockServer.enqueue(MockResponse().setResponseCode(200)) + + val resource = DavResource(httpClient, mockServer.url("/dav/")) + resource.head { } + + assertEquals(mockServer.url("/moved/"), resource.permanentLocation) + } + + @Test + fun a308IsPermanentToo() { + mockServer.enqueue(redirect(308, "/moved/")) + mockServer.enqueue(MockResponse().setResponseCode(200)) + + val resource = DavResource(httpClient, mockServer.url("/dav/")) + resource.head { } + + assertEquals(mockServer.url("/moved/"), resource.permanentLocation) + } + + @Test + fun aTemporaryRedirectIsNotPersisted() { + mockServer.enqueue(redirect(302, "/elsewhere/")) + mockServer.enqueue(MockResponse().setResponseCode(200)) + + val resource = DavResource(httpClient, mockServer.url("/dav/")) + resource.head { } + + assertNull(resource.permanentLocation) + // The in-flight location still moves, as it must. + assertEquals(mockServer.url("/elsewhere/"), resource.location) + } + + @Test + fun permanentLocationDescribesOnlyTheRequestJustMade() { + // DavResource objects are reused. A value left over from an earlier + // request would have the caller persist a URL that a later one replaced, + // and every sync after that 404s. + mockServer.enqueue(redirect(301, "/moved/")) + mockServer.enqueue(MockResponse().setResponseCode(200)) + mockServer.enqueue(MockResponse().setResponseCode(200)) + + val resource = DavResource(httpClient, mockServer.url("/dav/")) + resource.head { } + assertEquals(mockServer.url("/moved/"), resource.permanentLocation) + + resource.head { } + assertNull(resource.permanentLocation) + } + + @Test + fun aTemporaryHopEndsThePermanentChain() { + // 301 -> 302 means "the resource moved to /moved/, and /moved/ is being + // served from /temp/ for now". Persisting /temp/ would be wrong. + mockServer.enqueue(redirect(301, "/moved/")) + mockServer.enqueue(redirect(302, "/temp/")) + mockServer.enqueue(MockResponse().setResponseCode(200)) + + val resource = DavResource(httpClient, mockServer.url("/dav/")) + resource.head { } + + assertEquals(mockServer.url("/moved/"), resource.permanentLocation) + assertEquals(mockServer.url("/temp/"), resource.location) + } + + // -- the "303 is not handled" claim, checked rather than assumed ----------- + + @Test + fun a303IsFollowed() { + // "Handles 301/302/307/308 but not 303" was listed as a defect we would + // inherit. It does not hold for this version: OkHttp's + // Response.isRedirect includes HTTP_SEE_OTHER, and followRedirects re-sends + // the same method, which is what RFC 6764 section 5 asks for in discovery. + mockServer.enqueue(redirect(303, "/other/")) + mockServer.enqueue(MockResponse().setResponseCode(200)) + + val resource = DavResource(httpClient, mockServer.url("/dav/")) + resource.head { } + + assertEquals(mockServer.url("/other/"), resource.location) + } + + // -- change 6: is parsed, not inferred --------------- + + private fun principalBody(inner: String) = MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody( + """ + /dav/ + $inner + HTTP/1.1 200 OK + + """.trimIndent(), + ) + + private fun principalOf(inner: String): CurrentUserPrincipal? { + mockServer.enqueue(principalBody(inner)) + var found: CurrentUserPrincipal? = null + DavResource(httpClient, mockServer.url("/dav/")) + .propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + found = response[CurrentUserPrincipal::class.java] + } + return found + } + + @Test + fun unauthenticatedIsDistinguishableFromAMerelyEmptyElement() { + // RFC 5397 §3: a 200 whose body says the request was not authenticated. + // Upstream parsed only , so this arrived identical to an empty + // element and to a server that omits the property — and a rejected + // credential then looks like a discovery that simply found nothing. + assertTrue(principalOf("")!!.unauthenticated) + + val empty = principalOf("") + assertNull(empty!!.href) + assertFalse(empty.unauthenticated) + } + + @Test + fun anOrdinaryPrincipalHrefStillParses() { + val principal = principalOf("/principals/me/") + assertEquals("/principals/me/", principal!!.href) + assertFalse(principal.unauthenticated) + } + + // -- change 1: commons-lang3 removed from date parsing -------------------- + + /** + * Runs [block] with a deliberately non-UTC default zone. + * + * The defect change 2 fixes *is* "the formatter used the default zone", so a + * test that does not move the default zone passes identically before and + * after the fix — and CI containers default to UTC. Without this the whole + * change is pinned by nothing. + */ + private fun inNonUtcZone(block: () -> T): T { + val previous = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")) // UTC+05:30 + try { + return block() + } finally { + TimeZone.setDefault(previous) + } + } + + @Test + fun formatDateEmitsGmtRatherThanLocalTimeLabelledGmt() = inNonUtcZone { + assertEquals( + "Wed, 21 Oct 2015 07:28:00 GMT", + HttpUtils.formatDate(java.util.Date(1445412480000L)), + ) + } + + @Test + fun parseDateAcceptsTheRfc7231Format() = inNonUtcZone { + assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time) + } + + @Test + fun parseDateHonoursAnExplicitOffsetInsteadOfMatchingGmtAsAPrefix() { + // Pattern 1 ends in the *literal* 'GMT', and SimpleDateFormat.parse is + // happy with a prefix — so without a full-consumption check the "+02:00" + // is discarded and the result is two hours out, which is the same class of + // error change 2 set out to remove. + assertEquals(1445405280000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT+02:00")!!.time) + assertEquals(1445430480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT-05:00")!!.time) + } + + @Test + fun parseDateRejectsTrailingGarbageRatherThanMatchingAPrefix() { + assertNull(HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT and then some")) + } + + @Test + fun parseDateAcceptsTheObsoleteFormats() { + // RFC 850, and ANSI C asctime() — both still emitted in the wild. + assertTrue(HttpUtils.parseDate("Wednesday, 21-Oct-15 07:28:00 GMT") != null) + assertTrue(HttpUtils.parseDate("Wed Oct 21 07:28:00 2015") != null) + } + + @Test + fun parseDateReturnsNullRatherThanThrowing() { + assertNull(HttpUtils.parseDate("not a date at all")) + } + + @Test + fun parseDateIsIndependentOfTheDefaultLocale() { + // SimpleDateFormat defaults to the platform locale, which would reject + // English month names on a Turkish or German device. + val locale = Locale.getDefault() + val zone = TimeZone.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")) + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Istanbul")) + assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time) + } finally { + Locale.setDefault(locale) + TimeZone.setDefault(zone) + } + } + + // --- change 7: a same-host downgrade is upgraded, a cross-host one is not --- + + /** + * A TLS MockWebServer, because the branch under test only exists on HTTPS. + * + * Same trap as change 2's: a test that runs against plain HTTP here would + * pass whatever the code did, since `location.isHttps` gates the whole + * decision. + */ + private fun httpsServer(): Pair { + val certificate = HeldCertificate.Builder() + .addSubjectAlternativeName("localhost") + .build() + val serverCertificates = HandshakeCertificates.Builder() + .heldCertificate(certificate) + .build() + val clientCertificates = HandshakeCertificates.Builder() + .addTrustedCertificate(certificate.certificate) + .build() + + val tlsServer = MockWebServer().apply { + useHttps(serverCertificates.sslSocketFactory(), false) + start() + } + val client = OkHttpClient.Builder() + .followRedirects(false) + .sslSocketFactory( + clientCertificates.sslSocketFactory(), + clientCertificates.trustManager, + ) + .build() + return tlsServer to client + } + + @Test + fun `a same-host redirect to http is retried over https`() { + // ⚠️ Exactly what a Nextcloud behind a proxy without `overwriteprotocol` + // sends for /.well-known/caldav. Refusing it makes a working CalDAV + // server report as "not a CalDAV server". + val (tls, client) = httpsServer() + val resource = DavResource(client, tls.url("/.well-known/caldav")) + val downgrade = "http://${tls.hostName}:${tls.port}/remote.php/dav/" + + tls.enqueue(MockResponse().setResponseCode(301).setHeader("Location", downgrade)) + tls.enqueue(MockResponse().setResponseCode(200)) + + resource.head { } + + tls.takeRequest() + val followed = tls.takeRequest() + assertEquals("/remote.php/dav/", followed.path) + // The scheme was put back; the host and path are the server's own. + assertEquals("https", resource.location.scheme) + assertEquals("/remote.php/dav/", resource.location.encodedPath) + } + + @Test + fun `a cross-host redirect to http is still refused`() { + val (tls, client) = httpsServer() + val resource = DavResource(client, tls.url("/dav/")) + tls.enqueue( + MockResponse().setResponseCode(301) + .setHeader("Location", "http://elsewhere.example.com/dav/"), + ) + + // No innocent reading of this one, so it stays fatal. + assertThrows(at.bitfire.dav4jvm.exception.DavException::class.java) { resource.head { } } + } + + // --- change 10: the Digest challenge beside a Basic one is read ---------- + + @Test + fun `a Basic challenge no longer hides the Digest one in the same header`() { + val handler = BasicDigestAuthHandler("example.com", "user", "pw") + val request = okhttp3.Request.Builder().url("https://cloud.example.com/dav/").build() + // The interceptor primes Basic preemptively over HTTPS, so by the time + // the first 401 arrives a Basic challenge is already cached — which is + // what made the abort fire on every server that offers both. + handler.authenticateRequest(request, null) + + val challenged = handler.authenticateRequest( + request, + run { + okhttp3.Response.Builder() + .request(request) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .addHeader("WWW-Authenticate", "Basic realm=\"dav\"") + .addHeader( + "WWW-Authenticate", + "Digest realm=\"dav\", nonce=\"abc\", qop=\"auth\"", + ) + .build() + }, + ) + + assertTrue(challenged!!.header("Authorization")!!.startsWith("Digest")) + } + + // --- change 11: the digest counter is per handler and per nonce ---------- + + @Test + fun `the nonce count restarts when the server issues a new nonce`() { + val handler = BasicDigestAuthHandler(null, "user", "pw") + val request = okhttp3.Request.Builder().url("https://example.com/dav/").build() + fun answer(nonce: String) = handler.digestRequest( + request, + okhttp3.Challenge( + "Digest", + mapOf("realm" to "dav", "nonce" to nonce, "qop" to "auth"), + ), + )!!.header("Authorization")!! + + assertTrue(answer("one").contains("nc=00000001")) + assertTrue(answer("one").contains("nc=00000002")) + // RFC 7616 3.4.1 counts requests sent with *that* nonce. + assertTrue(answer("two").contains("nc=00000001")) + } + + @Test + fun `two accounts do not interleave their nonce counts`() { + val request = okhttp3.Request.Builder().url("https://example.com/dav/").build() + val challenge = okhttp3.Challenge( + "Digest", + mapOf("realm" to "dav", "nonce" to "n", "qop" to "auth"), + ) + val first = BasicDigestAuthHandler(null, "one", "pw") + val second = BasicDigestAuthHandler(null, "two", "pw") + + first.digestRequest(request, challenge) + val theirs = second.digestRequest(request, challenge)!!.header("Authorization")!! + + assertTrue(theirs.contains("nc=00000001")) + } + + // --- change 12: qop list values are trimmed ----------------------------- + + @Test + fun `a spaced qop list still selects auth-int`() { + val handler = BasicDigestAuthHandler(null, "user", "pw") + val request = okhttp3.Request.Builder().url("https://example.com/dav/").build() + val header = handler.digestRequest( + request, + okhttp3.Challenge( + "Digest", + mapOf( + "realm" to "dav", + "nonce" to "n", + "qop" to "auth, auth-int", + ), + ), + )!!.header("Authorization")!! + + assertTrue(header.contains("qop=auth-int")) + } + + @Test + fun `a single spaced qop value does not fall back to RFC 2069`() { + val handler = BasicDigestAuthHandler(null, "user", "pw") + val request = okhttp3.Request.Builder().url("https://example.com/dav/").build() + val header = handler.digestRequest( + request, + okhttp3.Challenge( + "Digest", + mapOf("realm" to "dav", "nonce" to "n", "qop" to " auth"), + ), + )!!.header("Authorization")!! + + // The legacy branch emits no qop, nc or cnonce, which an RFC 7616 server + // rejects outright — a permanent 401 against a server behaving legally. + assertTrue(header.contains("qop=auth")) + assertTrue(header.contains("nc=")) + assertTrue(header.contains("cnonce=")) + } +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt new file mode 100644 index 0000000..7e7aa6c --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/QuotedStringUtilsTest.kt @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import org.junit.Assert.assertEquals +import org.junit.Test + +class QuotedStringUtilsTest { + + @Test + fun testAsQuotedString() { + assertEquals("\"\"", QuotedStringUtils.asQuotedString("")) + assertEquals("\"\\\"\"", QuotedStringUtils.asQuotedString("\"")) + assertEquals("\"\\\\\"", QuotedStringUtils.asQuotedString("\\")) + } + + fun testDecodeQuotedString() { + assertEquals("\"", QuotedStringUtils.decodeQuotedString("\"")) + assertEquals("\\", QuotedStringUtils.decodeQuotedString("\"\\\"")) + assertEquals("\"test", QuotedStringUtils.decodeQuotedString("\"test")) + assertEquals("test", QuotedStringUtils.decodeQuotedString("test")) + assertEquals("", QuotedStringUtils.decodeQuotedString("\"\"")) + assertEquals("test", QuotedStringUtils.decodeQuotedString("\"test\"")) + assertEquals("test\\", QuotedStringUtils.decodeQuotedString("\"test\\\"")) + assertEquals("test", QuotedStringUtils.decodeQuotedString("\"t\\e\\st\"")) + assertEquals("12\"34", QuotedStringUtils.decodeQuotedString("\"12\\\"34\"")) + assertEquals("1234\"", QuotedStringUtils.decodeQuotedString("\"1234\\\"\"")) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt new file mode 100644 index 0000000..466552c --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/UrlUtilsTest.kt @@ -0,0 +1,58 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.junit.Assert.* +import org.junit.Test + +class UrlUtilsTest { + + @Test + fun testEquals() { + assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!)) + assertTrue(UrlUtils.equals("http://host:80/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!)) + assertTrue(UrlUtils.equals("https://HOST:443/resource".toHttpUrlOrNull()!!, "https://host/resource".toHttpUrlOrNull()!!)) + assertTrue(UrlUtils.equals("https://host:443/my@dav/".toHttpUrlOrNull()!!, "https://host/my%40dav/".toHttpUrlOrNull()!!)) + assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource#frag1".toHttpUrlOrNull()!!)) + + // should work, but currently doesn't (see MR #5) + // assertTrue(UrlUtils.equals(HttpUrl.parse("https://host/%5bresource%5d/")!!, HttpUrl.parse("https://host/[resource]/")!!)) + + assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource/".toHttpUrlOrNull()!!)) + assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host:81/resource".toHttpUrlOrNull()!!)) + } + + @Test + fun testHostToDomain() { + assertNull(UrlUtils.hostToDomain(null)) + assertEquals("", UrlUtils.hostToDomain(".")) + assertEquals("com", UrlUtils.hostToDomain("com")) + assertEquals("com", UrlUtils.hostToDomain("com.")) + assertEquals("example.com", UrlUtils.hostToDomain("example.com")) + assertEquals("example.com", UrlUtils.hostToDomain("example.com.")) + assertEquals("example.com", UrlUtils.hostToDomain(".example.com")) + assertEquals("example.com", UrlUtils.hostToDomain(".example.com.")) + assertEquals("example.com", UrlUtils.hostToDomain("host.example.com")) + assertEquals("example.com", UrlUtils.hostToDomain("host.example.com.")) + assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com")) + assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com.")) + } + + @Test + fun testOmitTrailingSlash() { + assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource".toHttpUrlOrNull()!!)) + assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!)) + } + + @Test + fun testWithTrailingSlash() { + assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource".toHttpUrlOrNull()!!)) + assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!)) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt new file mode 100644 index 0000000..b29488f --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/XmlUtilsTest.kt @@ -0,0 +1,97 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader + +class XmlUtilsTest { + + @Test + fun testProcessTagRoot() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("")) + // now on START_DOCUMENT [0] + + var processed = false + XmlUtils.processTag(parser, Property.Name("", "test")) { + processed = true + } + assertTrue(processed) + } + + @Test + fun testProcessTagDepth1() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("")) + parser.next() // now on START_TAG + + var processed = false + XmlUtils.processTag(parser, Property.Name("", "test")) { + processed = true + } + assertTrue(processed) + } + + @Test + fun testReadText() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 1Test 2")) + parser.next() + parser.next() // now on START_TAG + + assertEquals("Test 1", XmlUtils.readText(parser)) + assertEquals(XmlPullParser.END_TAG, parser.eventType) + parser.next() + + assertEquals("Test 2", XmlUtils.readText(parser)) + assertEquals(XmlPullParser.END_TAG, parser.eventType) + } + + @Test + fun testReadTextCDATA() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 2]]>")) + parser.next() // now on START_TAG + + assertEquals("Test 1Test 2", XmlUtils.readText(parser)) + assertEquals(XmlPullParser.END_TAG, parser.eventType) + } + + @Test + fun testReadTextPropertyRoot() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 1Test 2")) + parser.next() // now on START_TAG + + val entries = mutableListOf() + XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries) + assertEquals("Test 1", entries[0]) + assertEquals("Test 2", entries[1]) + + parser.next() // END_TAG + assertEquals(XmlPullParser.END_DOCUMENT, parser.eventType) + } + + @Test + fun testReadTextPropertyListDepth1() { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("Test 1Test 2")) + parser.next() // now on START_TAG [1] + + val entries = mutableListOf() + XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries) + assertEquals("Test 1", entries[0]) + assertEquals("Test 2", entries[1]) + assertEquals(XmlPullParser.END_TAG, parser.eventType) + assertEquals("test", parser.name) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt new file mode 100644 index 0000000..071c953 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/DavExceptionTest.kt @@ -0,0 +1,176 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.property.ResourceType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +class DavExceptionTest { + + private val httpClient = OkHttpClient.Builder() + .followRedirects(false) + .build() + private val mockServer = MockWebServer() + private fun sampleUrl() = mockServer.url("/dav/") + + @Before + fun startServer() = mockServer.start() + + @After + fun stopServer() = mockServer.shutdown() + + + /** + * Test truncation of a too large plain text request in [DavException]. + */ + @Test + fun testRequestLargeTextError() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + val builder = StringBuilder() + builder.append(CharArray(DavException.MAX_EXCERPT_SIZE+100) { '*' }) + val body = builder.toString() + + val e = DavException("Error with large request body", null, Response.Builder() + .request(Request.Builder() + .url("http://example.com") + .post(body.toRequestBody("text/plain".toMediaType())) + .build()) + .protocol(Protocol.HTTP_1_1) + .code(204) + .message("No Content") + .build()) + + assertTrue(e.errors.isEmpty()) + assertEquals( + body.substring(0, DavException.MAX_EXCERPT_SIZE), + e.requestBody + ) + } + + /** + * Test a large HTML response which has a multi-octet UTF-8 character + * exactly at the cut-off position. + */ + @Test + fun testResponseLargeTextError() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + val builder = StringBuilder() + builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1) { '*' }) + builder.append("\u03C0") // Pi + val body = builder.toString() + + mockServer.enqueue(MockResponse() + .setResponseCode(404) + .setHeader("Content-Type", "text/html") + .setBody(body)) + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(e.code, 404) + assertTrue(e.errors.isEmpty()) + assertEquals( + body.substring(0, DavException.MAX_EXCERPT_SIZE-1), + e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1) + ) + } + } + + @Test + fun testResponseNonTextError() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(403) + .setHeader("Content-Type", "application/octet-stream") + .setBody("12345")) + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(e.code, 403) + assertTrue(e.errors.isEmpty()) + assertNull(e.responseBody) + } + } + + @Test + fun testSerialization() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + mockServer.enqueue(MockResponse() + .setResponseCode(500) + .setHeader("Content-Type", "text/plain") + .setBody("12345")) + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected DavException") + } catch (e: DavException) { + val baos = ByteArrayOutputStream() + val oos = ObjectOutputStream(baos) + oos.writeObject(e) + + val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray())) + val e2 = ois.readObject() as HttpException + assertEquals(500, e2.code) + assertTrue(e2.responseBody!!.contains("12345")) + } + } + + /** + * Test precondition XML element (sample from RFC 4918 16) + */ + @Test + fun testXmlError() { + val url = sampleUrl() + val dav = DavResource(httpClient, url) + + val body = "\n" + + "\n" + + " \n" + + " /workspace/webdav/\n" + + " \n" + + "\n" + mockServer.enqueue(MockResponse() + .setResponseCode(423) + .setHeader("Content-Type", "application/xml; charset=\"utf-8\"") + .setBody(body)) + try { + dav.propfind(0, ResourceType.NAME) { _, _ -> } + fail("Expected HttpException") + } catch (e: HttpException) { + assertEquals(e.code, 423) + assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "lock-token-submitted") }) + assertEquals(body, e.responseBody) + } + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt new file mode 100644 index 0000000..f66a4cf --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/HttpExceptionTest.kt @@ -0,0 +1,43 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertTrue +import org.junit.Test + +class HttpExceptionTest { + + private val responseMessage = "Unknown error" + + @Test + fun testHttpFormatting() { + val request = Request.Builder() + .post("REQUEST\nBODY".toRequestBody("text/something".toMediaType())) + .url("http://example.com") + .build() + + val response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(500) + .message(responseMessage) + .body("SERVER\r\nRESPONSE".toResponseBody("text/something-other".toMediaType())) + .build() + val e = HttpException(response) + assertTrue(e.message!!.contains("500")) + assertTrue(e.message!!.contains(responseMessage)) + assertTrue(e.requestBody!!.contains("REQUEST\nBODY")) + assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE")) + } + +} diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt new file mode 100644 index 0000000..5294641 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/exception/ServiceUnavailableExceptionTest.kt @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package at.bitfire.dav4jvm.exception + +import at.bitfire.dav4jvm.HttpUtils +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.* +import org.junit.Test +import java.util.* +import kotlin.math.abs + +class ServiceUnavailableExceptionTest { + + val response503 = Response.Builder() + .request(Request.Builder() + .url("http://www.example.com") + .get() + .build()) + .protocol(Protocol.HTTP_1_1) + .code(503).message("Try later") + .build() + + @Test + fun testRetryAfter_NoTime() { + val e = ServiceUnavailableException(response503) + assertNull(e.retryAfter) + } + + @Test + fun testRetryAfter_Seconds() { + val response = response503.newBuilder() + .header("Retry-After", "120") + .build() + val e = ServiceUnavailableException(response) + assertNotNull(e.retryAfter) + assertTrue(withinTimeRange(e.retryAfter!!, 120)) + } + + @Test + fun testRetryAfter_Date() { + val cal = Calendar.getInstance() + cal.add(Calendar.MINUTE, 30) + val response = response503.newBuilder() + .header("Retry-After", HttpUtils.formatDate(cal.time)) + .build() + val e = ServiceUnavailableException(response) + assertNotNull(e.retryAfter) + assertTrue(withinTimeRange(e.retryAfter!!, 30*60)) + } + + + private fun withinTimeRange(d: Date, seconds: Int): Boolean { + val msCheck = d.time + val msShouldBe = Date().time + seconds*1000 + // assume max. 5 seconds difference for test running + return abs(msCheck - msShouldBe) < 5000 + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt new file mode 100644 index 0000000..c5ebd19 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/CalendarDescriptionTest.kt @@ -0,0 +1,15 @@ +package at.bitfire.dav4jvm.property + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CalendarDescriptionTest: PropertyTest() { + + @Test + fun testCalendarDescription() { + val results = parseProperty("My Calendar") + val result = results.first() as CalendarDescription + assertEquals("My Calendar", result.description) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt new file mode 100644 index 0000000..0dd971c --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/GetETagTest.kt @@ -0,0 +1,48 @@ +package at.bitfire.dav4jvm.property + +import org.junit.Assert.* +import org.junit.Test + +class GetETagTest: PropertyTest() { + + @Test + fun testGetETag_NoText() { + val results = parseProperty("") + val getETag = results.first() as GetETag + assertNull(getETag.eTag) + assertNull(getETag.weak) + } + + @Test + fun testGetETag_Strong() { + val results = parseProperty("\"Correct strong ETag\"") + val getETag = results.first() as GetETag + assertEquals("Correct strong ETag", getETag.eTag) + assertFalse(getETag.weak!!) + } + + @Test + fun testGetETag_Strong_NoQuotes() { + val results = parseProperty("Strong ETag without quotes") + val getETag = results.first() as GetETag + assertEquals("Strong ETag without quotes", getETag.eTag) + assertFalse(getETag.weak!!) + } + + @Test + fun testGetETag_Weak() { + val results = parseProperty("W/\"Correct weak ETag\"") + val getETag = results.first() as GetETag + assertEquals("Correct weak ETag", getETag.eTag) + assertTrue(getETag.weak!!) + } + + @Test + fun testGetETag_Weak_NoQuotes() { + val results = parseProperty("W/Weak ETag without quotes") + val getETag = results.first() as GetETag + assertEquals("Weak ETag without quotes", getETag.eTag) + assertTrue(getETag.weak!!) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt new file mode 100644 index 0000000..39584f8 --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/OwnerTest.kt @@ -0,0 +1,30 @@ +package at.bitfire.dav4jvm.property + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class OwnerTest: PropertyTest() { + + @Test + fun testOwner_PlainText() { + val results = parseProperty("https://example.com") + val owner = results.first() as Owner + assertNull(owner.href) + } + + @Test + fun testOwner_PlainTextAndHref() { + val results = parseProperty("Principal Name. mailto:owner@example.com (test)") + val owner = results.first() as Owner + assertEquals("mailto:owner@example.com", owner.href) + } + + @Test + fun testOwner_Href() { + val results = parseProperty("https://example.com") + val owner = results.first() as Owner + assertEquals("https://example.com", owner.href) + } + +} \ No newline at end of file diff --git a/dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt new file mode 100644 index 0000000..29d88de --- /dev/null +++ b/dav/src/test/kotlin/at/bitfire/dav4jvm/property/PropertyTest.kt @@ -0,0 +1,20 @@ +package at.bitfire.dav4jvm.property + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.XmlUtils +import java.io.StringReader + +open class PropertyTest { + + companion object { + + fun parseProperty(s: String): List { + val parser = XmlUtils.newPullParser() + parser.setInput(StringReader("$s")) + parser.nextTag() // move into + return Property.parse(parser) + } + + } + +} \ No newline at end of file diff --git a/design/store/icon/agendula-icon.png b/design/store/icon/agendula-icon.png new file mode 100644 index 0000000..8fc86e1 Binary files /dev/null and b/design/store/icon/agendula-icon.png differ diff --git a/design/store/icon/agendula-icon.svg b/design/store/icon/agendula-icon.svg new file mode 100644 index 0000000..ede60ab --- /dev/null +++ b/design/store/icon/agendula-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/design/store/icon/agendula-icon@1024.png b/design/store/icon/agendula-icon@1024.png new file mode 100644 index 0000000..9f18ee3 Binary files /dev/null and b/design/store/icon/agendula-icon@1024.png differ diff --git a/design/store/sample/Groceries.ics b/design/store/sample/Groceries.ics new file mode 100644 index 0000000..61fadb3 --- /dev/null +++ b/design/store/sample/Groceries.ics @@ -0,0 +1,52 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Agendula//store sample//EN +X-WR-CALNAME:Groceries +BEGIN:VTODO +UID:sample-groceries-0 +DTSTAMP:20260923T080000Z +SUMMARY:Oat milk +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-groceries-1 +DTSTAMP:20260923T080000Z +SUMMARY:Eggs +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-groceries-2 +DTSTAMP:20260923T080000Z +SUMMARY:Cherry tomatoes +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-groceries-3 +DTSTAMP:20260923T080000Z +SUMMARY:Fresh basil +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-groceries-4 +DTSTAMP:20260923T080000Z +SUMMARY:Coffee beans +PRIORITY:1 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-groceries-5 +DTSTAMP:20260923T080000Z +SUMMARY:Sourdough bread +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +BEGIN:VTODO +UID:sample-groceries-6 +DTSTAMP:20260923T080000Z +SUMMARY:Parmesan +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +END:VCALENDAR diff --git a/design/store/sample/Home.ics b/design/store/sample/Home.ics new file mode 100644 index 0000000..17a5afe --- /dev/null +++ b/design/store/sample/Home.ics @@ -0,0 +1,54 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Agendula//store sample//EN +X-WR-CALNAME:Home +BEGIN:VTODO +UID:sample-home-0 +DTSTAMP:20260923T080000Z +SUMMARY:Take out the recycling +DUE;VALUE=DATE:20260924 +DTSTART;VALUE=DATE:20260924 +RRULE:FREQ=WEEKLY;BYDAY=TH +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-home-1 +DTSTAMP:20260923T080000Z +SUMMARY:Fix the leaky tap +DUE;VALUE=DATE:20260927 +DESCRIPTION:Washer size is 1/2". Turn off the water under the sink first. +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-home-2 +DTSTAMP:20260923T080000Z +SUMMARY:Pay the rent +DUE;VALUE=DATE:20261001 +DTSTART;VALUE=DATE:20261001 +PRIORITY:1 +RRULE:FREQ=MONTHLY;BYMONTHDAY=1 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-home-3 +DTSTAMP:20260923T080000Z +SUMMARY:Book the car service +DUE;VALUE=DATE:20261001 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-home-4 +DTSTAMP:20260923T080000Z +SUMMARY:Clean the gutters +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-home-5 +DTSTAMP:20260923T080000Z +SUMMARY:Change the bed sheets +DUE;VALUE=DATE:20260923 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +END:VCALENDAR diff --git a/design/store/sample/Personal.ics b/design/store/sample/Personal.ics new file mode 100644 index 0000000..3b4eb66 --- /dev/null +++ b/design/store/sample/Personal.ics @@ -0,0 +1,108 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Agendula//store sample//EN +X-WR-CALNAME:Personal +BEGIN:VTODO +UID:sample-personal-0 +DTSTAMP:20260923T080000Z +SUMMARY:Renew passport +DUE;VALUE=DATE:20260923 +PRIORITY:1 +DESCRIPTION:Photos are in the desk drawer. Book a slot at the town hall first. +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-1 +DTSTAMP:20260923T080000Z +SUMMARY:Call the dentist +DUE;VALUE=DATE:20260923 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-2 +DTSTAMP:20260923T080000Z +SUMMARY:Water the plants +DUE;VALUE=DATE:20260923 +DTSTART;VALUE=DATE:20260923 +RRULE:FREQ=DAILY;INTERVAL=3 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-3 +DTSTAMP:20260923T080000Z +SUMMARY:Go for a run +DUE;VALUE=DATE:20260923 +DTSTART;VALUE=DATE:20260923 +RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-4 +DTSTAMP:20260923T080000Z +SUMMARY:Cancel the old gym membership +DUE;VALUE=DATE:20260921 +PRIORITY:1 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-5 +DTSTAMP:20260923T080000Z +SUMMARY:Birthday gift for Sam +DUE;VALUE=DATE:20260926 +PERCENT-COMPLETE:33 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-5-0 +DTSTAMP:20260923T080000Z +SUMMARY:Order the book +RELATED-TO:sample-personal-5 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +BEGIN:VTODO +UID:sample-personal-5-1 +DTSTAMP:20260923T080000Z +SUMMARY:Wrap it +RELATED-TO:sample-personal-5 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-5-2 +DTSTAMP:20260923T080000Z +SUMMARY:Write a card +RELATED-TO:sample-personal-5 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-6 +DTSTAMP:20260923T080000Z +SUMMARY:Plan the weekend hike +DUE;VALUE=DATE:20260927 +LOCATION:Saxon Switzerland +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-7 +DTSTAMP:20260923T080000Z +SUMMARY:Read "The Midnight Library" +PERCENT-COMPLETE:40 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-8 +DTSTAMP:20260923T080000Z +SUMMARY:Back up the photos +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-personal-9 +DTSTAMP:20260923T080000Z +SUMMARY:Pick up dry cleaning +DUE;VALUE=DATE:20260922 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +END:VCALENDAR diff --git a/design/store/sample/Work.ics b/design/store/sample/Work.ics new file mode 100644 index 0000000..25ec4d6 --- /dev/null +++ b/design/store/sample/Work.ics @@ -0,0 +1,96 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Agendula//store sample//EN +X-WR-CALNAME:Work +BEGIN:VTODO +UID:sample-work-0 +DTSTAMP:20260923T080000Z +SUMMARY:Send the Q3 report to Lena +DUE;VALUE=DATE:20260922 +PRIORITY:1 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-1 +DTSTAMP:20260923T080000Z +SUMMARY:Review Alex's pull request +DUE;VALUE=DATE:20260923 +PRIORITY:5 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-2 +DTSTAMP:20260923T080000Z +SUMMARY:Weekly team sync notes +DUE;VALUE=DATE:20260924 +DTSTART;VALUE=DATE:20260924 +RRULE:FREQ=WEEKLY;BYDAY=TH +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-3 +DTSTAMP:20260923T080000Z +SUMMARY:Prepare slides for the quarterly review +DUE;VALUE=DATE:20260925 +PRIORITY:5 +PERCENT-COMPLETE:50 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-3-0 +DTSTAMP:20260923T080000Z +SUMMARY:Collect the numbers +RELATED-TO:sample-work-3 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +BEGIN:VTODO +UID:sample-work-3-1 +DTSTAMP:20260923T080000Z +SUMMARY:Draft the outline +RELATED-TO:sample-work-3 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +BEGIN:VTODO +UID:sample-work-3-2 +DTSTAMP:20260923T080000Z +SUMMARY:Design the charts +RELATED-TO:sample-work-3 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-3-3 +DTSTAMP:20260923T080000Z +SUMMARY:Rehearse once +RELATED-TO:sample-work-3 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-4 +DTSTAMP:20260923T080000Z +SUMMARY:Book flights to the Lisbon conference +DUE;VALUE=DATE:20260929 +LOCATION:Lisbon +URL:https://example.org/conference +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-5 +DTSTAMP:20260923T080000Z +SUMMARY:Update the onboarding docs +PRIORITY:9 +STATUS:NEEDS-ACTION +END:VTODO +BEGIN:VTODO +UID:sample-work-6 +DTSTAMP:20260923T080000Z +SUMMARY:Plan the team offsite +DUE;VALUE=DATE:20260922 +STATUS:COMPLETED +PERCENT-COMPLETE:100 +COMPLETED:20260923T080000Z +END:VTODO +END:VCALENDAR diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index d7c562f..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,263 +0,0 @@ -# Agendula — architecture - -This document describes how Agendula is built **as it stands today**. For the -*why* behind the big decisions and the long-term plan, see -[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md). - ---- - -## 1. The thesis in one sentence - -Agendula is a Material 3 Expressive **front-end** over the OpenTasks -`TaskContract` provider — it reads, writes, and reminds on top of a tasks store -that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over -CalDAV. **Agendula owns no database and no sync stack.** It is the task-list -sibling to [Calendula](https://codeberg.org/jlmakiola/calendula), -which does the same thing for `CalendarContract`. - -The whole design hangs off one rule: - -> The entire app talks to a `TasksRepository`. Only the data layer knows there -> is a `ContentResolver`, a `TaskContract`, or an authority string behind it. -> **Provider column names and the authority string never leak above the data -> layer.** - -This is what lets "Posture A" (front-end over an installed provider) become -"Posture B" (bundle the Apache-2.0 provider, be self-contained) without touching -the UI, the ViewModels, or the domain. See §7. - ---- - -## 2. Layers - -``` - ┌──────────────────────────────────────────────┐ - UI │ Compose screens + ViewModels (ui/*) │ - │ RootScreen → permission gate → ListsScreen │ - └───────────────┬──────────────────────────────┘ - │ domain models + Flows only - ┌───────────────▼──────────────────────────────┐ - Domain │ Models, TaskForm, TaskFilter, TaskSorting, │ - │ DayWindow (pure Kotlin, no Android) │ - └───────────────┬──────────────────────────────┘ - │ TasksRepository (interface) - ┌───────────────▼──────────────────────────────┐ - Data │ TasksRepositoryImpl │ - │ └ TasksDataSource (interface) │ - │ └ AndroidTasksDataSource │ - │ └ ContentResolver / TaskContract / │ - │ ProviderResolver / ContentObserver│ - │ reminders/ prefs/ di/ demo/ │ - └───────────────┬──────────────────────────────┘ - │ content:// + dangerous perms - ┌───────────────▼──────────────────────────────┐ - External │ OpenTasks provider ←sync← DAVx5 / DecSync… │ - └──────────────────────────────────────────────┘ -``` - -The seam that matters is the pair of interfaces in the data layer: - -- **`TasksRepository`** — the only type the UI sees. Flow-based reads, suspend - writes. (`data/tasks/TasksRepository.kt`) -- **`TasksDataSource`** — the JVM-testable interface that does the actual - provider work; `AndroidTasksDataSource` is the only Android-coupled - implementation. - -Both are bound in Hilt in `data/di/DataModule.kt`. - ---- - -## 3. Module & package layout - -Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`. - -| Package | Contents | -|---|---| -| `domain/` | `Models` (TaskList, Task, TaskDetail, enums + pure iCal↔domain value mappers), `TaskForm` (validated create/edit), `TaskFilter` + `TaskFiltering` (smart lists), `TaskSorting`, `DayWindow` (local-midnight maths). No Android imports. | -| `data/tasks/` | `TasksContract` (vendored subset), `ProviderResolver` (the A/B seam), `TaskProjections`, `ColumnReader`, `TaskMapper` (cursor→domain), `TaskWriteMapper` (form→`ContentValues`), `TasksDataSource` + `AndroidTasksDataSource`, `TasksRepository` + `Impl`, `Failures`. | -| `data/reminders/` | `ReminderScheduler` (the self-scheduled engine), `DueReminderReceiver`, `BootReceiver`, `ProviderChangeReceiver`, `ScheduledReminderStore`, `TaskNotifier`. | -| `data/prefs/` | `SettingsPrefs` (DataStore). | -| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). | -| `data/demo/` | `DemoSeeder` (debug-only sample data). | -| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | -| root | `AgendulaApp` (Hilt app), `MainActivity`. | - ---- - -## 4. The data layer (the heart) - -### 4.1 Provider targeting — `ProviderResolver` - -`ProviderResolver.resolve()` walks a preference-ordered candidate list and -returns the first provider actually installed (via -`PackageManager.resolveContentProvider`), or `null` if none is. Each candidate -is a `TaskProvider(authority, readPermission, writePermission, packageName)`. - -| Provider | Authority | Permissions | -|---|---|---| -| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | -| tasks.org | `org.tasks.opentasks` | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | - -Both are backed by the same dmfs `TaskProvider`, so the **same `TaskContract` -columns apply** regardless of which is present. `null` from `resolve()` drives -the "install a tasks provider" onboarding gate. `hasPermission()` checks both -runtime perms for the active provider. - -### 4.2 `TasksContract` - -A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, -table paths, status/priority constants, the local-account type. Agendula does -**not** take a runtime dependency on OpenTasks; the authority is injected from -`ProviderResolver`, never hardcoded in the contract. - -### 4.3 Reads — Instances + `ContentObserver` - -`AndroidTasksDataSource` queries the denormalized **instances** view (so each -occurrence is a row with the joined list colour, account, etc.), maps each -cursor row through `ColumnReader` → `TaskMapper` → domain `Task`, and exposes -the result as a `Flow`. A `ContentObserver` on the active authority's -Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change -re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks) -update the UI live, and multiple sync sources coexist in one list. - -### 4.4 Writes — repository API - -```kotlin -interface TasksRepository { - fun taskLists(): Flow> - fun tasks(filter: TaskFilter): Flow> - fun taskDetail(taskId: Long): Flow - - suspend fun createTask(form: TaskForm): Long - suspend fun updateTask(taskId: Long, form: TaskForm) - suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture - suspend fun deleteTask(taskId: Long) - suspend fun createLocalList(name: String, color: Int): Long - - fun providerStatus(): ProviderStatus // READY | NEEDS_PERMISSION | NO_PROVIDER -} -``` - -`TaskWriteMapper` turns a validated `TaskForm` into `ContentValues`. Completion -sets `STATUS = COMPLETED` (+ percent/completed timestamp); DAVx5 syncs that back -out as a normal VTODO status change. Writes to local/unsynced lists use the -sync-adapter URI form where the provider requires it. - -### 4.5 Domain model notes - -- `Task.id` is the **instance** row id; `Task.taskId` is the underlying - `tasks._id` and the stable target for edits/completion. -- Subtasks are carried via `parentId` (`RELATED-TO` / `RELATION_TYPE_PARENT`); - `TaskDetail` bundles a task with its direct children. -- `effectiveColor` = the task's own colour, else the list colour. -- iCal priority is bucketed to `NONE/LOW/MEDIUM/HIGH`; status maps to a - 4-value enum. Both mappings are pure functions in `Models.kt`, unit-tested. - ---- - -## 5. Smart lists, filtering, sorting - -`TaskFilter` is either `OfList(listId)` or `Smart(SmartList)`. The smart lists — -`ALL, TODAY, UPCOMING, OVERDUE, NO_DATE, COMPLETED` — are computed from due -dates, not membership. `TaskFiltering.matches()` is a **pure predicate** taking -`todayStart`/`todayEnd` (local-midnight bounds from `DayWindow`), so it -unit-tests with a fixed clock. `TaskSorting` orders within a list (due / -priority / etc.). None of this touches Android, which is why it's all in -`domain/`. - ---- - -## 6. Reminders — the one subsystem that does NOT mirror Calendula - -Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks -providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`): - -- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks - within a rolling **30-day window**, computes each trigger as `due − lead` - (lead from `SettingsPrefs`), and **diffs against `ScheduledReminderStore`** so - only changed alarms move. It bails and clears everything if the provider is - absent or unpermissioned. -- Alarms are exact where allowed (`setExactAndAllowWhileIdle`, falling back to - `set` when `canScheduleExactAlarms()` is false), keyed by `taskId`. -- **`DueReminderReceiver`** fires → posts via `TaskNotifier` (channel, - `POST_NOTIFICATIONS` gate, dedupe-by-tag). -- **Re-sync triggers:** app start, **`BootReceiver`** (re-arm after reboot), and - **`ProviderChangeReceiver`** (`PROVIDER_CHANGED` on both authorities → external - sync changed the data). The store lets each run diff like Calendula diffs - reminder rows. - -This is the single largest piece of genuinely-new code in Agendula. - ---- - -## 7. The A / B seam (why the layering is shaped this way) - -- **Posture A (today):** front-end over whatever provider is installed. Ships - fast; requires a provider app present (the "needs DAVx5/OpenTasks" onboarding - moment). -- **Posture B (later):** add a `:provider` module bundling the Apache-2.0 - `opentasks-provider`. `ProviderResolver` then finds **our own** `org.dmfs.tasks` - first; external CalDAV engines sync directly into it. **The UI, ViewModels, - domain, and `TasksRepository` do not change** — only the resolver's default and - some manifest perms. - -Bundling the provider bundles **storage, not sync** — Agendula stays a pure -front-end over open backends either way. - ---- - -## 8. UI - -Compose + Material 3 **Expressive** (`MaterialExpressiveTheme`, -`MotionScheme.standard()`, dynamic colour with a hand-tuned warm-mauve -fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`, -`edit`, `settings`, `permission`) has a ViewModel + immutable `UiState`; -`ListsScreen` is the first rendered surface. - -`RootScreen` is the entry composable: it gates on `ProviderStatus` -(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → -`ListsScreen`). The remaining screens are being built one at a time — their -ViewModels exist and are tested against the real data layer; navigation -callbacks are currently stubs (see [`ROADMAP.md`](ROADMAP.md)). Follow the -`material-3` skill for component choices (M3 `ListItem` rows, expressive -checkbox/FAB/swipe motion). - ---- - -## 9. Dependency injection - -Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module -(`TasksDataSource` → `AndroidTasksDataSource`, `TasksRepository` → -`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore, -the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point; -`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. - ---- - -## 10. Build & tooling - -| | | -|---|---| -| Build | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, Java 17 | -| SDK | compileSdk 37, minSdk 29 (Android 10), targetSdk 36 | -| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | -| Other | DataStore, kotlinx-datetime, kotlinx-coroutines | -| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | -| Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). | -| CI | Split by forge: `.forgejo/workflows/ci.yaml` on Codeberg (canonical, no secrets), `.gitea/workflows/release.yaml` on Gitea (all secrets). See [`RELEASING.md`](RELEASING.md). | -| Distribution | F-Droid (`fdroid-metadata/`) + Codeberg release APKs | - ---- - -## 11. Manifest surface - -- **Permissions:** both `org.dmfs.*` and `org.tasks.*` read/write tasks perms - declared statically (the active set is requested at runtime); - `POST_NOTIFICATIONS`, `RECEIVE_BOOT_COMPLETED`, exact-alarm - (`USE_EXACT_ALARM` on 33+, `SCHEDULE_EXACT_ALARM` ≤32). -- **``** for package visibility: both provider authorities + a LAUNCHER - intent (so `resolveContentProvider` works and onboarding can open the - provider / a store listing). -- **Receivers:** `DueReminderReceiver` (not exported), `BootReceiver`, - `ProviderChangeReceiver` (both authorities). No `EVENT_REMINDER` receiver — - that's a Calendula thing that doesn't apply here. diff --git a/docs/PLAN.md b/docs/PLAN.md deleted file mode 100644 index 4852655..0000000 --- a/docs/PLAN.md +++ /dev/null @@ -1,322 +0,0 @@ -# Agendula — implementation plan - -> A modern Material 3 Expressive **task** app for Android. Reads, writes, and -> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / -> DecSync over CalDAV), with no own sync stack. -> -> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`; -> Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The -> name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula` -> ending — and a Calendula flower head is itself a cluster of small *florets*, -> so the two apps are florets of one bloom. - -Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula** -(renamed from the working title *Floret*, which was promoted to the shared -family / design-language name). - ---- - -## 0. The thesis this app embodies - -A nice M3-Expressive front end over open backends, no reinvented storage or -sync. Calendula proved the pattern against the OS calendar provider. Agendula -applies it to tasks. The crucial difference: **there is no OS tasks provider**, -so we depend on a tasks *provider app* being present — exactly as Calendula -depends on a sync app like DAVx5 for CalDAV. - -### Posture A now, Posture B long-term (locked decision) - -- **A (this plan):** pure front-end over whatever tasks provider is installed - (OpenTasks / tasks.org / jtx). Fast to ship; requires a provider app present. -- **B (later):** bundle the Apache-2.0 `opentasks-provider` so the app is - self-contained (owns the `org.dmfs.tasks` authority + `org.dmfs.permission.*`, - DAVx5 syncs directly into it). Bundling the provider bundles **storage, not - sync** — external CalDAV engines still feed it, which keeps us true to the - thesis. - -**The one rule that makes B additive instead of a rewrite:** the entire app -talks to a `TasksRepository`; only *one* class (`OpenTasksDataSource`) knows -about a `ContentResolver`, `TaskContract`, or an authority string, and it -resolves its authority at **runtime** via `ProviderResolver`. In A the resolver -finds the installed provider; in B it finds our own bundled one. Repo + UI + -domain never change. **Never let `TaskContract` column names or the authority -string leak above the data layer.** - ---- - -## 1. What transfers from Calendula - -Calendula's layering is the template. Lift these **verbatim or near-verbatim**: - -| Area | From Calendula | Change for Agendula | -|---|---|---| -| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only | -| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical | -| Theme | `ui/theme/Theme.kt` (`MaterialExpressiveTheme`, `MotionScheme.standard()`, dynamic color + hand-tuned fallback), `Type.kt`, `Color.kt` | reseed fallback palette | -| DI shape | `data/di/DataModule.kt` (`DataBindModule` + `DataProvideModule`), `@IoDispatcher` qualifier, DataStore wiring | rename store | -| Data seam pattern | `CalendarRepository` (Flow API) + `CalendarRepositoryImpl` wrapping a `CalendarDataSource` interface + `AndroidCalendarDataSource` (Cursor/ContentObserver), `Projections`, `ColumnReader`, mappers | retarget to `TaskContract` | -| Reactive flows | `ContentObserver` → `callbackFlow` bridge in the data source | observe tasks URI | -| Notifications | `ReminderNotifier` (channel, POST_NOTIFICATIONS gate, dedupe by tag) | reuse almost as-is | -| Prefs | `data/prefs/SettingsPrefs`, DataStore | reuse | -| Settings/onboarding/permission UI | `ui/settings`, `ui/permission` (`OnboardingScaffold`, `PermissionScreen`) | adapt copy + permissions | -| Widgets | Glance `widget/` scaffolding (receiver + `PROVIDER_CHANGED` refresh) | task-list widget | -| Common UI | `ui/common/*` (GroupedList, InlineTextField, OptionCard, ColorSwatchRow, FailureView, transitions) | reuse | -| Test stack | JUnit5 + Truth + Turbine + coroutines-test; data source as a JVM-testable seam | reuse | - -**Net: ~70–80% of the scaffolding is a copy.** The genuinely new code is the -`TaskContract` data layer, the task screens, and a **self-scheduled reminder -engine** (see §6 — the one place Calendula's pattern does *not* carry over). - -### What does NOT exist here (why Agendula is simpler than Calendula) - -No month/week/day grid rendering. No recurrence-scoped writes ("this & following" -vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly- -nested list with a due date and a checkbox. - ---- - -## 2. Module & package layout - -Single `:app` module for A (mirrors Calendula). Package root -`de.jeanlucmakiola.agendula`. - -``` -domain/ - Models.kt TaskList, Task, TaskStatus, Priority, SubtaskRelation - TaskForm.kt validated create/edit form (mirrors EventForm) - Filters.kt smart lists: Today, Upcoming, Overdue, Completed, All -data/ - di/ Qualifiers.kt, DataModule.kt (copy + retarget) - tasks/ - TasksContract.kt vendored subset of OpenTasks TaskContract (Apache-2.0) - ProviderResolver.kt detect installed provider authority + permissions ← the A/B seam - TaskProjections.kt column lists - ColumnReader.kt (copy from Calendula) - TaskMapper.kt cursor row -> domain - TaskWriteMapper.kt form -> ContentValues - TasksDataSource.kt interface (JVM-testable seam) - OpenTasksDataSource.kt ContentResolver/ContentObserver impl - TasksRepository.kt Flow API (interface) - TasksRepositoryImpl.kt wraps the data source - reminders/ - DueReminderScheduler.kt AlarmManager scheduling (NEW — see §6) - DueReminderReceiver.kt alarm fires -> post notification - BootRescheduleReceiver.kt + provider-change reschedule - TaskNotifier.kt (copy ReminderNotifier, retarget) - prefs/ SettingsPrefs (copy) -ui/ - theme/ (copy) - common/ (copy relevant pieces) - lists/ ListsScreen + ViewModel + UiState (overview of lists/accounts) - tasklist/ TaskListScreen (one list or a smart list) + VM + UiState - detail/ TaskDetailScreen + VM + UiState - edit/ TaskEditScreen + VM + UiState - settings/ (copy + adapt) - permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding - AgendulaHost.kt nav host (mirrors CalendarHost) - RootScreen.kt -widget/ Glance task widget (later milestone) -AgendulaApp.kt, MainActivity.kt -``` - ---- - -## 3. The data layer (the heart) - -### 3.1 Provider targeting — `ProviderResolver` - -Known providers, in preference order, each = (authority, read perm, write perm): - -| Provider | Authority | Permissions | Contract | -|---|---|---|---| -| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks TaskContract | -| tasks.org | `org.tasks.opentasks` *(verify on device)* | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks-compatible | -| jtx Board | `at.techbee.jtx` | `at.techbee.jtx.permission.READ` / `WRITE` | jtx (richer, different) | - -`ProviderResolver.detect()`: -1. `PackageManager.resolveContentProvider(authority, 0)` for each known authority. -2. Return the first present as the active `TaskProvider(authority, readPerm, writePerm)`. -3. None present → `null` → drives the "install a tasks provider" onboarding (§5). - -**v1 scope:** fully support the **OpenTasks contract** (covers OpenTasks + -tasks.org's compatible provider). jtx is detected but treated as "supported -later" (its contract differs). For B, the resolver simply also finds our bundled -`org.dmfs.tasks`. - -> Verify the exact tasks.org provider authority on a real device before relying -> on it — docs are inconsistent; OpenTasks (`org.dmfs.tasks`) is the certain one. - -### 3.2 `TasksContract.kt` - -Vendor the subset we use from the Apache-2.0 OpenTasks `TaskContract` (don't take -a runtime dep on OpenTasks). Authority is injected, not hardcoded. Tables/columns: - -- **TaskLists**: `_ID`, `LIST_NAME`, `LIST_COLOR`, `ACCOUNT_NAME`, `ACCOUNT_TYPE`, - `SYNC_ENABLED`, `VISIBLE`, `OWNER`. (Account fields → group lists by account in - the UI, like Calendula groups calendars.) -- **Tasks** (the denormalized instances view): `_ID`, `LIST_ID`, `TITLE`, - `DESCRIPTION`, `DTSTART`, `DUE`, `IS_ALLDAY`, `TZ`, `STATUS`, `PRIORITY`, - `PERCENT_COMPLETE`, `COMPLETED`, `RRULE`, `LIST_COLOR`, `ACCOUNT_*`. -- **Properties / Relation** (`Tasks.Properties`, mimetype Relation): subtasks via - `RELATED-TO` (`RELATION_TYPE_PARENT`). This is how DAVx5 carries the hierarchy. - -### 3.3 Repository API - -```kotlin -interface TasksRepository { - fun taskLists(): Flow> - fun tasks(filter: TaskFilter): Flow> // list-id or smart list - suspend fun taskDetail(taskId: Long): TaskDetail - suspend fun createTask(form: TaskForm): Long - suspend fun updateTask(taskId: Long, original: TaskForm, updated: TaskForm) - suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture - suspend fun deleteTask(taskId: Long) - suspend fun createLocalList(name: String, color: Int): Long // device-only list we own - // subtasks: createSubtask(parentId, form) / reparent via RELATED-TO -} -``` - -Writes go through the **sync-adapter URI form** where the provider requires it -(local/unsynced lists), mirroring Calendula's `createLocalCalendar`. Completion = -set `STATUS=COMPLETED`, `PERCENT_COMPLETE=100`, `COMPLETED=now`; DAVx5 syncs it -back out as a normal VTODO status change. - -### 3.4 Reactive flows - -`OpenTasksDataSource` registers a `ContentObserver` on the active authority's -Tasks/TaskLists URIs and emits via `callbackFlow` — identical mechanism to -Calendula, so external sync (DAVx5 pulling new tasks) updates the UI live, and -multiple sync sources coexist in one list. - ---- - -## 4. Screens (Compose, M3 Expressive) - -1. **Lists overview** (`ui/lists`) — smart lists at top (Today, Upcoming, - Overdue, All), then user lists grouped by account (reuse `GroupedList`). - Per-list color dot + open/undone count. -2. **Task list** (`ui/tasklist`) — the workhorse. Checkbox rows, swipe-to- - complete + swipe-to-delete, inline "add task" field (reuse `InlineTextField`), - subtask indentation, sort (due/priority/manual), section headers for smart - lists (Overdue / Today / Later). FAB to add. -3. **Task detail** (`ui/detail`) — title, notes, due/start, priority, - percent-complete, subtasks, source list/account, completed timestamp. -4. **Task edit** (`ui/edit`) — title, notes, list picker, due/start date-time - (reuse Calendula date/time pickers), priority, reminder offset, subtasks. - "Conflict-safe save" like Calendula (re-check before overwrite). -5. **Settings** (`ui/settings`) — theme/dynamic-color/language (copy), default - list, default reminder offset, "tasks app" info + manage button. -6. **Onboarding / permission** (`ui/permission`) — see §5. - -Material 3 Expressive throughout: `MaterialExpressiveTheme`, -`MotionScheme.standard()`, expressive checkbox/FAB/swipe motion. Follow the -`material-3` skill for component choices (M3 `ListItem` for rows, etc.). - ---- - -## 5. Onboarding & permissions - -Three gates, in order: - -1. **Provider present?** `ProviderResolver.detect()`. If none → a screen - explaining Agendula needs a tasks provider, with one-tap links to install - **OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app". - (This is Agendula's "needs DAVx5" moment. Disappears entirely under Posture B.) -2. **Tasks read/write permission** — request the active provider's runtime perms - (`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at - runtime; resolved dynamically from the detected provider. -3. **POST_NOTIFICATIONS + exact-alarm** — for due reminders (reuse Calendula's - reminder onboarding). - -`` in the manifest for package visibility (launch DAVx5 / OpenTasks), -exactly like Calendula's launcher query. - ---- - -## 6. Reminders — the one pattern that does NOT carry over - -Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and -just posts the notification (Etar model). **Tasks providers do not broadcast -reminders.** So Agendula must schedule its own: - -- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` / - `SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen - offset (and optionally at `DTSTART`). -- `DueReminderReceiver` fires → posts via `TaskNotifier` (the copied - `ReminderNotifier`), tapping opens the task. -- **Reschedule triggers:** boot (`RECEIVE_BOOT_COMPLETED`), and the data-source - `ContentObserver` firing on any task change (our edits *and* external sync) → - recompute alarms for the near-future window. Keep a small scheduled-alarm - registry in DataStore so we can diff like Calendula diffs reminder rows. - -This is the single largest *new* subsystem. Everything downstream of it (channel, -notification building, POST_NOTIFICATIONS gating, dedupe-by-tag) is copied. - ---- - -## 7. Manifest (vs Calendula) - -```xml - - - - - - - - - … LAUNCHER intent (package visibility) … -``` -Receivers: `DueReminderReceiver`, `BootRescheduleReceiver`, Glance widget -receiver. **No `EVENT_REMINDER` receiver** (doesn't apply). - ---- - -## 8. Milestones - -- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from - Calendula, rename to Agendula. Builds, shows themed empty scaffold. *(~½ day)* -- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource` - + repository. Lists overview + task list render real synced tasks (read-only), - live-updating via ContentObserver. *(the meaty milestone)* -- **M2 — Complete & CRUD.** Toggle complete (swipe + checkbox), create via inline - field + edit screen, delete. Local-list creation. -- **M3 — Detail/edit polish.** Due/start pickers, priority, percent, conflict-safe - saves, smart lists (Today/Upcoming/Overdue). -- **M4 — Subtasks.** `RELATED-TO` read + indentation; create/reparent. *(trickiest)* -- **M5 — Reminders.** `DueReminderScheduler` + receivers + onboarding. -- **M6 — Settings, widget, i18n, F-Droid metadata, CI** (clone Calendula's - `.gitea/workflows`, `fdroid-metadata/`, RELEASING docs). -- **B (separate, later):** add `:provider` module bundling Apache-2.0 - `opentasks-provider`; `ProviderResolver` default authority becomes our own - `org.dmfs.tasks`; add sync-adapter permissions; ship self-contained. UI/repo - untouched. - -**Effort:** M0–M3 is a small, fast core (days, given the Calendula head start). -M4 (subtasks) and M5 (reminders) are the two spots needing real thought. - ---- - -## 9. Open decisions / to verify - -1. ~~**Name**~~ — **Agendula** (final): *agenda* + Calendula's `-ula`. Renamed - from the working title *Floret*, which was promoted to the family / - design-language name. -2. **tasks.org provider authority** — verify on a real device; OpenTasks - (`org.dmfs.tasks`) is the certain target for v1. -3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? -4. **B authority choice** — bundling `org.dmfs.tasks` makes Agendula a *replacement* - for OpenTasks (one authority owner per device). Intended (one app instead of - two), but a conscious choice. -5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI. - ---- - -## 10. Sync sources Agendula inherits for free (README copy) - -Because Agendula builds on the provider, not on any one sync app, it works with -**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync, -CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration. -Google Tasks / Microsoft To Do are out of scope by design (proprietary, would -mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are -the lane. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 3a6782e..60a91df 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,7 +1,7 @@ --- title: Privacy Policy — Agendula description: What Agendula does with your data. No servers, no account, no analytics — your tasks stay on your device unless you add a CalDAV server yourself. -updated: 2026-09-15 +updated: 2026-09-21 --- -**Last updated:** 9 September 2026 +**Last updated:** 21 September 2026 Applies to the Android app **Agendula** (package `de.jeanlucmakiola.agendula`), all versions and all distribution channels. @@ -144,9 +144,13 @@ stored locally on your device and are removed when you uninstall the app. If Android Auto Backup is enabled on your device, your tasks and settings may be backed up to your own Google account, under Google's terms — the developer -has no access to it. Two things are deliberately excluded from that backup: -your stored CalDAV password, and Agendula's per-device sync bookkeeping. After -restoring onto a new device you therefore sign in to your server again. +has no access to it. What travels is Agendula's own task database and your +settings, and nothing else: the backup rules name those explicitly, which makes +everything not named — including the archived copy the app keeps of an older +version's database — excluded by default. Two things are deliberately kept out +of that backup: your stored CalDAV password, and Agendula's per-device sync +bookkeeping. After restoring onto a new device you therefore sign in to your +server again. ## 7. Crash reports @@ -183,6 +187,10 @@ process — it only opens the address. ## 9. Permissions and why they exist +This is the complete list the released app declares — you can check it against +the app's entry in F-Droid, or against `app/src/main/AndroidManifest.xml` in the +source: + - `INTERNET`, `ACCESS_NETWORK_STATE` — CalDAV sync with the server you configure, and checking whether a connection exists before trying. Without a CalDAV account, no connection is made. @@ -195,7 +203,8 @@ process — it only opens the address. - `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` and `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` — optional, requested only if you choose the external-provider storage mode, and only for the provider - you selected (OpenTasks or tasks.org). + you selected (OpenTasks or tasks.org). All four are declared in the manifest + because a manifest is static, but none is requested until you pick that mode. - `WAKE_LOCK`, `FOREGROUND_SERVICE` — required by the Android system component used for scheduled background work (WorkManager); on older Android versions it needs them to run an expedited sync. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index eb146c7..0000000 --- a/docs/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Agendula — documentation - -Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over -the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync -over CalDAV), with no own database or sync stack. Sibling to -[Calendula](https://codeberg.org/jlmakiola/calendula). See the -top-level [`../README.md`](../README.md) for the project pitch. - -## Index - -| Doc | What it covers | -|---|---| -| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. | -| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + Posture B), what's done, open decisions, how to build/verify. | -| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". | -| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. | - -Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections -feed the release notes). - -## How the docs relate - -- **PLAN** is the design decisions (mostly stable; the "why"). -- **ARCHITECTURE** is the current shape of the code (kept in sync with the - source as it grows). -- **ROADMAP** is the moving status layer (update as milestones land). -- **RELEASING** is the operational runbook. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 614a6a3..9d0e7b8 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,8 +1,8 @@ # Agendula — releasing -Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) and a -**Codeberg release** per version carrying the signed APK as a direct download. -Both are produced automatically by `.gitea/workflows/release.yaml` when a +Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner), a +**Codeberg release** per version carrying the signed APK as a direct download, +and **Google Play**. All three are produced automatically by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`** — the pipeline builds and publishes that version, then creates the matching `vX.Y.Z` tag and the releases itself. The parallel **Gitea release** is the changelog of record on the build instance and @@ -42,19 +42,19 @@ re-running the workflow safely retries). that branch, before it reaches `main`. 2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading - and the next `## [` becomes both the Gitea release notes and the F-Droid - per-version "What's New". The heading **must** match the version exactly. + and the next `## [` becomes the Gitea and Codeberg release notes. The + heading **must** match the version exactly. 3. **Bump the committed `versionName`** (and `versionCode`) in `app/build.gradle.kts`. **This bump is what triggers the release** when the - branch merges to `main`. Then run - ```sh - scripts/sync_changelog_to_fastlane.sh - ``` - and commit the generated - `fastlane/metadata/android/en-US/changelogs/.txt` — this is what - makes the **official** F-Droid listing (which harvests the changelog from the - tagged source tree) show this version. The self-hosted pipeline regenerates it - regardless, so forgetting only affects the official listing. + branch merges to `main`. + Then write this version's **"What's New"** by hand: + `fastlane/metadata/android//changelogs/.txt`, one per + shipped locale, each a short summary **under 500 characters** — F-Droid and + Play both publish these files, and Play rejects a longer one. It is not a + copy of the CHANGELOG.md section. `scripts/sync_changelog_to_fastlane.sh` + keeps a committed `en-US` file as it is and only generates one from + CHANGELOG.md when it is missing; CI fails a PR whose version has no + committed `en-US` changelog. 4. **Verify the release build on a real device** — the mandatory gate: ```sh scripts/verify-release.sh @@ -71,7 +71,8 @@ re-running the workflow safely retries). Only proceed once all of that passes on-device. 5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The merge triggers `release.yaml`, which detects the new version, builds, signs, - publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release. + publishes to F-Droid, creates the `vX.Y.Z` tag + releases, and uploads the + App Bundle to Play. > The `releaseTest` build type exists only for step 4 — it is never published. > The pipeline always builds and signs the real `release` variant. @@ -106,8 +107,13 @@ release work when a merge actually cuts a release: with the **repo key**, upload `repo/` + `metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`, and publish the release on - **Codeberg** with the signed APK + a SHA-256 checksum. Ordinary merges with no - version bump fall through `detect` and do nothing. + **Codeberg** with the signed APK + a SHA-256 checksum, and finally builds the + App Bundle. Ordinary merges with no version bump fall through `detect` and do + nothing. +- **`play` job** (same workflow, after `release`) — uploads the App Bundle and + every locale's "What's New" to Google Play. Runs last and separately so a Play + rejection can't endanger a release that already shipped; skips cleanly until + Play is configured. ### Codeberg direct-download channel @@ -123,6 +129,44 @@ green while never once publishing, which is how a crash-fix release reached F-Droid but not the Codeberg/Obtainium users who needed it. A broken mirror fails the release loudly. +### Google Play channel + +**Artifact.** Play gets an **App Bundle** (`bundleRelease`), never the APK. It is +built from the same source and signing config at the very end of the `release` +job, `continue-on-error`, and handed to the `play` job as a workflow artifact +(via a commit-pinned fork of `upload-artifact`/`download-artifact`; the official +v4 actions refuse any non-github.com forge). The APK and the F-Droid +reproducibility guarantee are untouched. + +**Signature.** Play App Signing treats the app key as the **upload key** only +and re-signs with Google's key. A Play install and an F-Droid/Codeberg install +therefore have **different signatures** and cannot update each other; switching +channels means uninstalling. Losing the app key is recoverable for Play (upload +key reset) but not for F-Droid. + +**fastlane** is only the Play Developer API client (`supply`), never the build. +The `deploy` lane uploads the bundle plus `changelogs/.txt` for +every locale that has one. It never touches the listing; that is the `listing` +lane's job (see [Store listings](#store-listings-single-source-of-truth)). + +**Track.** Uploads go straight to `production` at full rollout — the release +branch review is already the gate. Set the `PLAY_TRACK` variable (e.g. +`internal`) to stage, or `PLAY_RELEASE_STATUS=draft` to hold it unpublished. + +**First-time setup.** +1. Create the app in the Play Console with **English (United States) – en-US** + as the default language, so Play and F-Droid fall back to the same locale. +2. Upload the first AAB **by hand** — the API can't create an app's first + release. Enrol in Play App Signing on the way. +3. Create a Google Cloud service account, grant it release permissions for the + app in the Play Console, and store its JSON key as + `PLAY_SERVICE_ACCOUNT_JSON`. +4. Push the listing once with the `listing` lane (see + [Store listings](#store-listings-single-source-of-truth)), so every locale + that has a changelog also exists on Play before `deploy` sends its + "What's New". +5. Rehearse with `PLAY_DRY_RUN=true`, then clear it. + One-time setup: the Codeberg repo's **Releases** unit must be enabled and a `CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope — it pushes the tag as well as creating the release) added to Gitea Actions. @@ -196,6 +240,7 @@ user's pinned repo). | `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. | | `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. | | `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — pushes the tag to Codeberg, creates the release there and uploads the APK/checksum. If unset the step skips; if set and failing, the release fails. | +| `PLAY_SERVICE_ACCOUNT_JSON` | Google Cloud service-account key (full JSON) with Play Console release access. Uploads the AAB. If unset, the `play` job skips cleanly. | | `RENOVATE_TOKEN` | Codeberg bot-account token — repo read/write + PR scope on `jlmakiola/agendula`. Used only by `renovate.yml`. | | `GITHUB_COM_TOKEN` | Read-only github.com PAT (no scopes). Without it Renovate's changelog lookups hit the 60/h anonymous rate limit and PRs arrive with empty release notes. | @@ -204,22 +249,73 @@ users pin). Neither key nor `config.yml` is ever uploaded to the server — they live only in CI secrets and are reconstructed in-runner (nginx serves only `repo/`). ---- +### Variables (Gitea → repo Settings → Actions → Variables) -## F-Droid metadata (single source of truth) - -Store-listing text lives in **`fastlane/metadata/android//`** — the same -tree the official F-Droid repo harvests from source. At release time -`scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's -"localized" layout, so there is no second copy to maintain. The app-level control -file (`Categories`/`License`/links) stays in -`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. Per-version changelogs are -seeded into `fastlane/.../en-US/changelogs/.txt` by -`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the -transform. +| Variable | Default | Purpose | +| --- | --- | --- | +| `PLAY_TRACK` | `production` | Play track for the bundle; `internal`/`alpha`/`beta` to stage. | +| `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. | +| `PLAY_DRY_RUN` | `false` | `true` validates the Play edit and discards it. | --- +## Store listings (single source of truth) + +Everything both stores show — title, short and full description, "What's New", +icon, feature graphic, screenshots — lives in +**`fastlane/metadata/android//`**. Nothing is edited in the Play Console +or kept in a second copy for F-Droid. + +``` +fastlane/metadata/android/ + en-US/ fallback of both stores; holds every graphic + title.txt ≤ 30 chars + short_description.txt ≤ 80 chars + full_description.txt ≤ 4000 chars + changelogs/.txt ≤ 500 chars + images/ + icon.png 512×512, 32-bit PNG + featureGraphic.png 1024×500, no alpha + phoneScreenshots/ 2–8, no alpha, 320–3840 px, long side ≤ 2× short + sevenInchScreenshots/ optional, same rules + tenInchScreenshots/ optional, same rules + en-GB/, de-DE/, pt-BR/, … text only; one per shipped values-* language +``` + +`en-US` is the fallback for both stores: F-Droid always uses it, and it is the +Play app's default language. So text and graphics committed there cover every +locale without its own. Graphics are committed **once, under `en-US`**; add them +to another locale only when they actually differ (e.g. localized screenshots). + +A raw phone capture (1080×2400, 20:9) breaks Play's 2:1 aspect cap. Frame or +crop screenshots to at most 2:1, e.g. 1080×2160. + +**Validation.** `scripts/check_store_listing.py` checks the whole tree against +Play's limits (the stricter of the two stores) and runs on every PR. Add +`--complete` to also require what a live Play listing needs: icon, feature +graphic, ≥ 2 phone screenshots and this version's `en-US` changelog. + +**F-Droid.** The official repo harvests the tree from the tagged source. The +self-hosted repo gets it through `scripts/fastlane_to_fdroid_localized.sh` at +release time. The app-level control file (`Categories`/`License`/links) stays in +`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. + +**Play.** `fastlane/Fastfile`'s `listing` lane pushes text and graphics +(`supply`, only changed images are re-uploaded). It is run by hand, never by the +release pipeline, because overwriting a live listing triggers a Play review: + +```sh +bundle install +SUPPLY_JSON_KEY=/path/to/play-service-account.json \ + bundle exec fastlane listing dry_run:true # validate, discard +SUPPLY_JSON_KEY=/path/to/play-service-account.json \ + bundle exec fastlane listing +``` + +The lane refuses to run unless `check_store_listing.py --complete` passes. +If the Play app's default language is ever not `en-US`, set +`PLAY_DEFAULT_LOCALE` and the lane copies the graphics into that locale. + ## Crash deobfuscation Each release attaches `mapping-.txt.gz` (the R8 mapping) to its Gitea diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 0c7e47d..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,163 +0,0 @@ -# Agendula — roadmap - -Where the project is and where it's going. This is the **status** view; the -design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the -pieces fit together is in [`ARCHITECTURE.md`](ARCHITECTURE.md). - -Status legend: ✅ done · 🚧 in progress · ⬜ not started - ---- - -## Current state (one line) - -The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` -provider is **done and unit-tested**, and the Material 3 Expressive UI is built -through **M5**: lists → task list (swipe gestures, inline add, smart-list section -headers) → detail / edit with full CRUD, date-time pickers, priority, -percent-complete, conflict-safe saves, per-task reminders, and subtask -create + reparent — plus a one-time reminder onboarding step and a Settings -screen (theme, dynamic colour, due-reminder master toggle + default offset + -exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining -work is M6 (Glance widget, translations, F-Droid release; the Settings screen -landed early with M5 and still needs a language entry). - ---- - -## Milestones - -### ✅ M0 — Skeleton -Project scaffolding copied from Calendula (Gradle, version catalog, Hilt, -Material 3 Expressive theme, Gitea CI/release workflows, F-Droid metadata), -reseeded to a warm-mauve fallback palette. Builds, shows a themed placeholder. - -### ✅ M1 — Read path + logic (the "backoffice") -The complete non-visual stack: -- Vendored `TaskContract` subset, `ProviderResolver` (runtime authority - detection), `ColumnReader`, mappers, `AndroidTasksDataSource` (Instances - query + `ContentObserver`), and `TasksRepository` exposing live Flows of - lists / tasks / detail. -- Domain models, smart-list filtering (Today / Upcoming / Overdue / No-date / - All / Completed), sorting, form validation, subtasks via `parentId`. -- Self-scheduled due-reminder engine (AlarmManager + boot / provider-change - re-sync), notifications, DataStore prefs. -- Render-only ViewModels + UiState for **every** screen, so the UI is build-only - from here. -- JVM unit tests across mappers, filtering, sorting, form, value mapping, and - day windows. - -### ✅ M2 — Screens: lists, task list, complete & CRUD -The real Material 3 Expressive UI, built screen by screen against the M1 -ViewModels. -- ✅ Provider/permission onboarding gate (`RootScreen`). -- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account. -- ✅ Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task - list → detail / edit, each binding its M1 ViewModel from route args). -- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete / - -delete (`SwipeToDismissBox`), inline add field (`InlineAdd` → `quickAdd`), - smart-list section headers (`TaskSections`). -- ✅ Detail + edit screens — detail shows title / description / subtasks with - edit+delete; edit has the full CRUD form (title, description, save) wired to a - list picker. - -### ✅ M3 — Detail / edit polish -- ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`). -- ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`; - M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the - list and detail screens, and the edit form's M3 segmented selector tints its - active segment in the chosen level's hue. -- ✅ Smart-list section presentation (`TaskSections` headers on the task list). -- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit - form; writes `Tasks.PERCENT_COMPLETE` (clamped 0–100, status left to the - complete toggle). -- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the - value captured when the form loaded and throws `TaskConflictException`; the - editor offers overwrite-or-cancel instead of clobbering an external change. - -### ✅ M4 — Subtasks (UI) -`RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`. -- ✅ Create subtask (`AddSubtaskField` → `TaskDetailViewModel.addSubtask` sets - `parentId`); subtasks render as a grouped section in the detail screen. Tapping - a subtask opens its own detail (a new `TaskDetail` entry, so it can have - children too), and the subtask's detail shows a tappable "Part of …" parent - card so it never reads as a stray standalone task. -- ✅ Inline expansion on the task list — a parent row has a dedicated expand - button (trailing chevron, separate from the count chip). Each section flattens - into one grouped run (parents + their expanded children) and corners are chosen - per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded - parent opens its bottom to its children, and the child group rounds off on its - last segment while the next top-level task stays mid-run. Children are - full-width, set a step down in tone (`surfaceContainer` vs the parents' - `surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask" - row (on by default; opt out in Settings → Tasks since M5). - Offered only where the list holds all the children (a real list; smart lists - that omit off-day children stay collapsed). `TaskDetail.parent` carries the - parent for the detail card. -- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form - groups active candidates by due-date section (Overdue / Today / Upcoming / No - date) and files a task under any top-level task in its list (or "None" to - promote it); switching list clears the now-invalid parent. Candidates stay - active + top-level to keep nesting one level deep, matching the detail screen. - -### ✅ M5 — Reminders onboarding & polish -The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync, -`DueReminderReceiver`, `TaskNotifier`). -- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm` - `reminderMinutesBeforeDue`). -- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen` - (Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the - provider/permission grant; it requests the runtime permission (API 33+) and - records the choice (`reminderOnboardingDone`). Re-requestable from Settings. -- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on - Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the - system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted). -- ✅ Default reminder-offset settings UI — exposed as "When to remind" in - Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled` - switch that gates the entire engine (`ReminderScheduler` clears all alarms when - off; `DueReminderReceiver` suppresses any in-flight fire). -- ⬜ End-to-end verification on device (build + unit tests green; not yet run on - a device with a live provider). - -### 🚧 M6 — Settings, widget, i18n, release -- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`). -- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph, - reached by the gear on the lists overview). Covers theme, dynamic colour, due - reminders (toggle + default offset + exact-alarm status), default list, and the - add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there - are translations to switch to). -- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl. -- ⬜ Translations — only `res/values/` (English); no `values-XX`. -- ⬜ Finalize F-Droid metadata, confirm CI release flow. - -### ⬜ Posture B (separate track, later) -Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; -`ProviderResolver` defaults to our own `org.dmfs.tasks`; add sync-adapter -permissions; ship self-contained. UI / repository / domain untouched — see -[`ARCHITECTURE.md`](ARCHITECTURE.md) §7. - ---- - -## Open decisions / to verify - -These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through. - -1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`). -2. ~~**tasks.org provider authority**~~ verified on device: - `org.tasks.opentasks` + `org.tasks.permission.*`. -3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? - (Not in the candidate list today.) -4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a - *replacement* for OpenTasks (one authority owner per device). Intended, but a - conscious choice. -5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); - recurrence-aware editing is out of scope for v1. - ---- - -## How to contribute / verify - -- Build: `./gradlew :app:assembleDebug` -- Unit tests: `./gradlew :app:testDebugUnitTest` -- Run on a device/emulator that has **OpenTasks** or **tasks.org** installed (and - ideally DAVx5 syncing a CalDAV task list) so the read/write paths have real - data. Debug builds use `DemoSeeder` for sample data when no provider data is - present. diff --git a/docs/STORAGE-AND-SYNC.md b/docs/STORAGE-AND-SYNC.md deleted file mode 100644 index d6831a9..0000000 --- a/docs/STORAGE-AND-SYNC.md +++ /dev/null @@ -1,357 +0,0 @@ -# Agendula — storage and sync - -> Decided direction, captured 2026-08-01. Supersedes the earlier "Posture B = -> bundle OpenTasks" working notes, which are withdrawn (see -> [Dead ends](#dead-ends--do-not-revisit)). This is the detailed companion to -> `ARCHITECTURE.md` §7 and the `ProviderResolver` comments, **and it redefines -> what Posture B means** — those two need a follow-up edit. -> `ROADMAP.md` / `PLAN.md` remain known-stale and are due a deliberate pass; -> this document does not attempt it. - -## The plan, in short - -**What we're building** - -1. **Our own provider.** Vendor the Apache-2.0 dmfs task provider in-tree as - `:provider`, renamed to our own authority and permission namespace. Our - database, our namespace — coexists with everything, replaces nothing. -2. **Our own sync.** An Agendula sync adapter, so remote storage never depends - on another app's roadmap. -3. **The user picks the mode.** Local-only · Synced · External provider. -4. **Least permission.** Ask only for what the chosen mode needs, when it needs - it. -5. **Kit-first.** Anything that isn't task-domain goes to floret-kit. - -**In what order** - -| # | Step | Why now | -|---|---|---| -| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | -| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | -| 3 | Export / backup | our data now lives only in our app's private storage | -| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | -| 5 | Sync adapter | the 1.x arc; design discussion pending | - -Everything below is the reasoning behind those choices, the alternatives that -were rejected, and the constraints they have to survive. - ---- - -## The decision - -Agendula gets its **own identity all the way down** — its own task database -under its own authority, its own sync, and a storage mode the user picks. It -does not adopt, replace, or impersonate another project's provider. - -Four parts: - -1. **Own DB, bundled in-process.** Vendor the Apache-2.0 dmfs - `opentasks-provider` as an in-tree `:provider` Gradle module, renamed to - authority `de.jeanlucmakiola.agendula.tasks` with permissions - `de.jeanlucmakiola.agendula.permission.READ_TASKS` / `…WRITE_TASKS`. Not a - separate provider *app*; not a schema written from scratch. We keep the dmfs - `TaskContract` shape — it's proven, it's what our whole data layer already - speaks, and it's what every CalDAV engine already understands — we just own - the namespace it lives in. -2. **Own sync adapter**, so remote storage never depends on another app's - roadmap. Protocol coverage is deliberately open — separate discussion. -3. **The user chooses the backend**: local-only, synced, or an external provider - that's already on the device. -4. **Ask for only what the chosen mode actually needs**, at the moment it needs - it. - -Plus a standing rule: **anything that isn't task-domain goes to floret-kit.** - -### The vocabulary, redefined - -`ARCHITECTURE.md` §7 and `ProviderResolver`'s KDoc still describe Posture B as -"bundle OpenTasks and find `org.dmfs.tasks` first." Replace with: - -- **Posture A** — front-end over an *external* provider (OpenTasks, tasks.org). - Still fully supported; it stops being the default and becomes a **user - choice**. -- **Posture B** — our own bundled provider under **our own** authority. - Coexists with everything; replaces nothing. - -The A/B seam itself is unchanged and still earns its keep: `ProviderResolver` is -the only thing that knows an authority, `AndroidTasksDataSource` the only thing -that touches a resolver. UI, ViewModels, domain and repository are untouched by -all of this. - ---- - -## Why not squat `org.dmfs.tasks` - -The rejected plan was to bundle the provider under dmfs's own authority so -DAVx5 would sync into it unwittingly. Reasons it's out, in order of how badly -each one bites: - -1. **It is a structural identity mismatch, and the resulting bug is invisible to - both sides.** Content-provider *authorities* are how a sync engine finds a - provider, but Android **account visibility is keyed by package name**: since - API 26 an app only sees accounts whose authenticator has made them visible to - *its package*, and `GET_ACCOUNTS` alone no longer suffices. A sync engine's - allowlist would name the package `org.dmfs.tasks`, not - `de.jeanlucmakiola.agendula`. So the bundled provider could find **zero** - accounts — and the dmfs provider uses account enumeration to prune task lists - whose account has gone away. The failure mode isn't "no sync", it's "our - provider quietly purges synced lists." *(Reasoned from the platform rules, - not from having read DAVx5's source — but the class of bug is structural, and - every future place anything keys on package rather than authority is a fresh - instance of it.)* An explicit integration under our own name makes this bug - impossible by construction. -2. **Play Store install-time landmine.** Two apps cannot declare the same - authority (`INSTALL_FAILED_CONFLICTING_PROVIDER`) or the same `` - name (`INSTALL_FAILED_DUPLICATE_PERMISSION`, waived only for identical - signing certs). Anyone with OpenTasks installed gets a failed install, - surfacing as one-star reviews we can't usefully answer. -3. **Migration data loss.** "Uninstall OpenTasks first" takes its DB with it. - CalDAV-synced tasks reconcile back; local-only tasks are simply gone, and - OpenTasks has no export (dmfs/opentasks #170, #204, #71 — years-old, - unimplemented; their wiki punts to a desktop client). -4. **Squatting another project's namespace doesn't scale.** At low install - counts nobody notices. At scale we'd be generating issues on dmfs's tracker - that aren't dmfs's fault, and silently maintaining a schema fork under their - name. - ---- - -## The `:provider` module - -**Source.** dmfs `opentasks-provider`, Apache-2.0. Target the **1.4.2** source -(DB version 23 — the version that actually carries `is_recurring`; tasks.org's -fork is DB 22 and lacks it, which is why `TaskMapper.task` reads `rrule`/`rdate` -for recurrence detection rather than trusting the column). - -**Layout: in-tree module, not a git submodule.** floret-kit is a submodule -because we co-develop it. This is a fork we will sync from upstream -approximately never, so in-tree is simpler for both stores and honest about what -it is. Ship a `provider/PROVENANCE.md`: upstream commit, and every change we -made. - -**License hygiene, on day one.** The app is MIT, the provider is Apache-2.0 — -permissive into permissive, fine — but the module keeps its Apache-2.0 headers, -`LICENSE`, and `NOTICE`. Ten minutes now; embarrassing to retrofit once it's in -two store listings. - -**What we change:** - -- Authority → `de.jeanlucmakiola.agendula.tasks` (it's already a string - resource, `opentasks_authority`). -- Permission names → `de.jeanlucmakiola.agendula.permission.*`. These are - **hardcoded in the AAR manifest**, which is the single clearest reason - vendoring is mandatory rather than merely preferable — you cannot rename them - in a prebuilt artifact without `tools:` node surgery we'd rather not ship. -- Drop ``. - We own our own accounts, so we don't need it — but note the provider's - account-cleanup path is written *assuming* it, so this is a review-and-rework - item, not a free deletion. **Verify** the provider's local-list/local-account - path works with no account present at all; that's the entire local-only mode. -- Drop the exported `BOOT_COMPLETED` / `TIME_SET` / `TIMEZONE_CHANGED` receiver, - or keep it deliberately and give it an explicit `android:exported`. The AAR is - from the `targetSdk 29` era; AGP hard-errors on a merged manifest with an - intent-filtered component and no explicit `exported` once targetSdk ≥ 31, and - we're on 36. Fixed at source instead of patched around. -- Modernize the build: it ships `minSdk 21` / `targetSdk 29`, Robolectric 3.5.1, - JUnit 4.12. We're `minSdk 29` / `targetSdk 36` and Play raises its target-API - floor annually, so this isn't optional upkeep. - -**Our data now lives in our app's private storage.** Uninstall means deletion. -That single fact is what promotes export/backup from "nice to have" to a v1 -feature — see [Storage modes](#storage-modes--the-users-choice). - ---- - -## Storage modes — the user's choice - -| Mode | Backing store | Sync | Needs | -|---|---|---|---| -| **Local** | our bundled provider | none | no permissions at all — same-uid provider access needs no grant | -| **Synced** | our bundled provider | our sync adapter | network + an account the user configures | -| **External** | OpenTasks / tasks.org | whatever that provider's engine does (DAVx5 …) | that provider's `READ`/`WRITE_TASKS`, granted at runtime | - -Local and Synced are the same store — Synced is Local with an account attached, -so switching on sync is not a migration. - -**Resolver ordering needs deciding.** Today `ProviderResolver.CANDIDATES` is a -fixed priority list and the first hit wins. Once we bundle our own provider, -"first hit" is the wrong rule: someone who used Agendula locally and *later* -installs DAVx5 + OpenTasks would see an external candidate outrank the provider -that actually holds their data. Options: rank ours first whenever it's -non-empty, or make the mode an explicit Settings choice (it's user-visible -either way, so probably both — auto-pick a sane default, let Settings override). - -**Export/backup is a v1 feature.** Not, as previously framed, a migration safety -net for "uninstall OpenTasks" — that scenario no longer exists. It's data -portability for Local-mode users, whose tasks otherwise exist in exactly one -place with no second copy. On Play, where most users won't have a sync engine, -that's the majority. - ---- - -## Sync — own adapter - -**Decided:** Agendula ships its own sync. Not because DAVx5 is bad, but because -depending on it makes one external maintainer's roadmap the gate on our core -feature — the same shape of dependency the whole identity decision exists to -escape. - -The two precedents diverge and the choice between them is the whole point: -tasks.org has its own authority **and its own sync** (sovereign); jtx Board has -its own authority and **depends on DAVx5** (and got added, though a working -relationship with bitfire is part of that story). We're taking the tasks.org -shape. - -**Play sharpens this.** DAVx5 is a paid app on Google Play and free only on -F-Droid — *worth confirming, since it's load-bearing* — which means most Play -users will never have it. Lobbying bitfire is therefore an **F-Droid-audience -feature, not a sync strategy**. - -**Still file the DAVx5 issue** — cheap, non-blocking, real value for F-Droid -users. And make it the strongest possible version of the ask: our provider *is* -the dmfs provider with renamed strings, so it's byte-identical contract -compliance and a small enum-shaped addition with near-zero ongoing maintenance -for them. Say that explicitly. "Here's a change that can't break anything" lands -very differently from "please support my app." - -**Open — the next discussion.** Protocol coverage ("support as much as -possible"), the account model, conflict resolution, and where the DAV/iCalendar -work lives. One constraint to settle early: we're MIT; `dav4jvm` is Apache-2.0 -and fine, but **verify `ical4android`'s license** before assuming it's usable. - ---- - -## Permissions — only what the mode needs - -The manifest is static, so "request only what we need" is really two different -mechanisms, and conflating them is how apps end up over-permissioned: - -- **Runtime (dangerous) permissions** — genuinely stageable. Ask at the moment - the feature is used, never up front. -- **Install-time (normal) permissions** — declared unconditionally; the only - lever is **not declaring them until the feature ships**, and not letting a - bundled dependency drag in ones we don't use. - -| Permission | When | -|---|---| -| *(none)* for our own provider | same-uid access needs no grant — `ProviderStatus.NEEDS_PERMISSION` must never fire in Local/Synced mode | -| `org.dmfs.permission.*`, `org.tasks.permission.*` | requested **only** when the user selects External mode; declared always (static manifest) | -| `POST_NOTIFICATIONS` | when reminders are first enabled | -| `USE_EXACT_ALARM` / `SCHEDULE_EXACT_ALARM` | when exact due-time reminders are used. Note Play reviews `USE_EXACT_ALARM` and requires the app to be a calendar/alarm/task app — we qualify, but it needs a justification in the listing | -| `INTERNET` | **don't declare it until sync ships** | -| `GET_ACCOUNTS` | never — stripped from the vendored provider; our own account type doesn't need it to see its own accounts | - -**Work item:** the permission gate in `RootScreen` / `PermissionViewModel` / -`ProviderResolver.hasPermission` currently assumes an external provider always -needs a grant. It needs a bypass for our own provider. Modest, but it's the -exact flow `fix/provider-interaction-review` just touched — merge that first. - ---- - -## What lands in floret-kit - -Standing rule, matching the kit's own thesis (*share the mechanics, keep the -look — the kit never knows about a specific app's domain*): if it isn't -task-domain, it goes to the kit. - -| Candidate | Kit module | Note | -|---|---|---| -| ContentProvider seam — `ColumnReader`, failures, observer→Flow | `core-provider` | **already on the kit's deferred list**, blocked on migrating Calendula to the name-based reader. Bundling our own provider is the forcing function that makes this worth doing. | -| Runtime-permission staging — request/state machine, rationale plumbing, "ask at point of use" | new, e.g. `core-permissions` | pure mechanics, and Calendula has the identical problem | -| DAV client + iCalendar parse/serialize | new, e.g. `core-dav` | **the big one.** Calendula is a calendar app; it needs the same primitives. Worth designing for two consumers from the start rather than extracting later | -| Export/backup plumbing — SAF, file writing, share-out | kit | the *serialization* of tasks is domain; the plumbing isn't | -| Sync-adapter/account scaffolding | kit, probably | the `AbstractThreadedSyncAdapter` + authenticator boilerplate is identical everywhere; the delta logic is domain | - -**Stays app-local:** the vendored `:provider` module (task-specific, and -Apache-2.0 against the kit's MIT), `TaskContract` and the mappers, domain models -and smart lists, all screens, and the reminder *scheduler* (per the kit's -existing "not shared" call — Agendula pulls, Calendula pushes). - ---- - -## Distribution — F-Droid and Play from day one - -Not on either yet; both are targets, so build *for* them rather than retrofitting. - -- **F-Droid** requires from-source. The in-tree `:provider` module satisfies it; - a JitPack artifact would not. floret-kit's composite build already keeps the - kit from-source, and the reproducibility guard (`vcsInfo { include = false }` - in `app/build.gradle.kts`) is already in place. -- **Play** requires a rising target-API floor, a data-safety declaration, and - justification for `USE_EXACT_ALARM`. It also means dangerous permissions we - don't use are a liability, not just clutter — which is most of why - `GET_ACCOUNTS` and the stray receiver come out of the vendored provider. -- **Parked, not solved:** dual-distribution signing. F-Droid reproducible builds - verify against *our* signed APK; Play App Signing re-signs with Google's key. - Both can coexist, but it needs a deliberate pass before the first Play upload. - See `RELEASING.md`. - ---- - -## Dead ends — do not revisit - -- **One APK that detects at install time and adapts.** Impossible. - `` authorities and `` declarations are frozen at build - time and read by the OS at install; there is no install-time hook where our - code runs. And unlike a component, a `` cannot be runtime-toggled - — no `setComponentEnabledSetting` equivalent. -- **Dynamic feature modules** to deliver the provider conditionally. Conditions - are limited to hardware features / SDK / country — there is no "only if app X - is absent" — and they require Play, so they're dead for F-Droid regardless. -- **`frontend` / `standalone` build flavors.** Two flavors means two - `applicationId`s (two listings, two signing lines, and switching costs a user - their local data), or one `applicationId` and they can't coexist in a repo - anyway. Obsolete now that our provider coexists with everything instead of - replacing anything. -- **Maven Central for the provider.** `org.dmfs:opentasks-provider` *is* there — - but only up to `1.1.8.1` (2016, `jar` packaging, 3 versions). No DB 23. - Verified. -- **JitPack (`com.github.dmfs.opentasks:opentasks-provider:1.4.2`).** Has the - right version, but it's a prebuilt artifact (fails F-Droid from-source), it - can't have its hardcoded permission names renamed, and adding JitPack widens - the dependency trust surface — `settings.gradle.kts` is currently `google()` + - `mavenCentral()` only, under `FAIL_ON_PROJECT_REPOS`. *Still usable for a - throwaway spike* (a library string resource can be overridden from the app - module, so the authority rename works), but not for anything we ship. - ---- - -## Sequencing - -1. **Merge `fix/provider-interaction-review`** (`47cf99a`, currently unmerged - into `main`). It's blocking nothing and rotting, and it touches the exact - permission flow step 2 changes. -2. **Vendor `:provider`** under our own authority and permission namespace, with - the permission-gate bypass. This is the identity, done once, done right — - and it ships a complete local-first app to both stores. -3. **Export/backup.** Now a v1 feature, not a migration hack. -4. **File the DAVx5 issue.** Non-blocking, cheap, serves F-Droid users. -5. **Sync adapter.** The 1.x arc; design discussion pending. - -**Scope honesty:** the withdrawn notes costed this at "2–4 days shippable, +1 -week for F-Droid." Steps 2–5 are a substantially larger program than that, and -the roadmap should say so rather than inheriting the old estimate. - ---- - -## Open questions - -1. **Sync protocol coverage**, account model, conflict resolution — the next - discussion. -2. **Resolver ordering / mode selection UX** once our provider coexists with - external ones (see [Storage modes](#storage-modes--the-users-choice)). -3. **Does the vendored provider work with no account at all?** Local-only mode - depends on it entirely. First thing the vendoring work should prove. -4. **`ical4android` licensing** vs our MIT. -5. **jtx Board** as an additional External-mode candidate — richer contract, - later. (`PLAN.md` decision #3, still open.) - ---- - -## Related - -The provider-interaction review on `fix/provider-interaction-review` fixed, -among others: recurrence-aware editing (routes through the instances URI), -all-day UTC handling, the `DUE`/`DURATION` collision, per-task reminders (Alarm -property rows), and flow-recovery robustness. That makes `ROADMAP.md` open -decision #5 ("recurrence-aware editing out of scope for v1") **stale**. diff --git a/docs/fdroid-official/README.md b/docs/fdroid-official/README.md new file mode 100644 index 0000000..eda1fae --- /dev/null +++ b/docs/fdroid-official/README.md @@ -0,0 +1,50 @@ +# Official F-Droid submission + +`de.jeanlucmakiola.agendula.yml` is the fdroiddata recipe for the official +F-Droid repo. Same model as Calendula: F-Droid rebuilds each tag from source, +checks it is byte-identical to our signed APK (`Binaries`), and publishes our +binary, so official and self-hosted installs share a signature and update each +other. A version that doesn't reproduce is skipped, never published wrong. + +## Verified (2026-09-24) + +- **From-source rebuild matches the distributed APK.** v0.4.0 rebuilt from its + tag against the published `agendula_v0.4.0.apk`: 136 of 140 entries + identical. The other four were datastore's `libdatastore_shared_counter.so`, + stripped only because the local host had an NDK and CI doesn't. Fixed with + `jniLibs { keepDebugSymbols += "**/*.so" }`; the rebuild now ships them + byte-identical to the published APK. 1.0.0 is the first release with it. +- **Signing block is clean**: v2 signature + verity padding, no dependency + metadata. `fdroid scanner` finds no non-free classes and no extra blocks. +- **All dependencies are FOSS** (no Play Services, Firebase or analytics). + Crash reports are shown to the user and only sent by hand. +- **Committed version equals the tag-derived one**, so the pipeline's + versionCode pin is a no-op and F-Droid building the tag as-is matches. +- **floret-kit** is pinned to a tagged commit on its Codeberg `main`, so the + `submodules: true` checkout resolves from a clean clone. +- App signing cert SHA-256 (`AllowedAPKSigningKeys`) read from the published + APK: `097946b3…af120`. Agendula's own key, not Calendula's. + +`scripts/check_reproducible_release.sh` guards all of the above on every PR. + +## Listing + +Nothing listing-related goes in the recipe. F-Droid harvests +`fastlane/metadata/android//` from the tagged source, the same tree Play +is fed from: title, descriptions, icon, feature graphic, screenshots and +`changelogs/.txt`. `title.txt` becomes the app name per locale. + +## Submitting + +After `v1.0.0` is tagged and `agendula_v1.0.0.apk` is live on the self-hosted +repo: + +1. Fork `https://gitlab.com/fdroid/fdroiddata`, copy the recipe to + `metadata/de.jeanlucmakiola.agendula.yml`. +2. `fdroid readmeta && fdroid lint de.jeanlucmakiola.agendula`, then + `fdroid build -v -l de.jeanlucmakiola.agendula` to rebuild and verify. +3. Open the merge request. Later versions are picked up from new `vX.Y.Z` tags + (`AutoUpdateMode`), so there is no per-release work in fdroiddata. + +The recipe says `License: MIT`; `:dav` is vendored MPL-2.0 (`dav/PROVENANCE.md`). +If the reviewer asks, change it to `MIT AND MPL-2.0`. diff --git a/docs/fdroid-official/de.jeanlucmakiola.agendula.yml b/docs/fdroid-official/de.jeanlucmakiola.agendula.yml new file mode 100644 index 0000000..8f10f32 --- /dev/null +++ b/docs/fdroid-official/de.jeanlucmakiola.agendula.yml @@ -0,0 +1,44 @@ +# Draft fdroiddata recipe for the OFFICIAL F-Droid repository. Submitted as +# metadata/de.jeanlucmakiola.agendula.yml in fdroiddata; see README.md here. +# Not the self-hosted control file (that is ../../fdroid-metadata/). +# +# Reproducible build + developer-signed binary: F-Droid rebuilds from source, +# compares against our APK from `Binaries`, and on a match publishes OUR binary, +# so official and self-hosted installs share one signature. + +Categories: + - Task +License: MIT +AuthorName: Jean-Luc Makiola +SourceCode: https://codeberg.org/jlmakiola/agendula +IssueTracker: https://codeberg.org/jlmakiola/agendula/issues +Translation: https://weblate.dev.jeanlucmakiola.de/engage/agendula/ +Changelog: https://codeberg.org/jlmakiola/agendula/src/branch/main/CHANGELOG.md +Donate: https://ko-fi.com/jeanlucmakiola + +AutoName: Agendula + +RepoType: git +Repo: https://codeberg.org/jlmakiola/agendula.git +Binaries: https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo/agendula_v%v.apk + +# 1.0.0 is the first release that carries every reproducibility fix +# (scripts/check_reproducible_release.sh); 0.x releases strip datastore's .so +# files host-dependently and won't verify. submodules: floret-kit is an +# included build. No key.properties on the buildserver, so the release build +# comes out unsigned, which is what F-Droid compares. +Builds: + - versionName: 1.0.0 + versionCode: 10000 + commit: v1.0.0 + subdir: app + submodules: true + gradle: + - yes + +AllowedAPKSigningKeys: 097946b32c375a8cc81fcdf1985d601c06134b29613d9d8632f59178085af120 + +AutoUpdateMode: Version +UpdateCheckMode: Tags ^v[0-9.]+$ +CurrentVersion: 1.0.0 +CurrentVersionCode: 10000 diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..295643a --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,4 @@ +# fastlane is only the Play Developer API client here (see Fastfile). +json_key_file(ENV["SUPPLY_JSON_KEY"] || "play-service-account.json") + +package_name("de.jeanlucmakiola.agendula") diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..644facd --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,75 @@ +# Google Play: release upload and listing sync. +# +# fastlane/metadata/android is the single source for both stores: the official +# F-Droid repo harvests it from the tagged tree, the self-hosted repo gets it +# through scripts/fastlane_to_fdroid_localized.sh, and this pushes it to Play. +# Building and signing stay with Gradle so the release build remains +# F-Droid-reproducible. + +require "fileutils" +require "tmpdir" + +default_platform(:android) + +ROOT = File.expand_path("..", __dir__) +METADATA = File.join(ROOT, "fastlane/metadata/android") + +# Graphics are committed once, under en-US (F-Droid's fallback). Play falls back +# to its *default language*, en-US for this app; if that ever differs +# (PLAY_DEFAULT_LOCALE), the listing lane copies the graphics across. +IMAGE_LOCALE = "en-US" + +platform :android do + desc "Upload an already-built, already-signed AAB to Play" + lane :deploy do |options| + aab = File.expand_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab", ROOT) + UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab) + + supply( + aab: aab, + metadata_path: METADATA, + track: options[:track] || "production", + release_status: options[:release_status] || "completed", + # The APK belongs to F-Droid and the Codeberg download. + skip_upload_apk: true, + # The listing goes through the `listing` lane, deliberately; overwriting + # a live listing by accident triggers a Play review. + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true, + # "What's New" tracks every release, from every locale's + # changelogs/.txt — the same files F-Droid shows. + skip_upload_changelogs: false, + validate_only: options[:dry_run].to_s == "true", + ) + end + + desc "Push listing text and graphics from the repo to Play" + desc " dry_run:true validate against the API and discard the edit" + lane :listing do |options| + sh("python3", File.join(ROOT, "scripts/check_store_listing.py"), "--complete") + + default_locale = ENV["PLAY_DEFAULT_LOCALE"] || IMAGE_LOCALE + + Dir.mktmpdir("play-listing") do |staged| + FileUtils.cp_r("#{METADATA}/.", staged) + src = File.join(staged, IMAGE_LOCALE, "images") + dst = File.join(staged, default_locale, "images") + FileUtils.cp_r(src, dst) if default_locale != IMAGE_LOCALE && !File.exist?(dst) + + supply( + metadata_path: staged, + skip_upload_aab: true, + skip_upload_apk: true, + skip_upload_metadata: false, + # "What's New" belongs to a release on a track, not to the listing. + skip_upload_changelogs: true, + skip_upload_images: false, + skip_upload_screenshots: false, + # Only re-upload graphics whose content changed. + sync_image_upload: true, + validate_only: options[:dry_run].to_s == "true", + ) + end + end +end diff --git a/fastlane/metadata/android/de-DE/changelogs/10000.txt b/fastlane/metadata/android/de-DE/changelogs/10000.txt new file mode 100644 index 0000000..6458e4c --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/10000.txt @@ -0,0 +1,11 @@ +### Neu +- CalDAV-Sync eingebaut: Nextcloud, Radicale, Baïkal und mehr. +- Agendula speichert deine Aufgaben selbst, keine andere App nötig. Aus + OpenTasks oder tasks.org übernimmst du sie unter Einstellungen → Speicher. +- Wiederkehrende Aufgaben, mehrere Erinnerungen, Listen in der App, + iCalendar-Import und -Export, ein Widget und eine Schnelleinstellungs-Kachel. + +### Geändert +- Neue Einstellungen: ganztägige Erinnerungen, Schlummern, Sync-Intervall, + Zeitformat, Wochenbeginn. + diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt new file mode 100644 index 0000000..c21ae46 --- /dev/null +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1,26 @@ +Agendula ist eine moderne Open-Source-Aufgaben-App für Android und die +Schwester-App von Calendula. + +Synchronisiere deine Aufgaben per CalDAV: Füge ein Nextcloud-, Radicale-, +Baïkal- oder anderes CalDAV-Konto hinzu und wähle, welche Aufgabenlisten +mitkommen. Oder behalte alles auf dem Handy – Agendula speichert deine Aufgaben +selbst, ohne Konto und ohne weitere App. + +Du nutzt schon OpenTasks oder tasks.org? Dann lass Agendula mit dieser App +arbeiten, damit per DAVx5 o. Ä. synchronisierte Aufgaben so erscheinen, wie sie +sind – und übernimm sie jederzeit in Agendulas eigenen Speicher, unter +Einstellungen → Speicher. + +Was die App kann: Listen direkt in der App verwalten, Fälligkeits- und +Startdaten, ganztägige Aufgaben, Unteraufgaben, Prioritäten, Fortschritt, +wiederkehrende Aufgaben, bei denen du auch einzelne Termine bearbeiten kannst, +mehrere Erinnerungen pro Aufgabe mit Schlummern, iCalendar-Import und -Export, +ein Widget für den Startbildschirm, App-Verknüpfungen und eine +Schnelleinstellungs-Kachel. + +Das Design ist durchgehend echtes Material 3 Expressive, mit dynamischen Farben +und ausdrucksstarken Animationen und Formen. + +Datenschutz: keine Telemetrie, keine Analyse, keine Werbung. Agendula geht nur +online, um mit den CalDAV-Konten zu synchronisieren, die du selbst hinzufügst. +Ohne Konto verlassen deine Aufgaben das Gerät nur, wenn du sie exportierst. diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt new file mode 100644 index 0000000..74c8566 --- /dev/null +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +Eine Aufgaben-App in Material 3 Expressive, mit CalDAV-Sync. diff --git a/fastlane/metadata/android/de-DE/title.txt b/fastlane/metadata/android/de-DE/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/fastlane/metadata/android/en-GB/changelogs/10000.txt b/fastlane/metadata/android/en-GB/changelogs/10000.txt new file mode 100644 index 0000000..1b00a77 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/10000.txt @@ -0,0 +1,11 @@ +### 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. + diff --git a/fastlane/metadata/android/en-GB/full_description.txt b/fastlane/metadata/android/en-GB/full_description.txt new file mode 100644 index 0000000..7b9d71a --- /dev/null +++ b/fastlane/metadata/android/en-GB/full_description.txt @@ -0,0 +1,23 @@ +Agendula is a modern, open-source task app for Android, and the sibling of +Calendula. + +Sync your tasks over CalDAV: add a Nextcloud, Radicale, Baïkal or any other +CalDAV account and pick which task lists come along. Or keep everything on the +phone — Agendula stores your tasks itself, with no account and nothing else to +install. + +Already use OpenTasks or tasks.org? Point Agendula at that app instead, so tasks +synced by DAVx5 or similar show up as they are, and copy them into Agendula's +own storage whenever you like, under Settings → Storage. + +What it does: lists you manage in the app, due and start dates, all-day tasks, +subtasks, priorities, progress, repeating tasks you can edit one occurrence at a +time, several reminders per task with snooze, iCalendar import and export, a +home-screen widget, launcher shortcuts and a Quick Settings tile. + +The design is real Material 3 Expressive throughout, with dynamic colour and +expressive motion and shapes. + +Privacy: no telemetry, no analytics, no ads. Agendula goes online only to sync +with the CalDAV accounts you add. Without one, your tasks never leave the device +unless you export them. diff --git a/fastlane/metadata/android/en-GB/short_description.txt b/fastlane/metadata/android/en-GB/short_description.txt new file mode 100644 index 0000000..8fa4fbd --- /dev/null +++ b/fastlane/metadata/android/en-GB/short_description.txt @@ -0,0 +1 @@ +A Material 3 Expressive task app with CalDAV sync. diff --git a/fastlane/metadata/android/en-GB/title.txt b/fastlane/metadata/android/en-GB/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/en-GB/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/fastlane/metadata/android/en-US/changelogs/10000.txt b/fastlane/metadata/android/en-US/changelogs/10000.txt new file mode 100644 index 0000000..1b00a77 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10000.txt @@ -0,0 +1,11 @@ +### 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. + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 5a33820..1b2a613 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,10 +1,11 @@ -Agendula is a modern, open-source task app for Android. It works directly on an -existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to -your device via DAVx5, SmoothSync or DecSync show up automatically, and changes -you make sync back the same way — no own account, no own sync. +Agendula is a modern, open-source task app for Android. -The differentiator is the design: real Material 3 Expressive throughout, with -dynamic color, expressive motion, and expressive shapes. Sibling to Calendula. +Sync your tasks over CalDAV: add a Nextcloud, Radicale, Baïkal or any other CalDAV account and pick which task lists come along. Or keep everything on the phone — Agendula stores your tasks itself, with no account and nothing else to install. -Privacy: zero telemetry, no analytics, no network access of its own — your data -never leaves the device except through the sync app you already trust. +Already use OpenTasks or tasks.org? Point Agendula at that app instead, so tasks synced by DAVx5 or similar show up as they are, and copy them into Agendula's own storage whenever you like, under Settings → Storage. + +What it does: lists you manage in the app, due and start dates, all-day tasks, subtasks, priorities, progress, repeating tasks you can edit one occurrence at a time, several reminders per task with snooze, iCalendar import and export, a home-screen widget, launcher shortcuts and a Quick Settings tile. + +The design is real Material 3 Expressive throughout, with dynamic color and expressive motion and shapes. + +Privacy: no telemetry, no analytics, no ads. Agendula goes online only to sync with the CalDAV accounts you add. Without one, your tasks never leave the device unless you export them. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..9252ec2 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..8fc86e1 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png new file mode 100644 index 0000000..105aa12 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png new file mode 100644 index 0000000..8c96623 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png new file mode 100644 index 0000000..19c02f9 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png new file mode 100644 index 0000000..021c58a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png new file mode 100644 index 0000000..cb50bac Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index c3a2b18..8fa4fbd 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -A modern Material 3 Expressive task app for Android. +A Material 3 Expressive task app with CalDAV sync. diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt index 2c1774e..5c368ac 100644 --- a/fastlane/metadata/android/en-US/title.txt +++ b/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Agendula \ No newline at end of file +Agendula: Tasks diff --git a/fastlane/metadata/android/pt-BR/changelogs/10000.txt b/fastlane/metadata/android/pt-BR/changelogs/10000.txt new file mode 100644 index 0000000..ea3c8cd --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/10000.txt @@ -0,0 +1,11 @@ +### Novo +- Sincronização CalDAV integrada: Nextcloud, Radicale, Baïkal e outros. +- O Agendula guarda suas tarefas sozinho, sem outro app. Copie-as do OpenTasks + ou do tasks.org em Ajustes → Armazenamento. +- Tarefas recorrentes, vários lembretes por tarefa, listas no app, importação e + exportação iCalendar, widget e bloco de Configurações rápidas. + +### Alterado +- Novos ajustes: lembretes de dia inteiro, soneca, intervalo de sincronização, + formato de hora e início da semana. + diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt new file mode 100644 index 0000000..f867544 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -0,0 +1,24 @@ +O Agendula é um app de tarefas moderno e de código aberto para Android, irmão do +Calendula. + +Sincronize suas tarefas via CalDAV: adicione uma conta Nextcloud, Radicale, +Baïkal ou qualquer outro servidor CalDAV e escolha quais listas de tarefas +entram. Ou mantenha tudo no celular — o Agendula guarda suas tarefas sozinho, +sem conta e sem instalar mais nada. + +Já usa o OpenTasks ou o tasks.org? Aponte o Agendula para esse app, para que as +tarefas sincronizadas pelo DAVx5 ou similar apareçam como estão, e copie-as para +o armazenamento do próprio Agendula quando quiser, em Ajustes → Armazenamento. + +O que ele faz: listas gerenciadas no app, datas de vencimento e de início, +tarefas de dia inteiro, subtarefas, prioridades, progresso, tarefas recorrentes +que você pode editar uma ocorrência por vez, vários lembretes por tarefa com +soneca, importação e exportação iCalendar, widget na tela inicial, atalhos e um +bloco de Configurações rápidas. + +O design é Material 3 Expressive de verdade em todo o app, com cores dinâmicas e +movimentos e formas expressivos. + +Privacidade: sem telemetria, sem análises, sem anúncios. O Agendula só se +conecta à internet para sincronizar com as contas CalDAV que você adicionar. Sem +uma conta, suas tarefas só saem do aparelho se você as exportar. diff --git a/fastlane/metadata/android/pt-BR/short_description.txt b/fastlane/metadata/android/pt-BR/short_description.txt new file mode 100644 index 0000000..c5bf280 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/short_description.txt @@ -0,0 +1 @@ +Um app de tarefas em Material 3 Expressive, com sincronização CalDAV. diff --git a/fastlane/metadata/android/pt-BR/title.txt b/fastlane/metadata/android/pt-BR/title.txt new file mode 100644 index 0000000..5c368ac --- /dev/null +++ b/fastlane/metadata/android/pt-BR/title.txt @@ -0,0 +1 @@ +Agendula: Tasks diff --git a/fdroid-metadata/de.jeanlucmakiola.agendula.yml b/fdroid-metadata/de.jeanlucmakiola.agendula.yml index 6f7f9d9..de231fe 100644 --- a/fdroid-metadata/de.jeanlucmakiola.agendula.yml +++ b/fdroid-metadata/de.jeanlucmakiola.agendula.yml @@ -1,10 +1,11 @@ AuthorName: Jean-Luc Makiola License: MIT Name: Agendula -Summary: A modern Material 3 Expressive task app for Android. +Summary: A Material 3 Expressive task app with CalDAV sync. Categories: - Time SourceCode: https://codeberg.org/jlmakiola/agendula IssueTracker: https://codeberg.org/jlmakiola/agendula/issues +Donate: https://ko-fi.com/jeanlucmakiola diff --git a/floret-kit b/floret-kit index e047a2b..db4be68 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit e047a2bd48e6ea9cdc0d04f7dc9df311869ff2f1 +Subproject commit db4be68dad7d5aff2ca16bddfc5cb22fb96ea447 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b691c2a..407694a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,32 +1,73 @@ [versions] agp = "9.2.1" kotlin = "2.3.21" -ksp = "2.3.9" -hilt = "2.59.2" +ksp = "2.3.11" +hilt = "2.60.1" coreKtx = "1.19.0" appcompat = "1.7.1" -lifecycleRuntime = "2.10.0" +lifecycleRuntime = "2.11.0" activityCompose = "1.13.0" composeBom = "2026.05.01" # Material 3 Expressive APIs currently live only in the 1.5 alpha line. # Pin explicitly to override the BOM (which ships stable 1.4.0). # Re-evaluate when 1.5.0 stable lands. -material3 = "1.5.0-alpha21" +material3 = "1.5.0-alpha26" datastore = "1.2.1" -junit = "6.1.0" -junitPlatform = "6.1.0" +# Glance: kept on the same version as Calendula's widgets. +glance = "1.2.0" +# Room — Agendula's own task store. +room = "2.8.4" +kotlinxSerialization = "1.8.1" +# SAF directory writing for export/backup (DocumentFile). +documentfile = "1.1.0" +junit = "6.1.3" +junitPlatform = "6.1.3" truth = "1.4.5" androidxJunit = "1.3.0" espressoCore = "3.7.0" kotlinxDatetime = "0.7.0" kotlinxCoroutines = "1.10.2" turbine = "1.2.0" -hiltNavigationCompose = "1.3.0" +# androidx.hilt — one version for the whole group. hilt-work and +# hilt-navigation-compose splitting versions inside a group is the kind of thing +# that resolves fine and then fails at runtime. +androidxHilt = "1.4.0" +# WorkManager: sync runs here, triggered *through* the sync-adapter framework. +work = "2.11.2" +# Custom Tabs, for Nextcloud Login Flow v2. +browser = "1.10.0" navigationCompose = "2.9.0" -lifecycleCompose = "2.10.0" +lifecycleCompose = "2.11.0" androidxTestRules = "1.7.0" -# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). -glance = "1.1.1" + +# --- :dav (vendored dav4jvm) ------------------------------------------------- +# dav4jvm 2.2.1 is the last OkHttp release; 3.0.0 deleted the OkHttp package for +# Ktor and 4.x additionally requires Java 21 bytecode, which we do not target. +# See dav/PROVENANCE.md. OkHttp 4.12.0 is what the vendored tree was written +# against and what DAVx5 ships. +okhttp = "4.12.0" +# org.xmlpull.v1 ships in the Android framework, so xpp3 is compileOnly + +# testImplementation and never reaches the APK. +xpp3 = "1.1.6" + +# --- :caldav (our CalDAV protocol layer) ------------------------------------- +# SRV/TXT lookups for RFC 6764 discovery. Android's resolver exposes no SRV API +# below API 29's DnsResolver (which is callback-only and cannot do TXT paths), +# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5 +# uses. BSD-3 — it goes on the attribution screen with the rest. +dnsjava = "3.6.3" +# WebDAV-Push delivery. The connector only: DAVx5 also ships the embedded FCM +# distributor, which pulls in Play Services and would get us flagged on F-Droid. +unifiedpush = "3.3.5" +# The vendored dav4jvm tests and :caldav's are JUnit 4, unlike the app's JUnit 5. +junit4 = "4.13.2" + +# RFC 5545 recurrence expansion. The last of the dmfs dependencies: the vendored +# `:provider` module that needed the rest was deleted, +# and lib-recur stayed because our own store expands a series in process. +# Pinned at 0.12.2 — 0.16.0 removed RecurrenceSet. rfc5545-datetime arrives with +# it as part of its API surface, so it is not declared separately. +dmfsLibRecur = "0.12.2" [libraries] # AndroidX core @@ -53,9 +94,23 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +# Room — the own-store database +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# Glance — the home-screen task widget +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } + # DataStore androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +# SAF — writing the export into a user-chosen folder +androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } + # Unit tests junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } @@ -76,24 +131,56 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor # Test - Flow assertions turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } -# Hilt navigation-compose (for hiltViewModel() in Composables) -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +# Hilt navigation-compose (hiltViewModel() moved out of it in 1.4.0, into +# hilt-lifecycle-viewmodel-compose — which it still brings in transitively, but +# we call that API directly, so it is declared here rather than inherited) +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHilt" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } # Navigation-compose (the NavHost / back stack) androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } # Lifecycle compose (for collectAsStateWithLifecycle) androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } - -# Glance — Jetpack home-screen widgets (Compose-like RemoteViews) -androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } -androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } +# ProcessLifecycleOwner — the WAL checkpoint hangs off ON_STOP. +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntime" } # Android tests - GrantPermissionRule androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } +unifiedpush-connector = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpush" } + +# :dav — vendored dav4jvm (see dav/PROVENANCE.md) +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +# Test-only: MockWebServer over TLS, so an HTTPS-only code path can be exercised. +okhttp-tls = { group = "com.squareup.okhttp3", name = "okhttp-tls", version.ref = "okhttp" } +xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" } + +# :caldav — RFC 6764 service discovery +dnsjava = { group = "dnsjava", name = "dnsjava", version.ref = "dnsjava" } +junit4 = { group = "junit", name = "junit", version.ref = "junit4" } + +# Recurrence expansion for our own store +dmfs-lib-recur = { group = "org.dmfs", name = "lib-recur", version.ref = "dmfsLibRecur" } + +# floret-kit — the shared house library, an included build (see settings.gradle.kts). +# Deliberately version-less: the composite build substitutes these coordinates +# with the submodule's own projects, so a version here would be fiction. +floret-core-time = { group = "de.jeanlucmakiola.floret", name = "core-time" } +floret-core-reminders = { group = "de.jeanlucmakiola.floret", name = "core-reminders" } +floret-core-locale = { group = "de.jeanlucmakiola.floret", name = "core-locale" } +floret-core-crash = { group = "de.jeanlucmakiola.floret", name = "core-crash" } +floret-identity = { group = "de.jeanlucmakiola.floret", name = "identity" } +floret-components = { group = "de.jeanlucmakiola.floret", name = "components" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/scripts/check_reproducible_release.sh b/scripts/check_reproducible_release.sh index ed08135..357b4a0 100755 --- a/scripts/check_reproducible_release.sh +++ b/scripts/check_reproducible_release.sh @@ -17,6 +17,8 @@ # 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency # metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's # binary scanner rejects as an extra signing block. +# 4. jniLibs { keepDebugSymbols += "**/*.so" } — else AGP strips prebuilt .so +# files only when an NDK is installed, so the bytes depend on the host. set -euo pipefail APP="app/build.gradle.kts" @@ -62,6 +64,17 @@ else fail=1 fi +# 4. Prebuilt native libs must not be stripped. AGP strips them only if an NDK is +# installed, so the output would depend on the build host. +if grep -Pzoq 'jniLibs\s*\{[^}]*keepDebugSymbols\s*\+=\s*"\*\*/\*\.so"' "$APP"; then + echo "OK: jniLibs keepDebugSymbols — native libs ship unstripped on every host." +else + echo "ERROR: '$APP' is missing 'jniLibs { keepDebugSymbols += \"**/*.so\" }'." >&2 + echo " AGP strips .so files only when an NDK is present, so a from-source" >&2 + echo " rebuild would not match the published APK." >&2 + fail=1 +fi + if [ "$fail" -ne 0 ]; then echo >&2 echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2 diff --git a/scripts/check_store_listing.py b/scripts/check_store_listing.py new file mode 100755 index 0000000..907a199 --- /dev/null +++ b/scripts/check_store_listing.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Validate fastlane/metadata/android against what BOTH stores accept. + +The fastlane tree is the single source for the F-Droid listing (official repo +harvests it, the self-hosted repo gets it via fastlane_to_fdroid_localized.sh) +and for the Play listing (`bundle exec fastlane listing`). F-Droid accepts +nearly anything; Play rejects a lot at upload time. Checking against Play's +rules here is what keeps the graphics shippable to both from the same files. + + scripts/check_store_listing.py validate what exists + scripts/check_store_listing.py --complete also require everything a Play + listing needs to go live + +SDK- and dependency-free (PNG/JPEG headers are parsed by hand) so CI can run it +without setup. +""" +import re +import struct +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +META = ROOT / "fastlane/metadata/android" +RES = ROOT / "app/src/main/res" + +# Play's limits; F-Droid truncates or recommends the same. +TEXT_LIMITS = {"title.txt": 30, "short_description.txt": 80, "full_description.txt": 4000} +CHANGELOG_LIMIT = 500 + +# The fallback locale of both stores: F-Droid always, Play as the app's default +# language. It carries the full text and every graphic. +REQUIRED_LOCALES = ("en-US",) +IMAGE_LOCALE = "en-US" + +SCREENSHOT_DIRS = ("phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", + "tvScreenshots", "wearScreenshots") +# Play policy on the title: no ranking, price or promotional terms. +TITLE_BANNED = re.compile(r"\b(best|top|#1|no\.? ?1|free|sale|discount|new|hot|download now)\b", re.I) + +errors, warnings = [], [] + + +def err(path, msg): + errors.append(f"{path.relative_to(ROOT)}: {msg}") + + +def warn(path, msg): + warnings.append(f"{path.relative_to(ROOT)}: {msg}") + + +def image_info(path): + """(format, width, height, has_alpha) or None if unreadable.""" + data = path.read_bytes() + if data[:8] == b"\x89PNG\r\n\x1a\n": + w, h, _depth, color = struct.unpack(">IIBB", data[16:26]) + has_alpha = color in (4, 6) or b"tRNS" in data[:data.find(b"IDAT")] + return "png", w, h, has_alpha + if data[:2] == b"\xff\xd8": + i = 2 + while i + 9 < len(data): + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + seg_len = struct.unpack(">H", data[i + 2:i + 4])[0] + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + h, w = struct.unpack(">HH", data[i + 5:i + 9]) + return "jpeg", w, h, False + i += 2 + seg_len + return None + + +def check_image(path, *, size=None, alpha=None, max_mb, screenshot=False): + info = image_info(path) + if info is None: + err(path, "not a PNG or JPEG") + return + fmt, w, h, has_alpha = info + if size and (w, h) != size: + err(path, f"is {w}x{h}, Play requires exactly {size[0]}x{size[1]}") + if alpha is True and (fmt != "png" or not has_alpha): + err(path, "must be a 32-bit PNG (with alpha channel)") + if alpha is False and has_alpha: + err(path, "has an alpha channel; Play wants 24-bit PNG or JPEG") + if screenshot: + short, long_ = sorted((w, h)) + if short < 320 or long_ > 3840: + err(path, f"is {w}x{h}; each side must be 320–3840 px") + if long_ > 2 * short: + err(path, f"is {w}x{h}; the long side may be at most twice the short side") + if short < 1080: + warn(path, f"is {w}x{h}; Play only features screenshots at 1080 px or more") + mb = path.stat().st_size / 1_000_000 + if mb > max_mb: + err(path, f"is {mb:.1f} MB, over Play's {max_mb} MB limit") + + +def check_images(locale_dir): + images = locale_dir / "images" + if not images.is_dir(): + return + if locale_dir.name != IMAGE_LOCALE: + warn(images, f"graphics belong in {IMAGE_LOCALE} only; both stores fall back to it") + for f in images.iterdir(): + if f.is_file() and f.stem not in ("icon", "featureGraphic", "promoGraphic", "tvBanner"): + err(f, "unknown image; neither store will pick it up") + icon = images / "icon.png" + if icon.exists(): + check_image(icon, size=(512, 512), alpha=True, max_mb=1) + for name, size in (("featureGraphic", (1024, 500)), ("promoGraphic", (180, 120)), + ("tvBanner", (1280, 720))): + for f in images.glob(f"{name}.*"): + check_image(f, size=size, alpha=False, max_mb=15) + for d in SCREENSHOT_DIRS: + shots = sorted(p for p in (images / d).glob("*") if p.is_file()) + if len(shots) > 8: + err(images / d, f"{len(shots)} screenshots; Play takes at most 8") + for s in shots: + check_image(s, alpha=False, max_mb=8, screenshot=True) + + +def version_code(): + gradle = (ROOT / "app/build.gradle.kts").read_text() + name = re.search(r'versionName\s*=\s*"([^"]+)"', gradle).group(1) + parts = (name.split(".") + ["0", "0"])[:3] + return int(parts[0]) * 10000 + int(parts[1]) * 100 + int(parts[2]) + + +def shipped_languages(): + langs = set() + for d in RES.glob("values-*"): + if (d / "strings.xml").exists(): + m = re.fullmatch(r"values-([a-z]{2,3})(?:-r([A-Z]{2}))?", d.name) + if m: + langs.add((m.group(1), m.group(2))) + return langs + + +def main(): + complete = "--complete" in sys.argv[1:] + current = version_code() + locales = sorted(d for d in META.iterdir() if d.is_dir()) + names = {d.name for d in locales} + + for loc in REQUIRED_LOCALES: + if loc not in names: + err(META / loc, "missing; this locale is a store fallback and must exist") + + for d in locales: + has_text = any((d / f).exists() for f in TEXT_LIMITS) + for fname, limit in TEXT_LIMITS.items(): + f = d / fname + if not f.exists(): + if has_text or d.name in REQUIRED_LOCALES: + err(f, "missing (a locale with any listing text needs all three files)") + continue + text = f.read_text(encoding="utf-8").strip() + if not text: + err(f, "empty") + elif len(text) > limit: + err(f, f"{len(text)} chars, limit {limit}") + if fname == "title.txt" and TITLE_BANNED.search(text): + warn(f, "promotional term in the title; Play policy forbids these") + for f in sorted((d / "changelogs").glob("*.txt")): + n = len(f.read_text(encoding="utf-8").strip()) + if f.stem == str(current) and n > CHANGELOG_LIMIT: + err(f, f"{n} chars, limit {CHANGELOG_LIMIT}; Play would reject this release") + elif n > CHANGELOG_LIMIT: + warn(f, f"{n} chars, over {CHANGELOG_LIMIT} (already published)") + check_images(d) + + for lang, region in sorted(shipped_languages(), key=str): + match = f"{lang}-{region}" if region else lang + if not any(n == match or n.startswith(f"{lang}-") for n in names): + warn(META, f"app ships values-{lang}{'-r' + region if region else ''} " + "but there is no store locale for it") + + if complete: + images = META / IMAGE_LOCALE / "images" + if not (images / "icon.png").exists(): + err(images / "icon.png", "required for Play") + if not list(images.glob("featureGraphic.*")): + err(images / "featureGraphic.png", "required for Play (1024x500, no alpha)") + if len(list((images / "phoneScreenshots").glob("*"))) < 2: + err(images / "phoneScreenshots", "Play requires at least 2 phone screenshots") + for loc in REQUIRED_LOCALES: + if not (META / loc / "changelogs" / f"{current}.txt").exists(): + err(META / loc / "changelogs" / f"{current}.txt", "no What's New for this version") + + for w in warnings: + print(f"warning: {w}") + for e in errors: + print(f"ERROR: {e}", file=sys.stderr) + if errors: + return 1 + print(f"Store listing OK ({len(locales)} locales, {len(warnings)} warnings).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/emulator_screenshot.sh b/scripts/emulator_screenshot.sh new file mode 100755 index 0000000..0a3a20d --- /dev/null +++ b/scripts/emulator_screenshot.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Capture the emulator screen and frame it in the AVD's device skin, like +# Android Studio's "Take screenshot" with the device frame on. +# +# usage: scripts/emulator_screenshot.sh [serial] +# writes design/store/raw/.png and design/store/framed/.png +set -euo pipefail +cd "$(dirname "$0")/.." + +NAME="${1:?need a name, e.g. 01-today}" +SERIAL="${2:-emulator-5554}" +SDK="${ANDROID_HOME:-$HOME/Android/Sdk}" + +AVD=$(adb -s "$SERIAL" emu avd name | head -1 | tr -d '\r') +SKIN=$(grep -oP '^skin.path=\K.*' "$HOME/.android/avd/$AVD.avd/config.ini" || true) +[ -n "$SKIN" ] || SKIN="$SDK/skins/$(grep -oP '^skin.name=\K.*' "$HOME/.android/avd/$AVD.avd/config.ini")" +[ -f "$SKIN/layout" ] || { echo "No skin layout for $AVD at $SKIN" >&2; exit 1; } + +# Screen offset inside the frame: the `device` part of the portrait layout. +read -r X Y < <(awk '/part2/{p=1} p&&/ x /{x=$2} p&&/ y /{print x, $2; exit}' "$SKIN/layout") + +mkdir -p design/store/raw design/store/framed +RAW="design/store/raw/$NAME.png" +OUT="design/store/framed/$NAME.png" +adb -s "$SERIAL" exec-out screencap -p > "$RAW" + +BACK=$(ls "$SKIN"/back.* | head -1) +MASK=$(ls "$SKIN"/mask.* 2>/dev/null | head -1 || true) +magick "$BACK" -alpha set -background none \ + \( -size "$(magick identify -format '%wx%h' "$BACK")" xc:none \ + "$RAW" -geometry "+$X+$Y" -composite \ + ${MASK:+"$MASK" -geometry "+$X+$Y" -composite} \) \ + +swap -composite "$OUT" + +echo "$OUT ($(magick identify -format '%wx%h' "$OUT"), frame $(basename "$SKIN"))" diff --git a/scripts/fastlane_to_fdroid_localized.sh b/scripts/fastlane_to_fdroid_localized.sh index 044e56e..b84f3f4 100755 --- a/scripts/fastlane_to_fdroid_localized.sh +++ b/scripts/fastlane_to_fdroid_localized.sh @@ -14,8 +14,8 @@ # short_description.txt -> summary.txt # full_description.txt -> description.txt # title.txt -> name.txt -# images/icon.png -> icon.png -# images/phoneScreenshots/* -> phoneScreenshots/* +# images/{icon,featureGraphic,promoGraphic,tvBanner}.* -> same name +# images/Screenshots/* -> Screenshots/* # changelogs/.txt -> changelogs/.txt # (changelogs are seeded into the fastlane tree by # scripts/sync_changelog_to_fastlane.sh.) @@ -32,11 +32,14 @@ for locdir in "$SRC"/*/; do [ -f "$locdir/short_description.txt" ] && cp "$locdir/short_description.txt" "$dst/summary.txt" [ -f "$locdir/full_description.txt" ] && cp "$locdir/full_description.txt" "$dst/description.txt" [ -f "$locdir/title.txt" ] && cp "$locdir/title.txt" "$dst/name.txt" - [ -f "$locdir/images/icon.png" ] && cp "$locdir/images/icon.png" "$dst/icon.png" - if [ -d "$locdir/images/phoneScreenshots" ]; then - mkdir -p "$dst/phoneScreenshots" - cp "$locdir"images/phoneScreenshots/* "$dst/phoneScreenshots/" - fi + for img in "$locdir"images/{icon,featureGraphic,promoGraphic,tvBanner}.*; do + cp "$img" "$dst/" + done + for shots in "$locdir"images/*Screenshots/; do + t="$(basename "$shots")" + mkdir -p "$dst/$t" + cp "$shots"* "$dst/$t/" + done # Per-version changelogs live in the same fastlane tree (see # scripts/sync_changelog_to_fastlane.sh) and map straight across. if [ -d "$locdir/changelogs" ]; then diff --git a/scripts/make_import_fixture.py b/scripts/make_import_fixture.py new file mode 100644 index 0000000..3c87a44 --- /dev/null +++ b/scripts/make_import_fixture.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Build the dmfs `tasks.db` fixture the one-shot import is tested against. + +The provider is deleted, so the import cannot +keep a real v0.3.x database around by asking the provider to create one. This +writes the schema the provider's TaskDatabaseHelper produces at DATABASE_VERSION +23 — tables `Lists`, `Tasks`, `Properties` only, which are the three the import +reads — and seeds a spread that covers what the import has to get right. + +Regenerate with: python3 scripts/make_import_fixture.py +""" + +from __future__ import annotations + +import os +import sqlite3 +import sys + +OUT = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "app/src/androidTest/assets/tasks-v23.db", +) + +DDL = [ + """CREATE TABLE Lists ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + account_name TEXT, account_type TEXT, list_name TEXT, list_color INTEGER, + list_access_level INTEGER, visible INTEGER, sync_enabled INTEGER, + list_owner TEXT, _dirty INTEGER DEFAULT 0, _sync_id TEXT, + sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT, sync4 TEXT, + sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""", + """CREATE TABLE Tasks ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER DEFAULT 0, + list_id INTEGER NOT NULL, title TEXT, location TEXT, geo TEXT, + description TEXT, url TEXT, organizer TEXT, priority INTEGER, + task_color INTEGER, class INTEGER, completed INTEGER, + completed_is_allday INTEGER, percent_complete INTEGER, + status INTEGER DEFAULT 0, is_new INTEGER, is_closed INTEGER, + dtstart INTEGER, created INTEGER, last_modified INTEGER, + is_allday INTEGER, tz TEXT, due INTEGER, duration TEXT, rdate TEXT, + exdate TEXT, rrule TEXT, parent_id INTEGER, sorting TEXT, + has_alarms INTEGER, has_properties INTEGER, pinned INTEGER, + original_instance_sync_id TEXT, original_instance_id INTEGER, + original_instance_time INTEGER, original_instance_allday INTEGER, + _dirty INTEGER DEFAULT 1, _deleted INTEGER DEFAULT 0, _sync_id TEXT, + _uid TEXT, sync_version TEXT, sync1 TEXT, sync2 TEXT, sync3 TEXT, + sync4 TEXT, sync5 TEXT, sync6 TEXT, sync7 TEXT, sync8 TEXT)""", + """CREATE TABLE Properties ( + property_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, + mimetype INTEGER, prop_version INTEGER, + data0 TEXT, data1 TEXT, data2 TEXT, data3 TEXT, data4 TEXT, data5 TEXT, + data6 TEXT, data7 TEXT, data8 TEXT, data9 TEXT, data10 TEXT, + data11 TEXT, data12 TEXT, data13 TEXT, data14 TEXT, data15 TEXT, + prop_sync1 TEXT, prop_sync2 TEXT, prop_sync3 TEXT, prop_sync4 TEXT, + prop_sync5 TEXT, prop_sync6 TEXT, prop_sync7 TEXT, prop_sync8 TEXT)""", +] + +LOCAL = ("Local", "org.dmfs.account.LOCAL") +CALDAV = ("me@example.org", "bitfire.at.davdroid") +ALARM_MIMETYPE = "vnd.android.cursor.item/alarm" + +# 2026-01-15T09:00:00Z, and a day in millis. +T0 = 1_768_467_600_000 +DAY = 86_400_000 + + +def seed(db: sqlite3.Connection) -> None: + lists = db.executemany( + "INSERT INTO Lists (_id, account_name, account_type, list_name, list_color," + " visible, sync_enabled, list_owner) VALUES (?,?,?,?,?,?,?,?)", + [ + (1, *LOCAL, "Personal", 0xFF7A5C6B, 1, 1, None), + (2, *LOCAL, "Hidden list", 0xFF445566, 0, 1, None), + # An external account inside our own authority: only reachable if the + # user pointed DAVx5 at us. Imported as a local list, UID preserved. + (3, *CALDAV, "Work", 0xFF2244AA, 1, 1, "Me"), + ], + ) + del lists + + def task(**kw): + cols = ", ".join(kw) + marks = ", ".join("?" * len(kw)) + db.execute(f"INSERT INTO Tasks ({cols}) VALUES ({marks})", tuple(kw.values())) + + task(_id=1, list_id=1, title="Buy milk", due=T0 + DAY, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000001", created=T0, last_modified=T0) + # No UID: the import mints one. + task(_id=2, list_id=1, title="Call the dentist", due=T0 + 2 * DAY, status=1, + percent_complete=40, created=T0, last_modified=T0) + task(_id=3, list_id=1, title="Gather receipts", parent_id=1, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000003", created=T0, last_modified=T0) + task(_id=4, list_id=1, title="Renew domain", status=2, percent_complete=100, + completed=T0 - DAY, is_closed=1, + _uid="a1b2c3d4-0000-4000-8000-000000000004", created=T0, last_modified=T0) + task(_id=5, list_id=1, title="Water the plants", dtstart=T0, due=T0 + 3600_000, + rrule="FREQ=WEEKLY;BYDAY=MO,TH", tz="Europe/Berlin", + _uid="a1b2c3d4-0000-4000-8000-000000000005", created=T0, last_modified=T0) + task(_id=6, list_id=1, title="Team offsite", dtstart=T0 - T0 % DAY, + due=T0 - T0 % DAY + DAY, is_allday=1, + _uid="a1b2c3d4-0000-4000-8000-000000000006", created=T0, last_modified=T0) + # Deleted-but-unsynced: gone as far as the user is concerned, so not imported. + task(_id=7, list_id=1, title="Cancelled thing", _deleted=1, + _uid="a1b2c3d4-0000-4000-8000-000000000007", created=T0, last_modified=T0) + task(_id=8, list_id=2, title="Task in a hidden list", status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000008", created=T0, last_modified=T0) + task(_id=9, list_id=3, title="Ship the release", due=T0 + 5 * DAY, status=0, + _uid="a1b2c3d4-0000-4000-8000-000000000009", created=T0, last_modified=T0, + _sync_id="https://dav.example.org/tasks/9.ics") + + db.executemany( + "INSERT INTO Properties (property_id, task_id, mimetype, data0, data1, data2, data3)" + " VALUES (?,?,?,?,?,?,?)", + [ + # data0 minutes before, data1 reference (1 = DUE), data3 alarm type. + (1, 1, ALARM_MIMETYPE, "30", "1", None, "1"), + (2, 9, ALARM_MIMETYPE, "1440", "1", "Ship it", "1"), + # A non-alarm property the import must skip. + (3, 1, "vnd.android.cursor.item/category", "Errands", None, None, None), + ], + ) + + +def main() -> int: + os.makedirs(os.path.dirname(OUT), exist_ok=True) + if os.path.exists(OUT): + os.remove(OUT) + db = sqlite3.connect(OUT) + try: + for statement in DDL: + db.execute(statement) + db.execute("PRAGMA user_version = 23") + seed(db) + db.commit() + finally: + db.close() + print(f"wrote {OUT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/make_store_sample_tasks.py b/scripts/make_store_sample_tasks.py new file mode 100644 index 0000000..31a7e97 --- /dev/null +++ b/scripts/make_store_sample_tasks.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Write sample task lists for store screenshots, dated relative to a given day. + + scripts/make_store_sample_tasks.py [YYYY-MM-DD] + +Writes design/store/sample/.ics, one list per file, for the in-app +iCalendar import. Defaults to today. +""" +import sys +from datetime import date, timedelta +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "design/store/sample" + +# (summary, due offset in days or None, extras) +LISTS = { + "Personal": [ + ("Renew passport", 0, {"PRIORITY": "1", "DESCRIPTION": "Photos are in the desk drawer. Book a slot at the town hall first."}), + ("Call the dentist", 0, {}), + ("Water the plants", 0, {"RRULE": "FREQ=DAILY;INTERVAL=3"}), + ("Go for a run", 0, {"RRULE": "FREQ=WEEKLY;BYDAY=MO,WE,FR"}), + ("Cancel the old gym membership", -2, {"PRIORITY": "1"}), + ("Birthday gift for Sam", 3, {"PERCENT-COMPLETE": "33", "children": [ + ("Order the book", True), ("Wrap it", False), ("Write a card", False)]}), + ("Plan the weekend hike", 4, {"LOCATION": "Saxon Switzerland"}), + ("Read \"The Midnight Library\"", None, {"PERCENT-COMPLETE": "40"}), + ("Back up the photos", None, {}), + ("Pick up dry cleaning", -1, {"done": True}), + ], + "Work": [ + ("Send the Q3 report to Lena", -1, {"PRIORITY": "1"}), + ("Review Alex's pull request", 0, {"PRIORITY": "5"}), + ("Weekly team sync notes", 1, {"RRULE": "FREQ=WEEKLY;BYDAY=TH"}), + ("Prepare slides for the quarterly review", 2, {"PRIORITY": "5", "PERCENT-COMPLETE": "50", "children": [ + ("Collect the numbers", True), ("Draft the outline", True), + ("Design the charts", False), ("Rehearse once", False)]}), + ("Book flights to the Lisbon conference", 6, {"LOCATION": "Lisbon", "URL": "https://example.org/conference"}), + ("Update the onboarding docs", None, {"PRIORITY": "9"}), + ("Plan the team offsite", -1, {"done": True}), + ], + "Home": [ + ("Take out the recycling", 1, {"RRULE": "FREQ=WEEKLY;BYDAY=TH"}), + ("Fix the leaky tap", 4, {"DESCRIPTION": "Washer size is 1/2\". Turn off the water under the sink first."}), + ("Pay the rent", "month", {"RRULE": "FREQ=MONTHLY;BYMONTHDAY=1", "PRIORITY": "1"}), + ("Book the car service", 8, {}), + ("Clean the gutters", None, {}), + ("Change the bed sheets", 0, {"done": True}), + ], + "Groceries": [ + ("Oat milk", None, {}), + ("Eggs", None, {}), + ("Cherry tomatoes", None, {}), + ("Fresh basil", None, {}), + ("Coffee beans", None, {"PRIORITY": "1"}), + ("Sourdough bread", None, {"done": True}), + ("Parmesan", None, {"done": True}), + ], +} + + +def ics_date(d): + return d.strftime("%Y%m%d") + + +def todo(uid, summary, due, extras, stamp, parent=None, done=False): + lines = ["BEGIN:VTODO", f"UID:{uid}", f"DTSTAMP:{stamp}", f"SUMMARY:{summary}"] + if due is not None: + lines.append(f"DUE;VALUE=DATE:{ics_date(due)}") + if "RRULE" in extras: + lines.append(f"DTSTART;VALUE=DATE:{ics_date(due)}") + for key in ("PRIORITY", "PERCENT-COMPLETE", "LOCATION", "URL", "RRULE"): + if key in extras: + lines.append(f"{key}:{extras[key]}") + if "DESCRIPTION" in extras: + lines.append("DESCRIPTION:" + extras["DESCRIPTION"].replace(",", "\\,")) + if parent: + lines.append(f"RELATED-TO:{parent}") + if done: + lines += ["STATUS:COMPLETED", "PERCENT-COMPLETE:100", f"COMPLETED:{stamp}"] + else: + lines.append("STATUS:NEEDS-ACTION") + lines.append("END:VTODO") + return lines + + +def main(): + today = date.fromisoformat(sys.argv[1]) if len(sys.argv) > 1 else date.today() + stamp = today.strftime("%Y%m%dT080000Z") + next_first = (today.replace(day=1) + timedelta(days=32)).replace(day=1) + OUT.mkdir(parents=True, exist_ok=True) + for name, tasks in LISTS.items(): + lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Agendula//store sample//EN", + f"X-WR-CALNAME:{name}"] + for i, (summary, offset, extras) in enumerate(tasks): + due = next_first if offset == "month" else ( + None if offset is None else today + timedelta(days=offset)) + uid = f"sample-{name.lower()}-{i}" + lines += todo(uid, summary, due, extras, stamp, done=extras.get("done", False)) + for j, (child, child_done) in enumerate(extras.get("children", [])): + lines += todo(f"{uid}-{j}", child, None, {}, stamp, parent=uid, done=child_done) + lines.append("END:VCALENDAR") + (OUT / f"{name}.ics").write_text("\r\n".join(lines) + "\r\n", encoding="utf-8") + print(OUT / f"{name}.ics") + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_changelog_to_fastlane.sh b/scripts/sync_changelog_to_fastlane.sh index f6d0e93..d4a8190 100755 --- a/scripts/sync_changelog_to_fastlane.sh +++ b/scripts/sync_changelog_to_fastlane.sh @@ -1,16 +1,13 @@ #!/usr/bin/env bash -# Write the current version's CHANGELOG.md section into the fastlane changelog -# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/.txt -# (en-US is F-Droid's fallback locale, so it covers every language). +# Ensure the current version's "What's New" exists at +# fastlane/metadata/android/en-US/changelogs/.txt, which both F-Droid and +# Google Play publish. # -# Run this when cutting a release (after editing CHANGELOG.md and bumping -# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL -# F-Droid repo — which reads the changelog from the tagged source tree — shows -# this version's "What's New". The self-hosted release pipeline also runs it so -# its changelog never depends on the file having been committed. Idempotent. -# -# Extraction matches the awk used for the Gitea release notes so all three -# (release notes, self-hosted changelog, official changelog) stay in sync. +# A COMMITTED file wins and is never rewritten: it is a hand-written summary +# under 500 characters (Play's cap), not a copy of the CHANGELOG.md section. +# Only when the file is missing is it generated from that section, so the +# self-hosted release pipeline always has something to publish. The other +# locales' changelogs are written by hand and never touched here. set -euo pipefail cd "$(dirname "$0")/.." # repo root @@ -20,22 +17,38 @@ MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.} MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) +# Play rejects a longer "What's New" and F-Droid truncates it in-client. +MAX_CHARS=${MAX_CHARS:-500} + CL_DIR="fastlane/metadata/android/en-US/changelogs" mkdir -p "$CL_DIR" OUT="$CL_DIR/${VERSION_CODE}.txt" -awk -v ver="$VERSION" ' - $0 ~ "^## \\[" ver "\\]" { flag = 1; next } - /^## \[/ { flag = 0 } - flag' CHANGELOG.md > "$OUT" -# Trim leading blank lines (same as the pipeline did). -sed -i -e '/./,$!d' "$OUT" -if [ ! -s "$OUT" ]; then - echo "See CHANGELOG.md for $VERSION." > "$OUT" +if [ -s "$OUT" ]; then + ACTION="Kept" +else + ACTION="Generated" + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md > "$OUT" + # Trim leading blank lines (same as the pipeline did). + sed -i -e '/./,$!d' "$OUT" + [ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT" fi -CHARS=$(wc -m < "$OUT" | tr -d ' ') -echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)" -if [ "$CHARS" -gt 500 ]; then - echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2 +# Counted like check_store_listing.py: surrounding whitespace doesn't count. +CHARS=$(python3 -c 'import sys; print(len(open(sys.argv[1], encoding="utf-8").read().strip()))' "$OUT") +echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)" + +# Hard limit, enforced in CI (.forgejo/workflows/ci.yaml) and before a release. +if [ "$CHARS" -gt "$MAX_CHARS" ]; then + cat >&2 </dev/null || t adb shell pm revoke "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true echo -echo "Installed and reset. Now verify ON THE DEVICE before releasing:" -echo " 1. Launch from a clean state — the permission screen must appear (no crash)." -echo " 2. Grant tasks access — the task list must load." -echo " 3. Create a task with a due reminder and confirm the notification fires." -echo " 4. Exercise the release's headline changes end to end." +echo "Installed and reset. Now verify ON THE DEVICE before releasing." +echo +echo "Which path you are on depends on what else is installed — since 1.0.0 the" +echo "app owns its store, so the default path grants nothing at all:" +echo +echo " No OpenTasks / tasks.org on the device (the default, and what a fresh" +echo " install looks like):" +echo " 1. Launch from a clean state — the task list must load straight away." +echo " There is no permission to grant in this mode, so no gate appears." +echo " 2. Create a list, then a task in it — a fresh install has neither." +echo " 3. Give the task a due reminder and confirm the notification fires." +echo +echo " With OpenTasks or tasks.org installed:" +echo " 1. Launch from a clean state — the permission gate must appear (no crash)." +echo " 2. Grant tasks access — that app's task list must load." +echo " 3. Settings -> Storage -> Copy tasks from ... — the confirm must name a" +echo " real count, and switching to \"On this device\" must then show them." +echo " 4. Create a task with a due reminder and confirm the notification fires." +echo +echo " Either way: exercise the release's headline changes end to end." echo echo "Watch for crashes with: adb logcat -b crash" echo "Only merge the release branch to main once all of the above pass on a device" diff --git a/settings.gradle.kts b/settings.gradle.kts index f522deb..ae7dfb8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,6 @@ dependencyResolutionManagement { rootProject.name = "Agendula" include(":app") +include(":dav") +include(":caldav") includeBuild("floret-kit")