Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
671910f54b | ||
|
|
d986d5cdda | ||
|
|
00a1e9da15 | ||
|
|
ac01993d41 | ||
|
|
88f7a16bff | ||
|
|
ffdcf1de19 | ||
|
|
9e9fd106e9 | ||
|
|
7bbdbe60e3 | ||
|
|
2baaa2d516 | ||
|
|
13433aeca5 | ||
|
|
1f67de18ae | ||
|
|
fccdbd825c | ||
|
|
44489c5665 | ||
|
|
633ec5b5d3 |
@@ -37,6 +37,29 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
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/<locale>/changelogs/<versionCode>.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
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# 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
|
# about defaults to building. Only paths the Gradle build provably
|
||||||
# never reads belong here — note that the workflows themselves, the
|
# never reads belong here — note that the workflows themselves, the
|
||||||
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
|
# `.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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BASE="${{ github.base_ref }}"
|
BASE="${{ github.base_ref }}"
|
||||||
@@ -141,9 +164,12 @@ jobs:
|
|||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew lintDebug
|
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
|
- name: Unit tests
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew testDebugUnitTest
|
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
|||||||
@@ -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
|
# 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,
|
# 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
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# 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,
|
# 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
|
# 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
|
# credentials) lives on the self-hosted Gitea instance, and this is the only
|
||||||
@@ -110,6 +115,16 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
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.
|
# 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
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
# re-uploads the existing index (recovery path).
|
# 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"
|
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
|
||||||
done
|
done
|
||||||
echo "Published $TAG to Codeberg."
|
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
|
||||||
|
|||||||
+20
@@ -65,3 +65,23 @@ Thumbs.db
|
|||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.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/
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.4.0] - 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+28
-31
@@ -1,29 +1,29 @@
|
|||||||
# Contributing to Agendula
|
# Contributing to Agendula
|
||||||
|
|
||||||
Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
|
Thanks for your interest in Agendula — a Material 3 Expressive task app with its
|
||||||
pure front-end over the OpenTasks `TaskContract` provider, with no own database
|
own Room task store and its own CalDAV sync, which can also work on top of the
|
||||||
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
|
OpenTasks `TaskContract` provider. This file covers the practical how.
|
||||||
(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.
|
|
||||||
|
|
||||||
## The one architectural rule
|
## The one architectural rule
|
||||||
|
|
||||||
Everything above the data layer talks to `TasksRepository` and sees only domain
|
Everything above the data layer talks to `TasksRepository` and sees only domain
|
||||||
types and Flows. **Provider column names, `TaskContract`, `ContentResolver`, and
|
types and Flows. **Room entities, provider column names, `TaskContract`,
|
||||||
the authority string never leak above `data/tasks/`.** This is what keeps
|
`ContentResolver` and the authority string never leak above `data/tasks/`.**
|
||||||
"Posture B" (bundling the provider later) an additive change instead of a
|
That seam is what let the store move from the provider to Room without touching
|
||||||
rewrite — see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §7. If a change
|
a screen, and what lets both stores sit behind one `TasksDataSource`. If a
|
||||||
would expose provider details to a ViewModel or the UI, it's in the wrong layer.
|
change would expose storage details to a ViewModel or the UI, it's in the wrong
|
||||||
|
layer.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- JDK 17
|
- JDK 17
|
||||||
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
|
- 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
|
- Any device or emulator for the default path — the store is Agendula's own.
|
||||||
anything touching the read/write paths (ideally with DAVx5 syncing a CalDAV
|
Debug builds seed an "Agendula Demo" list via `DemoSeeder`. For External
|
||||||
task list, so there's real data). Debug builds fall back to `DemoSeeder` for
|
mode, one with **OpenTasks** or **tasks.org** installed; for sync, a CalDAV
|
||||||
sample data.
|
account (a local Radicale is the quickest).
|
||||||
|
- Clone with `--recurse-submodules`: the `floret-kit` component library is a
|
||||||
|
submodule.
|
||||||
|
|
||||||
## Build, test, lint
|
## 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 |
|
| Layer | Lives in | Rule of thumb |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
|
| 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/` | |
|
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
|
||||||
| Screens | `ui/<area>/` | One ViewModel + immutable `UiState` per area; Compose for the screen. |
|
| Screens | `ui/<area>/` | 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
|
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
|
||||||
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
|
whitespace — all enforced by `.editorconfig` (2-space for yaml/toml/json/md).
|
||||||
Match the surrounding code.
|
Match the surrounding code.
|
||||||
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
|
- **Material 3 Expressive** for all UI, built from **floret-kit** components
|
||||||
colour-scheme tokens (never hardcoded colours), and canonical M3 components
|
first (`CollapsingScaffold`, `GroupedRow`, `InlineTextField`,
|
||||||
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
|
`FullScreenPicker` / `OptionPicker`, …) and colour-scheme tokens (never
|
||||||
new screen or component.
|
hardcoded colours). If a floret-kit component is nearly right, add the
|
||||||
- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
|
parameter there rather than dropping to raw Material 3.
|
||||||
the only Android-coupled data implementation so the rest stays JVM-testable.
|
- Prefer the domain layer for anything testable.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
|
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
|
||||||
come with JVM unit tests under `app/src/test/`. The data source is the
|
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.
|
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
|
||||||
- Add an instrumented test only when a path genuinely needs a real
|
- Add an instrumented test only when a path genuinely needs Android: the Room
|
||||||
`ContentResolver`.
|
data source, migrations and the import paths live under `app/src/androidTest/`.
|
||||||
|
|
||||||
## Commits & PRs
|
## 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
|
- Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` for any
|
||||||
user-visible change — its sections feed the release notes and F-Droid "What's
|
user-visible change — its sections feed the release notes and F-Droid "What's
|
||||||
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
|
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
|
- Don't bump `versionName` / `versionCode` in a regular PR — the committed
|
||||||
`versionName` is bumped only when **cutting a release** (that bump reaching
|
`versionName` is bumped only when **cutting a release** (that bump reaching
|
||||||
`main` is what triggers the release; the pipeline then mints the tag). See
|
`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
|
## Scope
|
||||||
|
|
||||||
Agendula stays true to its thesis: a front-end over **open** task backends
|
Agendula stays on **open** standards: CalDAV and iCalendar, through its own
|
||||||
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
|
sync or through the OpenTasks provider (OpenTasks and tasks.org). Proprietary
|
||||||
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
|
backends (Google Tasks, Microsoft To Do) are out of scope by design. jtx's
|
||||||
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's
|
|
||||||
richer contract is a possible later addition.
|
richer contract is a possible later addition.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -3,49 +3,131 @@
|
|||||||
<h1>Agendula</h1>
|
<h1>Agendula</h1>
|
||||||
|
|
||||||
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
|
||||||
Reads, writes, and reminds — on top of an existing tasks provider, with no own
|
Syncs over CalDAV, or keeps your tasks on the device. Open standards, no account
|
||||||
sync stack.</p>
|
required.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
<a href="https://codeberg.org/jlmakiola/agendula/actions"><img src="https://codeberg.org/jlmakiola/agendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/agendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||||
|
|
||||||
|
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Agendula is the task-list sibling to [Calendula](https://codeberg.org/jlmakiola/calendula).
|
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
|
It keeps its own task store, designed around RFC 5545's `VTODO`, and syncs it
|
||||||
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
|
with any CalDAV server — Nextcloud, Radicale, Baïkal and the rest. No account is
|
||||||
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
|
needed to use it: without one, your tasks simply stay on the phone.
|
||||||
database, no reinvented sync.
|
|
||||||
|
|
||||||
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
|
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;
|
“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
|
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.)
|
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
|
## What it does
|
||||||
> 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.
|
|
||||||
|
|
||||||
## 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**
|
## Sync
|
||||||
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
|
|
||||||
adapter — because it builds on the provider, not on any one sync app. Google
|
Add a CalDAV account under **Settings → Accounts** and choose which of its task
|
||||||
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean
|
lists to sync. Nextcloud signs in through its own login flow in the browser, so
|
||||||
owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane.
|
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
|
## Translations
|
||||||
|
|
||||||
Agendula ships in English so far, and would like not to. Translations are
|
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
||||||
managed on a self-hosted **Weblate**, and partial ones are fine — an
|
fine — an untranslated string simply falls back to English. Agendula ships in
|
||||||
untranslated string simply falls back to English.
|
English, German and Brazilian Portuguese so far.
|
||||||
|
|
||||||
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
**→ [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
|
and translate the strings in your browser. You can also reach this link in the
|
||||||
app from the top of **Settings → App language**.
|
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
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|||||||
+86
-11
@@ -27,12 +27,21 @@ android {
|
|||||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
||||||
versionCode = 400
|
// pipeline graduates it on its own. See docs/RELEASING.md.
|
||||||
versionName = "0.4.0"
|
versionCode = 10000
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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 {
|
signingConfigs {
|
||||||
@@ -66,6 +75,8 @@ android {
|
|||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
isMinifyEnabled = false
|
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
|
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
|
||||||
// and resource shrinking, but debug-signed and given its own applicationId
|
// and resource shrinking, but debug-signed and given its own applicationId
|
||||||
@@ -82,6 +93,8 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
matchingFallbacks += "release"
|
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 {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = 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
|
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||||
@@ -109,6 +126,12 @@ android {
|
|||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
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 {
|
lint {
|
||||||
@@ -129,6 +152,10 @@ android {
|
|||||||
isReturnDefaultValues = true
|
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 {
|
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 {
|
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.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
@@ -154,22 +197,53 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.hilt.android)
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
ksp(libs.hilt.compiler)
|
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.appwidget)
|
||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation("de.jeanlucmakiola.floret:core-time")
|
implementation(libs.floret.core.time)
|
||||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
implementation(libs.floret.core.reminders)
|
||||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
implementation(libs.floret.core.locale)
|
||||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
implementation(libs.floret.core.crash)
|
||||||
implementation("de.jeanlucmakiola.floret:identity")
|
implementation(libs.floret.identity)
|
||||||
implementation("de.jeanlucmakiola.floret:components")
|
implementation(libs.floret.components)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
@@ -185,6 +259,7 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(libs.androidx.test.rules)
|
androidTestImplementation(libs.androidx.test.rules)
|
||||||
androidTestImplementation(libs.truth)
|
androidTestImplementation(libs.truth)
|
||||||
|
androidTestImplementation(libs.androidx.room.testing)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-10
@@ -5,17 +5,18 @@
|
|||||||
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
|
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
|
||||||
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
|
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
|
||||||
# nothing calls it directly — Room then throws InstantiationException, reported
|
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||||
# as "Failed to create an instance of ...". We pull Room in transitively via
|
# as "Failed to create an instance of ...". This first bit us through a
|
||||||
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
|
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
||||||
# at startup, so the app died on launch in every minified build (issue #1).
|
# issue #1); Glance is gone and Room is now our own task store, so the rule
|
||||||
|
# matters more, not less — TasksDatabase is built on the first store read.
|
||||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
# WorkManager likewise looks its workers up by name and calls this constructor
|
|
||||||
# reflectively — same pruning, but it only bites once a worker actually runs
|
|
||||||
# (Glance's widget updates), so keep it explicitly rather than wait for it.
|
|
||||||
-keep class * extends androidx.work.ListenableWorker {
|
|
||||||
<init>(android.content.Context, androidx.work.WorkerParameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-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
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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<Preferences>
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+290
@@ -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<Preferences>
|
||||||
|
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<String, TaskEntity> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+542
@@ -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<Set<Long>>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -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<Long>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -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<String> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+324
@@ -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<Preferences>
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var source: FakeExternalStore
|
||||||
|
private lateinit var importer: ExternalImport
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||||
|
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
|
||||||
|
}
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.build()
|
||||||
|
source = FakeExternalStore()
|
||||||
|
importer = ExternalImport(
|
||||||
|
external = Provider { source },
|
||||||
|
resolver = ProviderResolver(NoProviderInstalled),
|
||||||
|
database = db,
|
||||||
|
dataStore = prefs,
|
||||||
|
io = Dispatchers.IO,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun copiesListsTasksAndAlarms() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||||
|
source.tasks = mapOf(
|
||||||
|
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||||
|
9L to listOf(task(200, "Invoice")),
|
||||||
|
)
|
||||||
|
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||||
|
|
||||||
|
val result = importer.run()
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(
|
||||||
|
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
|
||||||
|
)
|
||||||
|
assertThat(db.taskLists().lists().map { it.list.name })
|
||||||
|
.containsExactly("Errands", "Work")
|
||||||
|
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
|
||||||
|
.containsExactly("Milk", "Bread", "Invoice")
|
||||||
|
assertThat(importer.hasRun.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||||
|
@Test
|
||||||
|
fun importedListsAreDeviceOnly() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
|
||||||
|
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
|
||||||
|
|
||||||
|
importer.run()
|
||||||
|
|
||||||
|
assertThat(db.taskLists().lists().single().list.accountId).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider row ids are the source's; Room mints its own and the link follows. */
|
||||||
|
@Test
|
||||||
|
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"))
|
||||||
|
// Child before parent, so a naive single pass would not find the parent.
|
||||||
|
source.tasks = mapOf(
|
||||||
|
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
|
||||||
|
)
|
||||||
|
|
||||||
|
importer.run()
|
||||||
|
|
||||||
|
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
|
||||||
|
val parent = rows.single { it.title == "Parent" }
|
||||||
|
val child = rows.single { it.title == "Subtask" }
|
||||||
|
assertThat(child.parentId).isEqualTo(parent.id)
|
||||||
|
assertThat(child.parentId).isNotEqualTo(200L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
|
||||||
|
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
|
||||||
|
* would reject it and take the whole copy down, so it gets a fresh uid.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"))
|
||||||
|
source.tasks = mapOf(
|
||||||
|
7L to listOf(
|
||||||
|
task(100, "Weekly", uid = "shared-uid"),
|
||||||
|
task(101, "Weekly, that one week", uid = "shared-uid"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = importer.run()
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
|
||||||
|
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
|
||||||
|
assertThat(uids).hasSize(2)
|
||||||
|
assertThat(uids.toSet()).hasSize(2)
|
||||||
|
assertThat(uids).contains("shared-uid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A START-referenced reminder must not come across as a before-due one. */
|
||||||
|
@Test
|
||||||
|
fun preservesTheAlarmReference() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"))
|
||||||
|
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
|
||||||
|
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
|
||||||
|
|
||||||
|
importer.run()
|
||||||
|
|
||||||
|
val alarm = db.alarms().all().single()
|
||||||
|
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
|
||||||
|
assertThat(alarm.minutesBefore).isEqualTo(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
|
||||||
|
val result = importer.run()
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
|
||||||
|
assertThat(db.taskLists().lists()).isEmpty()
|
||||||
|
// Still on offer: there was nothing to copy, not a copy that happened.
|
||||||
|
assertThat(importer.hasRun.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read that blows up must leave Room exactly as it was. */
|
||||||
|
@Test
|
||||||
|
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"))
|
||||||
|
source.failOnExport = true
|
||||||
|
|
||||||
|
val result = importer.run()
|
||||||
|
|
||||||
|
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
|
||||||
|
assertThat(db.taskLists().lists()).isEmpty()
|
||||||
|
assertThat(importer.hasRun.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun previewCountsWhatARunWouldWrite() = runBlocking {
|
||||||
|
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||||
|
source.tasks = mapOf(
|
||||||
|
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||||
|
9L to listOf(task(200, "Invoice")),
|
||||||
|
)
|
||||||
|
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||||
|
// preview() resolves the provider itself, so it needs one to be installed.
|
||||||
|
val withProvider = ExternalImport(
|
||||||
|
external = Provider { source },
|
||||||
|
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
|
||||||
|
database = db,
|
||||||
|
dataStore = prefs,
|
||||||
|
io = Dispatchers.IO,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(withProvider.preview())
|
||||||
|
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun previewIsNullWithoutAReadableProvider() = runBlocking {
|
||||||
|
assertThat(importer.preview()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- fixtures --------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
color = 0xFF7E57C2.toInt(),
|
||||||
|
accountName = accountName,
|
||||||
|
accountType = "org.dmfs.account.LOCAL",
|
||||||
|
isSynced = true,
|
||||||
|
isVisible = true,
|
||||||
|
owner = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun task(
|
||||||
|
id: Long,
|
||||||
|
title: String,
|
||||||
|
uid: String? = "uid-$id",
|
||||||
|
parentId: Long? = null,
|
||||||
|
) = ExportTask(
|
||||||
|
taskId = id,
|
||||||
|
uid = uid,
|
||||||
|
title = title,
|
||||||
|
description = null,
|
||||||
|
location = null,
|
||||||
|
url = null,
|
||||||
|
priority = Priority.NONE,
|
||||||
|
status = TaskStatus.NEEDS_ACTION,
|
||||||
|
percentComplete = null,
|
||||||
|
start = null,
|
||||||
|
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
|
||||||
|
isAllDay = false,
|
||||||
|
completedAt = null,
|
||||||
|
created = null,
|
||||||
|
lastModified = null,
|
||||||
|
rrule = null,
|
||||||
|
rdate = null,
|
||||||
|
parentId = parentId,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
var counter = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the three reads the copy makes; everything else is out of scope. */
|
||||||
|
private class FakeExternalStore : TasksDataSource {
|
||||||
|
var lists: List<TaskList> = emptyList()
|
||||||
|
var tasks: Map<Long, List<ExportTask>> = emptyMap()
|
||||||
|
var alarms: Map<Long, TaskReminder> = emptyMap()
|
||||||
|
var failOnExport = false
|
||||||
|
|
||||||
|
override fun taskLists(): List<TaskList> = lists
|
||||||
|
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||||
|
if (failOnExport) error("provider went away mid-read")
|
||||||
|
return tasks[listId].orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, TaskReminder> = alarms
|
||||||
|
|
||||||
|
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||||
|
override fun task(taskId: Long): Task? = unused()
|
||||||
|
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||||
|
override fun insertTask(form: TaskForm): Long = unused()
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
||||||
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||||
|
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
||||||
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = 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"
|
||||||
|
}
|
||||||
@@ -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<DemoSeeder>): LaunchHook = LaunchHook { intent ->
|
||||||
|
if (intent.getBooleanExtra("agendula_seed", false)) seeder.get().seed()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -50,7 +50,7 @@ class DemoSeeder @Inject constructor(
|
|||||||
repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
|
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)))
|
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 {
|
private companion object {
|
||||||
@@ -2,20 +2,48 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<!-- Tasks provider access. Both permission sets are declared; the active one
|
<!-- External tasks-provider access, for StorageMode.EXTERNAL only. Both
|
||||||
|
permission sets are declared, since the manifest is static; the active one
|
||||||
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
(org.tasks.* for tasks.org, org.dmfs.* for OpenTasks) is requested at
|
||||||
runtime by the permission flow. Both are dangerous-level. -->
|
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. -->
|
||||||
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
<uses-permission android:name="org.dmfs.permission.READ_TASKS" />
|
||||||
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.dmfs.permission.WRITE_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
<uses-permission android:name="org.tasks.permission.READ_TASKS" />
|
||||||
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
<uses-permission android:name="org.tasks.permission.WRITE_TASKS" />
|
||||||
|
|
||||||
|
<!-- CalDAV sync. ACCESS_NETWORK_STATE is merged in by work-runtime anyway,
|
||||||
|
but it shows in F-Droid's permission diff, so declare it deliberately
|
||||||
|
rather than letting it appear from nowhere.
|
||||||
|
|
||||||
|
READ_SYNC_SETTINGS / WRITE_SYNC_SETTINGS are what the ContentResolver
|
||||||
|
sync APIs need. No FOREGROUND_SERVICE: sync is a plain worker, and the
|
||||||
|
dataSync FGS type would bring the Android 15 six-hours-per-24 budget
|
||||||
|
(whose failure mode is a fatal RemoteServiceException) and a Play
|
||||||
|
requirement for a video demo.
|
||||||
|
|
||||||
|
Two more permissions appear in the merged manifest without being
|
||||||
|
declared here, and both come from work-runtime: WAKE_LOCK, and
|
||||||
|
FOREGROUND_SERVICE. The latter is not us taking the FGS route — below
|
||||||
|
API 31 WorkManager implements expedited work with a foreground service,
|
||||||
|
and minSdk is 29, so it is load-bearing for the "Sync now" button.
|
||||||
|
Removing it with tools:node="remove" would break expedited work on
|
||||||
|
exactly the older devices that need it most. Noted because it shows in
|
||||||
|
F-Droid's permission diff and would otherwise look unexplained. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<!-- Exact due-time reminders: USE_EXACT_ALARM on 33+, SCHEDULE on 31-32. -->
|
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
alarm-clock and calendar apps on Play. Without it reminders go inexact. -->
|
||||||
android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
|
||||||
|
|
||||||
<!-- Package visibility (Android 11+): see the tasks providers so
|
<!-- Package visibility (Android 11+): see the tasks providers so
|
||||||
resolveContentProvider works, and launchable apps so we can open the
|
resolveContentProvider works, and launchable apps so we can open the
|
||||||
@@ -27,6 +55,12 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- Custom Tabs provider detection. Without this entry it silently finds
|
||||||
|
nothing on API 30+, and the Nextcloud login flow falls back to an
|
||||||
|
external browser for no visible reason. -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@@ -37,6 +71,7 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:localeConfig="@xml/locales_config"
|
android:localeConfig="@xml/locales_config"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Agendula"
|
android:theme="@style/Theme.Agendula"
|
||||||
@@ -50,8 +85,39 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Shared text becomes a new task's title. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- An .ics opened or shared from another app goes to the import screen. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="content" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
|
||||||
|
<service
|
||||||
|
android:name=".qs.NewTaskTileService"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@drawable/ic_qs_new_task"
|
||||||
|
android:label="@string/qs_tile_new_task"
|
||||||
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<!-- Standalone crash-report surface; MainActivity routes here on a
|
<!-- Standalone crash-report surface; MainActivity routes here on a
|
||||||
startup crash-loop. Not exported, kept out of recents. -->
|
startup crash-loop. Not exported, kept out of recents. -->
|
||||||
<activity
|
<activity
|
||||||
@@ -65,27 +131,148 @@
|
|||||||
android:name=".data.reminders.DueReminderReceiver"
|
android:name=".data.reminders.DueReminderReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Re-arm alarms after reboot. -->
|
<!-- Done / Snooze on a reminder, and the snooze re-show (internal). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".data.reminders.ReminderActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Re-arm alarms after reboot, an update, a clock/zone change, or an exact-alarm grant. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.BootReceiver"
|
android:name=".data.reminders.BootReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Re-sync reminders when the provider changes (external DAVx5 sync).
|
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
|
||||||
Targets both known authorities; the host must be static. -->
|
tasks while Agendula is backgrounded. External mode only: in OWN mode
|
||||||
|
nothing outside the app can change our data, and Room's
|
||||||
|
InvalidationTracker covers our own writes.
|
||||||
|
|
||||||
|
An intent-filter host must be a literal, so both external authorities
|
||||||
|
are listed — one filter each. Two <data> tags in a single filter would
|
||||||
|
mean the same thing (Android takes the cross product of every data
|
||||||
|
attribute in a filter), but reads as if it might not, which is what
|
||||||
|
lint's IntentFilterUniqueDataAttributes warns about. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.ProviderChangeReceiver"
|
android:name=".data.reminders.ProviderChangeReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||||
<data android:scheme="content" android:host="org.tasks.opentasks" />
|
|
||||||
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||||
|
<data android:scheme="content" android:host="org.tasks.opentasks" />
|
||||||
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Home-screen task widget (Glance). Exported: the launcher binds it. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.TaskWidgetReceiver"
|
||||||
|
android:label="@string/widget_tasks_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/appwidget_info_tasks" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Per-widget list picker, launched by the host on placement / reconfigure. -->
|
||||||
|
<activity
|
||||||
|
android:name=".widget.TaskWidgetConfigActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Redraws the widget on the day boundary (the app's own ROLLOVER alarm,
|
||||||
|
sent by explicit PendingIntent) and on a language change; the rest
|
||||||
|
re-arm that alarm. Task changes are handled in-process instead. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.LOCALE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||||
|
adapter an authority to register against: Agendula publishes no real
|
||||||
|
ContentProvider since :provider was deleted, and without an authority
|
||||||
|
ContentService.hasAuthorityAccess() makes every ContentResolver sync
|
||||||
|
call a silent no-op at targetSdk >= 34. -->
|
||||||
|
<provider
|
||||||
|
android:name=".data.sync.SyncStubProvider"
|
||||||
|
android:authorities="${applicationId}.sync"
|
||||||
|
android:exported="false"
|
||||||
|
android:syncable="true" />
|
||||||
|
|
||||||
|
<!-- Exported and guarded by ACCOUNT_MANAGER. Note that
|
||||||
|
android.permission.ACCOUNT_AUTHENTICATOR does not exist. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.AuthenticatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.ACCOUNT_MANAGER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accounts.AccountAuthenticator"
|
||||||
|
android:resource="@xml/authenticator" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.SyncAdapterService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_SYNC_ADAPTER">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.content.SyncAdapter"
|
||||||
|
android:resource="@xml/sync_adapter" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- UnifiedPush: the connector binds this to deliver endpoints and
|
||||||
|
WebDAV-Push messages. Not exported; the connector's own receiver is
|
||||||
|
what distributors talk to. -->
|
||||||
|
<service
|
||||||
|
android:name=".data.sync.push.AgendulaPushService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||||
|
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||||
|
@HiltWorker workers can take injected dependencies. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup"
|
||||||
|
tools:node="remove" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Persists the per-app language on API < 33, where the platform
|
<!-- Persists the per-app language on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
package de.jeanlucmakiola.agendula
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.hilt.work.HiltWorkerFactory
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.work.Configuration
|
||||||
import dagger.hilt.EntryPoint
|
import dagger.hilt.EntryPoint
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||||
|
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
* Application entry point. Registered as android:name=".AgendulaApp". Besides
|
||||||
@@ -20,7 +31,20 @@ import kotlinx.coroutines.launch
|
|||||||
* so alarms reflect tasks synced while the app was closed.
|
* so alarms reflect tasks synced while the app was closed.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class AgendulaApp : Application() {
|
class AgendulaApp : Application(), Configuration.Provider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets `@HiltWorker` workers take injected dependencies. The manifest removes
|
||||||
|
* WorkManager's default initializer so this one is used instead; without both
|
||||||
|
* halves, a worker with a constructor argument fails to instantiate at
|
||||||
|
* runtime rather than at build time.
|
||||||
|
*/
|
||||||
|
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() = Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -32,20 +56,71 @@ class AgendulaApp : Application() {
|
|||||||
appLabel = getString(R.string.app_name),
|
appLabel = getString(R.string.app_name),
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
chooseIssueUrl = getString(R.string.report_issue_url),
|
chooseIssueUrl = getString(R.string.report_issue_url),
|
||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val scheduler = EntryPointAccessors
|
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
|
||||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
val scheduler = entryPoint.reminderScheduler()
|
||||||
.reminderScheduler()
|
// Mirror the stored storage mode into ProviderResolver and import a
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
// v0.3.x install's tasks, both before anything reads a store.
|
||||||
runCatching { scheduler.sync() }
|
val startupGate = entryPoint.startupGate()
|
||||||
|
val scope = entryPoint.applicationScope()
|
||||||
|
// An alarm is armed off whichever store was active when it was scheduled,
|
||||||
|
// so a switch has to rebuild the set. Armed only once startup's own
|
||||||
|
// null -> 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)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface ReminderEntryPoint {
|
interface AppEntryPoint {
|
||||||
fun reminderScheduler(): ReminderScheduler
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,40 @@ package de.jeanlucmakiola.agendula
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
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.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
@@ -30,19 +47,30 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||||
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
* (replaceable) functional scaffold over the real data layer. Notification taps
|
||||||
* routing for reminder taps lands with the full UI.
|
* arrive as a [NavRequest] (see [navRequestOf]).
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
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
|
// 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
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
private var pendingCrashReport by mutableStateOf<String?>(null)
|
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
// A notification tap's destination, handed to the nav host and cleared once taken.
|
||||||
|
private var navRequest by mutableStateOf<NavRequest?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -56,14 +84,44 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
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.
|
// Surface a single captured crash as a dialog on the next launch.
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
|
|
||||||
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
// Sync hard on app open: the periodic worker's interval is a floor, and
|
||||||
// local (non-syncing) demo list once; no-op without the extra.
|
// in the `rare` and `restricted` App Standby buckets it may not have run
|
||||||
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
// at all. `KEEP` makes rescheduling idempotent, so this also repairs a
|
||||||
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
// 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 {
|
setContent {
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
@@ -73,26 +131,44 @@ class MainActivity : ComponentActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
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) {
|
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
CompositionLocalProvider(
|
||||||
pendingCrashReport?.let { report ->
|
LocalUse24HourFormat provides use24Hour,
|
||||||
CrashReportDialog(
|
LocalFirstDayOfWeek provides firstDayOfWeek,
|
||||||
report = report,
|
) {
|
||||||
onSend = {
|
RootScreen(
|
||||||
submitCrashReport(this@MainActivity, report)
|
modifier = Modifier.fillMaxSize(),
|
||||||
CrashReporter.clearReport(this@MainActivity)
|
navRequest = navRequest,
|
||||||
pendingCrashReport = null
|
onNavRequestConsumed = { navRequest = null },
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
CrashReporter.dismissPrompt(this@MainActivity)
|
|
||||||
pendingCrashReport = 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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// A successful start breaks any loop; reset the timing trail so a later
|
// A successful start breaks any loop; reset the timing trail so a later
|
||||||
@@ -102,13 +178,79 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
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 {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
putExtra(EXTRA_TASK_ID, taskId)
|
putExtra(EXTRA_TASK_ID, taskId)
|
||||||
|
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
@@ -10,29 +12,111 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
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.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.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
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.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import javax.inject.Provider
|
||||||
import javax.inject.Singleton
|
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<Preferences> by preferencesDataStore(
|
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
name = "agendula_prefs",
|
name = "agendula_prefs",
|
||||||
|
corruptionHandler = replaceCorrupted(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** See [CredentialsDataStore] for why this is a separate file. */
|
||||||
|
private val Context.credentialsDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||||
|
name = CREDENTIALS_DATASTORE,
|
||||||
|
corruptionHandler = replaceCorrupted(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** See [SyncStateDataStore] for why this is a separate file. */
|
||||||
|
private val Context.syncStateDataStore: DataStore<Preferences> 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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class DataBindModule {
|
abstract class DataBindModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@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
|
@Module
|
||||||
@@ -44,7 +128,53 @@ object DataProvideModule {
|
|||||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
context.agendulaDataStore
|
context.agendulaDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@CredentialsDataStore
|
||||||
|
fun provideCredentialsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
context.credentialsDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@SyncStateDataStore
|
||||||
|
fun provideSyncStateDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||||
|
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<RoomTasksDataSource>,
|
||||||
|
external: Provider<AndroidTasksDataSource>,
|
||||||
|
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@IoDispatcher
|
@IoDispatcher
|
||||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LaunchHook>
|
||||||
|
}
|
||||||
@@ -6,3 +6,58 @@ import javax.inject.Qualifier
|
|||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoDispatcher
|
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
|
||||||
|
|||||||
@@ -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<String>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ExportDocument>): 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<ExportDocument>): 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long>? = null): List<ExportDocument> = 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,25 +8,59 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
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.TaskFormField
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
import de.jeanlucmakiola.floret.reminders.reminderLeadsFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.time.DayOfWeek
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
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(
|
data class Settings(
|
||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
/** The list a new task defaults to; `null` = first available. */
|
/** The list a new task defaults to; `null` = first available. */
|
||||||
val defaultListId: Long? = null,
|
val defaultListId: Long? = null,
|
||||||
/** Default minutes before due to remind; 0 = at due time. */
|
/** Default reminders, as minutes before due (0 = at due time); empty = none. */
|
||||||
val reminderLeadMinutes: Int = 0,
|
val defaultReminderMinutes: List<Int> = listOf(0),
|
||||||
|
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
|
||||||
|
val defaultAllDayReminderMinutes: List<Int> = 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. */
|
/** Master switch for due reminders; off clears every scheduled alarm. */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
|
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
|
||||||
@@ -38,24 +72,52 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val bottomAddBar: Boolean = false,
|
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 =
|
* overrides the global default (an empty list = no reminder); absent =
|
||||||
* inherit. Agendula offers a single reminder, so each override is a
|
* inherit.
|
||||||
* one-element (or empty) list.
|
|
||||||
*/
|
*/
|
||||||
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
|
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
|
||||||
|
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = 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<Long> = emptySet(),
|
||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||||
perListReminderOverride.reminderLeadsFor(listId, listOf(reminderLeadMinutes)).firstOrNull()
|
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<Int> =
|
||||||
|
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
|
||||||
|
|
||||||
|
/** The lead times for an all-day task in [listId]. */
|
||||||
|
fun allDayReminderLeadsFor(listId: Long): List<Int> =
|
||||||
|
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@Singleton
|
@Singleton
|
||||||
class SettingsPrefs @Inject constructor(
|
class SettingsPrefs @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
) {
|
) {
|
||||||
val settings: Flow<Settings> = dataStore.data.map { p ->
|
val settings: Flow<Settings> = dataStore.data.map { p ->
|
||||||
Settings(
|
Settings(
|
||||||
@@ -63,29 +125,108 @@ class SettingsPrefs @Inject constructor(
|
|||||||
?: ThemeMode.SYSTEM,
|
?: ThemeMode.SYSTEM,
|
||||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
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,
|
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||||
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||||
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||||
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
||||||
|
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
|
||||||
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||||
.toSet(),
|
.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 setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
|
||||||
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
|
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
|
||||||
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
|
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
|
||||||
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
|
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<Int>) = dataStore.edit {
|
||||||
|
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
/** One-time reminder onboarding gate; false until the step has been shown. */
|
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
||||||
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
|
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<StorageMode?> = 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<Boolean> = 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 }
|
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)
|
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<TaskFormField>) = dataStore.edit {
|
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||||
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
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 DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||||
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
||||||
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
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 REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
|
||||||
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||||
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
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 LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
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.
|
* data migration.
|
||||||
*/
|
*/
|
||||||
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
|
|
||||||
|
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
|
||||||
|
private fun parseMinutes(stored: String): List<Int> =
|
||||||
|
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.reminders
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -10,20 +11,46 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
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
|
@AndroidEntryPoint
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@Inject lateinit var scheduler: ReminderScheduler
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var snoozeScheduler: ReminderSnoozeScheduler
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
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()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
scheduler.sync()
|
runCatching { scheduler.sync(afterReboot = afterReboot) }
|
||||||
|
if (afterReboot) runCatching { snoozeScheduler.rearm() }
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
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.data.tasks.TasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -24,6 +27,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
@Inject lateinit var dataSource: TasksDataSource
|
@Inject lateinit var dataSource: TasksDataSource
|
||||||
@Inject lateinit var notifier: TaskNotifier
|
@Inject lateinit var notifier: TaskNotifier
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -33,9 +37,15 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
|
val triggerAt = intent.getLongExtra(EXTRA_TRIGGER_AT, -1L)
|
||||||
val task = runCatching { dataSource.task(taskId) }.getOrNull()
|
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE, ScheduledReminder.NO_OCCURRENCE)
|
||||||
if (task != null && !task.isClosed) notifier.postDue(task)
|
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 {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
@@ -44,8 +54,37 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -20,10 +22,25 @@ import javax.inject.Inject
|
|||||||
class ProviderChangeReceiver : BroadcastReceiver() {
|
class ProviderChangeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@Inject lateinit var scheduler: ReminderScheduler
|
@Inject lateinit var scheduler: ReminderScheduler
|
||||||
|
@Inject lateinit var providerResolver: ProviderResolver
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
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()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ScheduledReminder>,
|
||||||
|
val arm: Set<ScheduledReminder>,
|
||||||
|
val keep: Set<ScheduledReminder>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun of(
|
||||||
|
previous: Set<ScheduledReminder>,
|
||||||
|
desired: Set<ScheduledReminder>,
|
||||||
|
fired: Set<ScheduledReminder> = 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -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<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Task>,
|
||||||
|
perTask: Map<Long, List<TaskReminder>>,
|
||||||
|
settings: Settings,
|
||||||
|
now: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): Set<ScheduledReminder> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,15 +12,19 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The self-scheduled due-reminder engine. Tasks providers don't deliver
|
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||||
* reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
|
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks
|
||||||
* alarm each, within a rolling window. Re-run on app start, boot and provider
|
* and arms one exact [AlarmManager] alarm per reminder, within a rolling window. Re-run
|
||||||
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
|
* 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
|
@Singleton
|
||||||
class ReminderScheduler @Inject constructor(
|
class ReminderScheduler @Inject constructor(
|
||||||
@@ -29,71 +33,127 @@ class ReminderScheduler @Inject constructor(
|
|||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val store: ScheduledReminderStore,
|
private val store: ScheduledReminderStore,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
|
private val snoozeScheduler: ReminderSnoozeScheduler,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() = withContext(io) {
|
private val syncLock = Mutex()
|
||||||
val provider = providerResolver.resolve()
|
|
||||||
|
/**
|
||||||
|
* 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()
|
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()
|
clearAll()
|
||||||
return@withContext
|
return
|
||||||
}
|
}
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val horizon = now + WINDOW_MS
|
|
||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
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
|
// ⚠️ Revoking exact alarms cancels them without telling us, and a grant
|
||||||
.filter { !it.isClosed && it.due != null }
|
// leaves the old ones inexact — either way the armed set can't be trusted.
|
||||||
.mapNotNull { task ->
|
val canExact = canScheduleExact()
|
||||||
// The task's list may override the global lead, or opt out entirely
|
val rearm = rearmAll || store.armedExact() != canExact
|
||||||
// (override = null), in which case it gets no reminder at all.
|
if (rearm) {
|
||||||
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
|
store.all().forEach { cancel(it) }
|
||||||
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
|
runCatching { snoozeScheduler.rearm() }
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
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 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
|
val flags = (if (create) PendingIntent.FLAG_UPDATE_CURRENT else PendingIntent.FLAG_NO_CREATE) or
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
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) {
|
/** @return whether the alarm is now armed, and so worth recording. */
|
||||||
val pi = pendingIntent(taskId, create = true) ?: return
|
private fun schedule(reminder: ScheduledReminder): Boolean {
|
||||||
|
val triggerAt = reminder.triggerAt
|
||||||
|
val pi = pendingIntent(reminder, create = true) ?: return false
|
||||||
val am = alarmManager()
|
val am = alarmManager()
|
||||||
val canExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
val canExact = canScheduleExact()
|
||||||
if (canExact) {
|
return try {
|
||||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
if (canExact) {
|
||||||
} else {
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
||||||
am.set(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) {
|
private fun cancel(reminder: ScheduledReminder) {
|
||||||
pendingIntent(taskId, create = false)?.let {
|
pendingIntent(reminder, create = false)?.let {
|
||||||
alarmManager().cancel(it)
|
alarmManager().cancel(it)
|
||||||
it.cancel()
|
it.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun clearAll() {
|
private suspend fun clearAll() {
|
||||||
store.all().keys.forEach { cancel(it) }
|
store.all().forEach { cancel(it) }
|
||||||
store.replace(emptyMap())
|
store.replace(emptySet(), fired = emptySet())
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val WINDOW_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-14
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -9,29 +10,80 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remembers which task reminders are currently scheduled (taskId → trigger time),
|
* One armed alarm. A recurring task has many occurrences sharing a [taskId], so
|
||||||
* so [ReminderScheduler] can diff against a fresh computation and cancel only the
|
* the trigger time is part of the identity — keying by task alone would collapse
|
||||||
* alarms that changed. Persisted in DataStore as a set of `taskId|trigger` strings.
|
* 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
|
@Singleton
|
||||||
class ScheduledReminderStore @Inject constructor(
|
class ScheduledReminderStore @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) {
|
) {
|
||||||
suspend fun all(): Map<Long, Long> =
|
suspend fun all(): Set<ScheduledReminder> = read(KEY)
|
||||||
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 replace(scheduled: Map<Long, Long>) {
|
/** Reminders that went off, and so must not be armed again while still desired. */
|
||||||
|
suspend fun fired(): Set<ScheduledReminder> = 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<ScheduledReminder>,
|
||||||
|
fired: Set<ScheduledReminder>? = null,
|
||||||
|
exact: Boolean? = null,
|
||||||
|
) {
|
||||||
dataStore.edit { prefs ->
|
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 {
|
suspend fun markFired(reminder: ScheduledReminder) {
|
||||||
val KEY = stringSetPreferencesKey("scheduled_reminders")
|
dataStore.edit { prefs ->
|
||||||
|
prefs[FIRED_KEY] = prefs[FIRED_KEY].orEmpty() + encode(setOf(reminder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun read(key: Preferences.Key<Set<String>>): Set<ScheduledReminder> =
|
||||||
|
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<ScheduledReminder>): Set<String> =
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,59 @@ package de.jeanlucmakiola.agendula.data.reminders
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationChannelGroup
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.text.format.DateFormat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.edit
|
||||||
import de.jeanlucmakiola.agendula.MainActivity
|
import de.jeanlucmakiola.agendula.MainActivity
|
||||||
import de.jeanlucmakiola.agendula.R
|
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.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.time.Instant as JInstant
|
import java.time.Instant as JInstant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per due task on a dedicated channel. The tag is the
|
* Posts one notification per due task on its list's channel, so each list can
|
||||||
* task id, so a re-fired alarm replaces rather than duplicates. Tapping opens
|
* have its own sound and importance. The tag is the task id, so a re-fired alarm
|
||||||
* the app (later: the task's detail screen — see [MainActivity]).
|
* replaces rather than duplicates. Tapping opens the task's detail screen (see
|
||||||
|
* [MainActivity.taskIntent]).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class TaskNotifier @Inject constructor(
|
class TaskNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@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 {
|
fun canPost(): Boolean {
|
||||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
@@ -42,20 +65,32 @@ class TaskNotifier @Inject constructor(
|
|||||||
|
|
||||||
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
|
// canPost() checks POST_NOTIFICATIONS before we ever call notify().
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun postDue(task: Task) {
|
fun postDue(task: Task, settings: Settings) {
|
||||||
if (!canPost()) return
|
if (!canPost()) return
|
||||||
ensureChannel()
|
val channelId = ensureListChannel(task.listId, task.listName)
|
||||||
val title = task.title.ifBlank { context.getString(R.string.task_untitled) }
|
val title = task.title.ifBlank { context.getString(R.string.task_untitled) }
|
||||||
val text = task.due?.let { due ->
|
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(
|
val tapIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
task.taskId.toInt(),
|
task.taskId.toInt(),
|
||||||
MainActivity.taskIntent(context, task.taskId),
|
MainActivity.taskIntent(context, task.taskId, task.occurrenceStart?.toEpochMilliseconds()),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
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)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.apply { if (text != null) setContentText(text) }
|
.apply { if (text != null) setContentText(text) }
|
||||||
@@ -63,36 +98,145 @@ class TaskNotifier @Inject constructor(
|
|||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(tapIntent)
|
.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()
|
.build()
|
||||||
NotificationManagerCompat.from(context).notify(task.taskId.toString(), NOTIFICATION_ID, notification)
|
NotificationManagerCompat.from(context).notify(task.taskId.toString(), NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDue(millis: Long, allDay: Boolean): String {
|
fun cancel(taskId: Long) {
|
||||||
val zone = ZoneId.systemDefault()
|
NotificationManagerCompat.from(context).cancel(taskId.toString(), NOTIFICATION_ID)
|
||||||
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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureChannel() {
|
private fun actionIntent(task: Task, action: String): PendingIntent = PendingIntent.getBroadcast(
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
context,
|
||||||
val manager = context.getSystemService(NotificationManager::class.java)
|
task.taskId.toInt(),
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
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(
|
manager.createNotificationChannel(
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
id,
|
||||||
context.getString(R.string.reminder_channel_name),
|
listName?.takeIf { it.isNotBlank() } ?: context.getString(R.string.reminder_channel_name),
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
importance,
|
||||||
).apply { description = context.getString(R.string.reminder_channel_desc) },
|
).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"
|
* Brings the channels in line with the current store's lists: one per list,
|
||||||
const val NOTIFICATION_ID = 1
|
* 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TaskCollection>,
|
||||||
|
/** 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<AccountEntity> = withContext(io) { database.accounts().all() }
|
||||||
|
|
||||||
|
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
||||||
|
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
|
||||||
|
|
||||||
|
/** Every account's synced lists. */
|
||||||
|
fun observeSyncedLists(): Flow<List<TaskListEntity>> = 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<TaskCollection>,
|
||||||
|
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<Long>()
|
||||||
|
val accountId = database.runInTransaction<Long> {
|
||||||
|
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<TaskCollection>): List<Long> {
|
||||||
|
val known = database.taskLists().attachable(accountId).associateBy { it.href }
|
||||||
|
val inserted = mutableListOf<Long>()
|
||||||
|
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<TaskCollection>,
|
||||||
|
/** Hrefs of the collections this account already syncs. */
|
||||||
|
val attached: Set<String>,
|
||||||
|
) : 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<TaskCollection>,
|
||||||
|
selected: Set<HttpUrl>,
|
||||||
|
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<TaskCollection>,
|
||||||
|
): 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<Long>) = 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<AccountEntity> = 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<TaskCollection>,
|
||||||
|
val drop: List<TaskListEntity>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<TaskCollection>,
|
||||||
|
selected: Set<HttpUrl>,
|
||||||
|
synced: List<TaskListEntity>,
|
||||||
|
): 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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
|
||||||
|
|
||||||
|
/** Observed, so a 401 during a background sync reaches an open screen. */
|
||||||
|
fun observeNeedingSignIn(): Flow<Set<Long>> = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Account> =
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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<String>): Map<Long, CollectionSupport> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Current failure counts, keyed by [key]. */
|
||||||
|
suspend fun counts(): Map<String, Int> = decode(dataStore.data.first()[KEY].orEmpty())
|
||||||
|
|
||||||
|
private fun decode(entries: Set<String>): Map<String, Int> = 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<String, Int>, cleared: Set<String>) {
|
||||||
|
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<Long>) {
|
||||||
|
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<Long>, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AccountEntity> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<out String>?,
|
||||||
|
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<out String>?,
|
||||||
|
): 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
|
||||||
|
}
|
||||||
@@ -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<Boolean> = prefs.storageMode.map { stored ->
|
||||||
|
(stored ?: resolver.autoMode()) == StorageMode.OWN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Long, Instant> =
|
||||||
|
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<Long, Instant>) {
|
||||||
|
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<Long>) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncReport>,
|
||||||
|
/**
|
||||||
|
* 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<SyncNotice> = 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<SyncReport>): Map<String, String> =
|
||||||
|
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<SyncReport> {
|
||||||
|
// 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncNotice>) {
|
||||||
|
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 <account> 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<SyncNotice>): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>`, 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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Observed, so a background sync's news reaches a screen that is already open. */
|
||||||
|
fun observeAll(): Flow<List<SyncNotice>> = 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<SyncReport>,
|
||||||
|
titles: Map<String, String> = emptyMap(),
|
||||||
|
): List<SyncNotice> {
|
||||||
|
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<SyncNotice>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long>) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DiscardedEdit> = emptyList(),
|
||||||
|
/** Resources the collection gave up on, so the rest of it could finish. */
|
||||||
|
val quarantined: List<QuarantinedResource> = 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,
|
||||||
|
)
|
||||||
@@ -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<TaskEntity>
|
||||||
|
|
||||||
|
fun insert(row: TaskEntity): Long
|
||||||
|
|
||||||
|
fun update(row: TaskEntity)
|
||||||
|
|
||||||
|
fun deleteAll(taskIds: List<Long>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Long>, 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<Long>) {
|
||||||
|
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun markSynced(taskIds: List<Long>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<sync-adapter android:contentAuthority>` 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<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
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<out String>?) = 0
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
uri: Uri,
|
||||||
|
values: ContentValues?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
) = 0
|
||||||
|
}
|
||||||
@@ -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<SyncWorker>()
|
||||||
|
.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<SyncPushWorker>()
|
||||||
|
.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<SyncWorker>()
|
||||||
|
.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<SyncWorker>(
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Distributor> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncReport>) {
|
||||||
|
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<String, HttpUrl> = 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<Long>) = 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<Long>, pushes: Map<Long, PushStore.ListPush>) {
|
||||||
|
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<PushRenewalWorker>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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<Long, ListPush> = decode(dataStore.data.first()[KEY].orEmpty())
|
||||||
|
|
||||||
|
fun observe(): Flow<Map<Long, ListPush>> = 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<Long, PushSupport?>): 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<Long>) {
|
||||||
|
if (listIds.isEmpty()) return
|
||||||
|
update { current -> listIds.forEach(current::remove) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun update(change: (MutableMap<Long, ListPush>) -> 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<String>): Map<Long, ListPush> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,21 @@ import android.os.Looper
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
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.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.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
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 java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only class that knows about the ContentResolver, [TasksContract] and the
|
* 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()
|
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<Task> =
|
override fun subtasks(parentTaskId: Long): List<Task> =
|
||||||
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
queryInstances("${Tasks.PARENT_ID} = ?", arrayOf(parentTaskId.toString()))
|
||||||
|
|
||||||
@@ -83,27 +97,296 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||||
|
// 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 ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
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())
|
val uri = resolver.insert(TasksContract.tasksUri(authority()), values.toContentValues())
|
||||||
?: throw TaskWriteFailedException("insert task")
|
?: throw TaskWriteFailedException("insert task")
|
||||||
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
return uri.lastPathSegment?.toLongOrNull() ?: throw TaskWriteFailedException("insert task: no id")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
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)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update task $taskId")
|
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<String, Any?>(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<Long> = 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<TaskReminder>) {
|
||||||
|
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<Long, List<TaskReminder>> =
|
||||||
|
readAlarms(null).groupBy({ it.first }) { it.third }
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, TaskReminder> =
|
||||||
|
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<Pair<Long, TaskReminder>> =
|
||||||
|
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<Triple<Long, Long, TaskReminder>> {
|
||||||
|
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) {
|
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||||
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
val values = TaskWriteMapper.completionValues(completed, System.currentTimeMillis())
|
||||||
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), taskId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("complete task $taskId")
|
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) {
|
override fun deleteTask(taskId: Long) {
|
||||||
resolver.delete(taskUri(authority(), taskId), null, null)
|
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")
|
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 ----------------------------------------------------------
|
// --- observation ----------------------------------------------------------
|
||||||
|
|
||||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||||
@@ -127,9 +435,16 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
|
||||||
override fun onChange(selfChange: Boolean) = onChange()
|
override fun onChange(selfChange: Boolean) = onChange()
|
||||||
}
|
}
|
||||||
resolver.registerContentObserver(TasksContract.instancesUri(provider.authority), true, observer)
|
// Register both or neither: if the second call throws, the first
|
||||||
resolver.registerContentObserver(TasksContract.listsUri(provider.authority), true, observer)
|
// registration would otherwise leak (no AutoCloseable was handed back yet).
|
||||||
return AutoCloseable { resolver.unregisterContentObserver(observer) }
|
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<String, Any?>.toContentValues(): ContentValues {
|
private fun Map<String, Any?>.toContentValues(): ContentValues {
|
||||||
|
|||||||
+105
@@ -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<RoomTasksDataSource>,
|
||||||
|
private val external: Provider<AndroidTasksDataSource>,
|
||||||
|
) : TasksDataSource {
|
||||||
|
|
||||||
|
private fun active(): TasksDataSource =
|
||||||
|
when (resolver.mode()) {
|
||||||
|
StorageMode.OWN -> room.get()
|
||||||
|
StorageMode.EXTERNAL -> external.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun taskLists(): List<TaskList> = active().taskLists()
|
||||||
|
override fun tasks(query: TaskQuery): List<Task> = 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<Task> = 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<Long, TaskReminder> = active().alarms()
|
||||||
|
override fun reminders(): Map<Long, List<TaskReminder>> = active().reminders()
|
||||||
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
||||||
|
active().setReminders(taskId, reminders)
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> = 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<Long>) = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 <T> Flow<T>.recoveringFromProviderFailure(fallback: () -> T): Flow<T> =
|
||||||
|
retryWhen { _, attempt ->
|
||||||
|
emit(fallback())
|
||||||
|
delay(retryDelayMs(attempt))
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.content.Context
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
|
* An external tasks provider Agendula can talk to. Every candidate runs the same
|
||||||
* candidate, so the [TasksContract] columns apply regardless of which is present.
|
* dmfs `TaskProvider`, so the [TasksContract] columns apply to either.
|
||||||
*/
|
*/
|
||||||
data class TaskProvider(
|
data class TaskProvider(
|
||||||
val authority: String,
|
val authority: String,
|
||||||
@@ -19,41 +16,122 @@ data class TaskProvider(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The A/B seam. Detects which tasks provider is installed at runtime and which
|
* Discovers the *external* tasks providers (OpenTasks, tasks.org) that
|
||||||
* permission set it needs, so nothing above the data layer hardcodes an
|
* [StorageMode.EXTERNAL] can be pointed at.
|
||||||
* authority. Under Posture B (bundled provider) this simply finds our own
|
*
|
||||||
* `org.dmfs.tasks` first. See docs/PLAN.md.
|
* 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
|
@Singleton
|
||||||
class ProviderResolver @Inject constructor(
|
class ProviderResolver @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
private val environment: ProviderEnvironment,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** The active provider, or `null` when no tasks provider is installed. */
|
private val modeListeners = CopyOnWriteArrayList<() -> Unit>()
|
||||||
fun resolve(): TaskProvider? {
|
|
||||||
for (candidate in CANDIDATES) {
|
/**
|
||||||
val info = context.packageManager.resolveContentProvider(candidate.authority, 0)
|
* The user's explicit choice, or `null` while they have not made one (which is
|
||||||
?: continue
|
* the normal state — most people never open Settings). Kept as a plain field
|
||||||
return candidate.copy(packageName = info.packageName)
|
* 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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun hasPermission(provider: TaskProvider): Boolean =
|
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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
* Verified on-device: tasks.org exposes `org.tasks.opentasks` backed by
|
||||||
* `org.dmfs.provider.tasks.TaskProvider`, guarded by
|
* `org.dmfs.provider.tasks.TaskProvider`, guarded by `org.tasks.permission.*`
|
||||||
* `org.tasks.permission.*` (dangerous). OpenTasks uses `org.dmfs.tasks`
|
* (dangerous). OpenTasks uses `org.dmfs.tasks` + `org.dmfs.permission.*`.
|
||||||
* + `org.dmfs.permission.*`. OpenTasks is listed first as the canonical
|
* OpenTasks is listed first as the canonical authority; on a device with
|
||||||
* authority; on a device with only one installed, order is moot.
|
* only one installed, order is moot.
|
||||||
*/
|
*/
|
||||||
val CANDIDATES: List<TaskProvider> = listOf(
|
val EXTERNAL_CANDIDATES: List<TaskProvider> = listOf(
|
||||||
TaskProvider(
|
TaskProvider(
|
||||||
authority = "org.dmfs.tasks",
|
authority = "org.dmfs.tasks",
|
||||||
readPermission = "org.dmfs.permission.READ_TASKS",
|
readPermission = "org.dmfs.permission.READ_TASKS",
|
||||||
|
|||||||
@@ -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<Unit>()
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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<Unit>()
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||||
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -16,10 +17,17 @@ object TaskMapper {
|
|||||||
fun instant(name: String): Instant? =
|
fun instant(name: String): Instant? =
|
||||||
r.getLong(name)?.let { Instant.fromEpochMilliseconds(it) }
|
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(
|
return Task(
|
||||||
id = instanceId,
|
taskId = r.getLong(Instances.TASK_ID) ?: rowId,
|
||||||
taskId = r.getLong(Instances.TASK_ID) ?: instanceId,
|
|
||||||
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
listId = r.getLong(Tasks.LIST_ID) ?: 0L,
|
||||||
title = r.getString(Tasks.TITLE).orEmpty(),
|
title = r.getString(Tasks.TITLE).orEmpty(),
|
||||||
description = r.getString(Tasks.DESCRIPTION),
|
description = r.getString(Tasks.DESCRIPTION),
|
||||||
@@ -38,10 +46,65 @@ object TaskMapper {
|
|||||||
listName = r.getString(Tasks.LIST_NAME),
|
listName = r.getString(Tasks.LIST_NAME),
|
||||||
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
accountName = r.getString(Tasks.ACCOUNT_NAME),
|
||||||
parentId = r.getLong(Tasks.PARENT_ID),
|
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),
|
distanceFromCurrent = r.getInt(Instances.DISTANCE_FROM_CURRENT),
|
||||||
created = instant(Tasks.CREATED),
|
created = instant(Tasks.CREATED),
|
||||||
lastModified = instant(Tasks.LAST_MODIFIED),
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
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.Lists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
|
||||||
|
|
||||||
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
|
||||||
object TaskProjections {
|
object TaskProjections {
|
||||||
@@ -18,31 +16,9 @@ object TaskProjections {
|
|||||||
Lists.ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Read from the `instances` view (inherits all task columns). */
|
// No `instances` projection on purpose: that read passes `projection = null`
|
||||||
val INSTANCES: Array<String> = arrayOf(
|
// (all columns), because the view's shape differs across provider versions —
|
||||||
Tasks.ID,
|
// tasks.org's bundled OpenTasks has no `is_recurring`, for one. A fixed list
|
||||||
Instances.TASK_ID,
|
// here would drift out of sync with the by-name mapper and quietly drop
|
||||||
Tasks.LIST_ID,
|
// columns it depends on. See AndroidTasksDataSource.queryInstances.
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
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.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.toICal
|
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),
|
* 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.TITLE, form.title.trim())
|
||||||
put(Tasks.LIST_ID, form.listId)
|
put(Tasks.LIST_ID, form.listId)
|
||||||
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
|
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())
|
put(Tasks.PRIORITY, form.priority.toICal())
|
||||||
// Keep completion in sync with progress whenever the form carries a
|
// Keep completion in sync with progress whenever the form carries a
|
||||||
// percent (the Progress field was used). The provider already auto-
|
// 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.IS_ALLDAY, if (form.isAllDay) 1 else 0)
|
||||||
put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
|
// All-day tasks are date-only in iCalendar. The provider reads them back
|
||||||
put(Tasks.DUE, form.due?.toEpochMilliseconds())
|
// 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)
|
put(Tasks.PARENT_ID, form.parentId)
|
||||||
// The provider treats a null tz as local time; set it explicitly for
|
// The provider treats a null tz as local time; set it explicitly for
|
||||||
// timed tasks so the stored instant is unambiguous across zones.
|
// timed tasks so the stored instant is unambiguous across zones.
|
||||||
@@ -48,6 +71,20 @@ object TaskWriteMapper {
|
|||||||
put(Tasks.TZ, if (timed) tzId else null)
|
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<String, Any?> =
|
||||||
|
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<String, Any?> =
|
||||||
|
taskValues(form, tzId) - Tasks.LIST_ID - Tasks.PARENT_ID
|
||||||
|
|
||||||
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
fun completionValues(completed: Boolean, nowMillis: Long): Map<String, Any?> =
|
||||||
if (completed) {
|
if (completed) {
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -63,9 +100,26 @@ object TaskWriteMapper {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun localListValues(name: String, color: Int): Map<String, Any?> = 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<String, Any?> = 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<String, Any?> = mapOf(
|
||||||
Lists.NAME to name.trim(),
|
Lists.NAME to name.trim(),
|
||||||
Lists.COLOR to color,
|
Lists.COLOR to color,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun localListValues(name: String, color: Int): Map<String, Any?> = listValues(name, color) + mapOf(
|
||||||
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
Lists.ACCOUNT_NAME to TasksContract.LOCAL_ACCOUNT_NAME,
|
||||||
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
Lists.ACCOUNT_TYPE to TasksContract.LOCAL_ACCOUNT_TYPE,
|
||||||
Lists.VISIBLE to 1,
|
Lists.VISIBLE to 1,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
|
* 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 IS_ALLDAY = "is_allday"
|
||||||
const val TZ = "tz"
|
const val TZ = "tz"
|
||||||
const val RRULE = "rrule"
|
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 PARENT_ID = "parent_id"
|
||||||
const val SORTING = "sorting"
|
const val SORTING = "sorting"
|
||||||
const val CREATED = "created"
|
const val CREATED = "created"
|
||||||
@@ -96,8 +103,59 @@ object TasksContract {
|
|||||||
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
const val INSTANCE_DUE_SORTING = "instance_due_sorting"
|
||||||
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
const val DISTANCE_FROM_CURRENT = "distance_from_current"
|
||||||
const val IS_RECURRING = "is_recurring"
|
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_*) --------------------------------
|
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||||
const val STATUS_NEEDS_ACTION = 0
|
const val STATUS_NEEDS_ACTION = 0
|
||||||
const val STATUS_IN_PROCESS = 1
|
const val STATUS_IN_PROCESS = 1
|
||||||
@@ -107,10 +165,21 @@ object TasksContract {
|
|||||||
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
|
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
|
||||||
const val PRIORITY_NONE = 0
|
const val PRIORITY_NONE = 0
|
||||||
|
|
||||||
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
fun authorityUri(authority: String): Uri = "content://$authority".toUri()
|
||||||
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri()
|
||||||
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
fun listUri(authority: String, listId: Long): Uri =
|
||||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
"content://$authority/${Lists.PATH}/$listId".toUri()
|
||||||
|
fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri()
|
||||||
|
fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single occurrence. Updating through this URI is how a *recurring* task is
|
||||||
|
* 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/<id>` 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. */
|
/** Append the sync-adapter params required to write local-account rows. */
|
||||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ package de.jeanlucmakiola.agendula.data.tasks
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
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<TaskReminder>.editable(): TaskReminder? = lastOrNull { !it.fromStart }
|
||||||
|
|
||||||
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
/** What to fetch from the provider. Smart-list date logic is applied above this. */
|
||||||
data class TaskQuery(
|
data class TaskQuery(
|
||||||
@@ -19,14 +38,112 @@ interface TasksDataSource {
|
|||||||
fun taskLists(): List<TaskList>
|
fun taskLists(): List<TaskList>
|
||||||
fun tasks(query: TaskQuery): List<Task>
|
fun tasks(query: TaskQuery): List<Task>
|
||||||
fun task(taskId: Long): Task?
|
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<Task>
|
fun subtasks(parentTaskId: Long): List<Task>
|
||||||
|
|
||||||
fun insertTask(form: TaskForm): Long
|
fun insertTask(form: TaskForm): Long
|
||||||
fun updateTask(taskId: Long, form: TaskForm)
|
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<TaskReminder>)
|
||||||
|
|
||||||
|
/** Every task's reminders, by task id, in stored order. One query, for the scheduler. */
|
||||||
|
fun reminders(): Map<Long, List<TaskReminder>> = alarms().mapValues { listOf(it.value) }
|
||||||
|
|
||||||
|
/** Every task's [editable] reminder, by task id. */
|
||||||
|
fun alarms(): Map<Long, TaskReminder>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<de.jeanlucmakiola.agendula.domain.export.ExportTask>
|
||||||
|
|
||||||
fun setCompleted(taskId: Long, completed: Boolean)
|
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 deleteTask(taskId: Long)
|
||||||
fun createLocalList(name: String, color: Int): 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<Long>) = Unit
|
||||||
|
|
||||||
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
/** Observe any change to tasks/lists; [onChange] fires on a background thread. */
|
||||||
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
fun registerObserver(onChange: () -> Unit): AutoCloseable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import de.jeanlucmakiola.agendula.domain.TaskDetail
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
/** 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 {
|
interface TasksRepository {
|
||||||
fun taskLists(): Flow<List<TaskList>>
|
fun taskLists(): Flow<List<TaskList>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<List<TaskList>> =
|
||||||
|
taskLists().map { lists -> lists.filter { it.acceptsWrites } }
|
||||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,20 +35,66 @@ interface TasksRepository {
|
|||||||
* children are usually filtered out of the list's own (date-based) query.
|
* children are usually filtered out of the list's own (date-based) query.
|
||||||
*/
|
*/
|
||||||
fun subtasks(parentId: Long): Flow<List<Task>>
|
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
/** [occurrenceStart] picks one occurrence of a series; `null` means the current one. */
|
||||||
|
fun taskDetail(taskId: Long, occurrenceStart: Instant? = null): Flow<TaskDetail?>
|
||||||
|
|
||||||
suspend fun createTask(form: TaskForm): Long
|
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
|
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
|
||||||
* task's current `last_modified` is re-checked first and a
|
* task's current `last_modified` is re-checked first and a
|
||||||
* [TaskConflictException] is thrown if it differs — i.e. something changed it
|
* [TaskConflictException] is thrown if it differs — i.e. something changed it
|
||||||
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
|
||||||
*/
|
*/
|
||||||
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
|
suspend fun updateTask(
|
||||||
suspend fun setCompleted(taskId: Long, completed: Boolean)
|
taskId: Long,
|
||||||
suspend fun deleteTask(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<TaskReminder>
|
||||||
|
|
||||||
|
/** Replace every reminder on the task; an empty list leaves the list's / global setting in charge. */
|
||||||
|
suspend fun setReminders(taskId: Long, reminders: List<TaskReminder>)
|
||||||
|
|
||||||
suspend fun createLocalList(name: String, color: Int): Long
|
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<Long>)
|
||||||
|
|
||||||
/** Synchronous snapshot for the permission/onboarding gate. */
|
/** Synchronous snapshot for the permission/onboarding gate. */
|
||||||
fun providerStatus(): ProviderStatus
|
fun providerStatus(): ProviderStatus
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks
|
|||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
|
import de.jeanlucmakiola.agendula.domain.SeriesCollapse
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
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.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSorting
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
@@ -28,6 +31,7 @@ import kotlin.time.Instant
|
|||||||
class TasksRepositoryImpl @Inject constructor(
|
class TasksRepositoryImpl @Inject constructor(
|
||||||
private val dataSource: TasksDataSource,
|
private val dataSource: TasksDataSource,
|
||||||
private val providerResolver: ProviderResolver,
|
private val providerResolver: ProviderResolver,
|
||||||
|
private val startupGate: StartupGate,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : TasksRepository {
|
) : TasksRepository {
|
||||||
|
|
||||||
@@ -36,16 +40,16 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||||
|
|
||||||
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||||
dataSource.subtasks(parentId)
|
SeriesCollapse.visible(dataSource.subtasks(parentId))
|
||||||
.filter { it.taskId != parentId }
|
.filter { it.taskId != parentId }
|
||||||
.sortedWith(TaskSorting.DEFAULT)
|
.sortedWith(TaskSorting.DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
override fun taskDetail(taskId: Long, occurrenceStart: Instant?): Flow<TaskDetail?> = observing {
|
||||||
dataSource.task(taskId)?.let { task ->
|
dataSource.task(taskId, occurrenceStart)?.let { task ->
|
||||||
TaskDetail(
|
TaskDetail(
|
||||||
task = task,
|
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) },
|
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
|
||||||
}
|
}
|
||||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
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.
|
// (done, total) of direct children per parent, over the unfiltered set.
|
||||||
val progress: Map<Long, Pair<Int, Int>> = all
|
val progress: Map<Long, Pair<Int, Int>> = all
|
||||||
.filter { (it.parentId ?: 0L) > 0L }
|
.filter { (it.parentId ?: 0L) > 0L }
|
||||||
@@ -80,41 +84,197 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) { dataSource.insertTask(form) }
|
|
||||||
|
|
||||||
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
|
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
// Conflict-safe overwrite: re-read just before writing and bail if the
|
val id = dataSource.insertTask(form)
|
||||||
// provider's last_modified moved since the form captured it (external
|
writeReminders(id, form)
|
||||||
// sync / another app). A null baseline means "force / overwrite anyway".
|
id
|
||||||
if (expectedLastModified != null) {
|
|
||||||
val current = dataSource.task(taskId)?.lastModified
|
|
||||||
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
|
|
||||||
}
|
|
||||||
dataSource.updateTask(taskId, form)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setCompleted(taskId: Long, completed: Boolean) =
|
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
||||||
withContext(io) { dataSource.setCompleted(taskId, completed) }
|
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) =
|
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
||||||
withContext(io) { dataSource.deleteTask(taskId) }
|
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<TaskReminder> =
|
||||||
|
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
|
||||||
|
|
||||||
|
override suspend fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
||||||
|
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 =
|
override suspend fun createLocalList(name: String, color: Int): Long =
|
||||||
withContext(io) { dataSource.createLocalList(name, color) }
|
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<Long>) =
|
||||||
|
withContext(io) { dataSource.reorderLists(listIds) }
|
||||||
|
|
||||||
override fun providerStatus(): ProviderStatus {
|
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
|
val provider = providerResolver.resolve() ?: return ProviderStatus.NO_PROVIDER
|
||||||
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
return if (providerResolver.hasPermission(provider)) ProviderStatus.READY
|
||||||
else ProviderStatus.NEEDS_PERMISSION
|
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
|
* callback (main thread) only pokes a conflated channel; the actual blocking
|
||||||
* query runs on [io].
|
* query runs on [io].
|
||||||
*/
|
*/
|
||||||
private fun <T> observing(load: () -> T): Flow<T> = callbackFlow {
|
private fun <T> observing(load: () -> T): Flow<T> = 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<Unit>(Channel.CONFLATED)
|
val ticks = Channel<Unit>(Channel.CONFLATED)
|
||||||
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
val handle = dataSource.registerObserver { ticks.trySend(Unit) }
|
||||||
ticks.trySend(Unit) // prime the initial emission
|
ticks.trySend(Unit) // prime the initial emission
|
||||||
@@ -128,3 +288,6 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
}.flowOn(io)
|
}.flowOn(io)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Subtask levels a duplicate follows; deeper than any real list, short of a cycle. */
|
||||||
|
private const val MAX_COPY_DEPTH = 8
|
||||||
|
|||||||
@@ -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>): 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<ICalComponent>): 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<ICalComponent> =
|
||||||
|
ICalParser.parseAll(text).filter { it.name.equals("VCALENDAR", ignoreCase = true) }
|
||||||
|
|
||||||
|
/** Every `VTODO` across [calendars], in document order. */
|
||||||
|
fun todosIn(calendars: List<ICalComponent>): List<ICalComponent> =
|
||||||
|
calendars.flatMap { it.components("VTODO") }
|
||||||
|
}
|
||||||
@@ -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<String> =
|
||||||
|
component.properties.mapNotNull { it.param("TZID")?.takeIf(String::isNotBlank) } +
|
||||||
|
component.components.flatMap(::tzidsIn)
|
||||||
|
}
|
||||||
@@ -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<Int>()
|
||||||
|
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 <T> 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<ICalProperty>()
|
||||||
|
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<String>,
|
||||||
|
into: MutableList<ICalProperty>,
|
||||||
|
) {
|
||||||
|
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<ICalParam>): List<ICalParam> {
|
||||||
|
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<String> {
|
||||||
|
val problems = mutableListOf<String>()
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Whether the import has run. Set before the rename, so both guards hold. */
|
||||||
|
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the last attempt failed and the archived source is still sitting
|
||||||
|
* there unread.
|
||||||
|
*
|
||||||
|
* Recorded because the alternative is what this class used to do: return an
|
||||||
|
* [ImportResult.Failed] that every caller dropped on the floor, leaving an
|
||||||
|
* upgrading user with an empty app, no message, and their tasks in a file
|
||||||
|
* only a developer could find. Settings → Storage offers the retry.
|
||||||
|
*/
|
||||||
|
val lastAttemptFailed: Flow<Boolean> = dataStore.data.map { it[IMPORT_FAILED] ?: false }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record
|
||||||
|
* completion. Safe to call on every launch.
|
||||||
|
*
|
||||||
|
* 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<ImportCounts> {
|
||||||
|
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<LegacyList>()
|
||||||
|
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<LegacyTaskRow>()
|
||||||
|
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<LegacyAlarm>()
|
||||||
|
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<Long, Long>()
|
||||||
|
val inserted = mutableListOf<Pair<LegacyTaskRow, TaskEntity>>()
|
||||||
|
val seen = mutableSetOf<Triple<Long, String, Instant?>>()
|
||||||
|
|
||||||
|
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<LegacyList>,
|
||||||
|
val tasks: List<LegacyTaskRow>,
|
||||||
|
val alarms: List<LegacyAlarm>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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?,
|
||||||
|
)
|
||||||
@@ -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<AccountEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<List<AccountEntity>>
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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<Long>)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val NONE = LocalWriteListener { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
|
||||||
|
|
||||||
|
override fun tasks(query: TaskQuery): List<Task> {
|
||||||
|
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<Task> {
|
||||||
|
val now = clock.now()
|
||||||
|
return tasks.subtasks(parentTaskId)
|
||||||
|
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||||
|
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||||
|
|
||||||
|
override fun reminders(): Map<Long, List<TaskReminder>> =
|
||||||
|
alarms.all().groupBy({ it.taskId }) {
|
||||||
|
TaskReminder(
|
||||||
|
minutesBefore = it.minutesBefore,
|
||||||
|
fromStart = it.reference == AlarmReference.START,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun alarms(): Map<Long, TaskReminder> =
|
||||||
|
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<TaskEntity>, now: Instant): List<Task> {
|
||||||
|
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<Instant?, Instant?> {
|
||||||
|
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<Instant?, Instant?> =
|
||||||
|
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<TaskReminder>) {
|
||||||
|
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<Long>) = 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<String>) = onChange()
|
||||||
|
}
|
||||||
|
database.invalidationTracker.addObserver(observer)
|
||||||
|
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zone(): String = ZoneId.systemDefault().id
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user