Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2320de60b | ||
|
|
62ee105f9d | ||
|
|
b1abcecd06 | ||
|
|
ba48a9821c | ||
|
|
fd8e4c9450 | ||
|
|
3452fd631b | ||
|
|
b8264e336f | ||
|
|
2c450d300f | ||
|
|
df1fb4e46b | ||
|
|
63c7aa64e2 | ||
|
|
c26ff5909b | ||
|
|
c1219a434d | ||
|
|
5dc1d49295 | ||
|
|
08c297f30a | ||
|
|
1d6c2a0d59 | ||
|
|
2a1230c0ca | ||
|
|
cd3b33c959 | ||
|
|
8175d58dcd | ||
|
|
05696b2991 | ||
|
|
23f6156e71 | ||
|
|
c1b65c0c86 | ||
|
|
b5ac279b94 | ||
|
|
8fba3f01a4 | ||
|
|
3b73dd4c53 | ||
|
|
01850a7b74 | ||
|
|
28e646fb31 | ||
|
|
6e3cc4bdbd | ||
|
|
b6185ed53c | ||
|
|
35110c96ce | ||
|
|
9338c64dc7 | ||
|
|
9a72fb0880 | ||
|
|
24e978efc3 | ||
|
|
2740dd5562 | ||
|
|
8dbfd07d10 | ||
|
|
3e1a84ab7d | ||
|
|
dcc55a158e |
@@ -195,6 +195,15 @@ jobs:
|
|||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Debug builds aren't minified, so a keep rule that drops a reflectively
|
||||||
|
# created class's constructor only breaks release builds (#1, #75). The
|
||||||
|
# releaseTest twin is R8-shrunk with the release rules and debug-signed.
|
||||||
|
- name: R8 keeps reflective constructors
|
||||||
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
run: |
|
||||||
|
./gradlew assembleFullReleaseTest
|
||||||
|
python3 scripts/check_r8_keeps.py
|
||||||
|
|
||||||
- name: Trivy filesystem scan
|
- name: Trivy filesystem scan
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -7,6 +7,88 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Self-hosted servers with a self-signed certificate can be added: Agendula
|
||||||
|
shows the certificate's fingerprint and asks whether to trust it. A server
|
||||||
|
reached over plain http can be added too, after a warning. If a trusted
|
||||||
|
certificate is later renewed, "Sign in again" on the account asks about the
|
||||||
|
new one (#68).
|
||||||
|
- An account can be given its own name (#105).
|
||||||
|
- Pull down on the task list to sync (#73).
|
||||||
|
- Task list sections can be collapsed (#91).
|
||||||
|
- A pure black theme for AMOLED screens (#99).
|
||||||
|
- Agendula can show up as "Tasks" in your launcher, picked from launcher
|
||||||
|
previews as in Calendula (#104).
|
||||||
|
- Reminder notifications show the task's description (#94).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Adding or opening a task from a widget returns you to the home screen
|
||||||
|
afterwards (#93).
|
||||||
|
- A swipe that starts at the screen edge no longer swipes the task (#90).
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-10-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A reminder can count from the task's start as well as from its due date, and
|
||||||
|
tasks with only a start date get reminders too. Default reminders can mix
|
||||||
|
both, e.g. a day before the start and an hour before the due date; each
|
||||||
|
applies to tasks that have its date (#72).
|
||||||
|
- An all-day task's reminders can each have their own time, so one task can
|
||||||
|
remind you at 09:00 and again at 17:00 (#78).
|
||||||
|
- Other apps can read the tasks kept in Agendula's own database through an
|
||||||
|
OpenTasks-compatible provider, once you grant them permission. Apps that
|
||||||
|
already support OpenTasks or tasks.org need only a small change to support
|
||||||
|
Agendula too (#43).
|
||||||
|
- With a second permission, other apps can also add, edit, complete and delete
|
||||||
|
those tasks, including their reminders and tags. They can tick off a single
|
||||||
|
occurrence of a recurring task. Task lists stay read-only for them (#63).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A new task no longer takes a default reminder whose time has already
|
||||||
|
passed, so a start of "today" does not ring the moment the task is saved.
|
||||||
|
- All-day default reminders are whole days; one set in hours rounds to the
|
||||||
|
nearest day.
|
||||||
|
- Switching a task between timed and all-day converts its reminders right
|
||||||
|
away in the editor, so what you see is what gets saved.
|
||||||
|
- The editor shows the default reminders a new task, or a newly added date,
|
||||||
|
will get, before you save. Removing them leaves the task without a reminder
|
||||||
|
instead of falling back to the default.
|
||||||
|
- Reminders now sync. A reminder set on a task is uploaded as a standard alarm
|
||||||
|
(`VALARM`), so it also notifies in macOS and iOS Reminders, Thunderbird,
|
||||||
|
tasks.org and jtx Board, and alarms set in those apps now fire in Agendula
|
||||||
|
instead of the default reminder. Removing a task's alarm in another app
|
||||||
|
turns it off here too, rather than falling back to the default (#52).
|
||||||
|
- A new task with a date, or one that gets a new date, takes the default
|
||||||
|
reminder for that date as its own, so it travels with the task to the server.
|
||||||
|
Changing the default later no longer changes those tasks; tasks that rely on
|
||||||
|
the default today are left as they are.
|
||||||
|
- On upgrade, reminders on open, synced tasks that so far stayed on this
|
||||||
|
device are uploaded once.
|
||||||
|
- Alarms imported from an `.ics` file become reminders on the task.
|
||||||
|
- Screens now open and close the way they do in Calendula: they slide in, and
|
||||||
|
a back swipe previews the screen behind before closing (#66).
|
||||||
|
- Settings now use Calendula's names and layout. Reminders is now
|
||||||
|
Notifications, Task form is now New task form, and export and import moved
|
||||||
|
out of Storage into a new Backup & restore section (#66).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed a crash when a task's date was shown in Russian and other languages
|
||||||
|
whose dates put a word after the year (#70).
|
||||||
|
- Widgets no longer turn into the Today widget or show "Can't load content"
|
||||||
|
after a task is ticked off (#92).
|
||||||
|
- Ticking off a task in the Tasks and Up next widgets now works in release
|
||||||
|
builds; until now the tap did nothing (#75).
|
||||||
|
- The Tasks widget leaves the same gap under its header as its preview (#86).
|
||||||
|
- The Starts and Due labels in the task form no longer get cut off at large
|
||||||
|
font sizes (#76).
|
||||||
|
- Adding an account by email address finds the server again for providers
|
||||||
|
that publish it in DNS, such as mailbox.org and Posteo. The DNS lookup never
|
||||||
|
reached the network, so they ended in "isn't a CalDAV server". It now goes
|
||||||
|
through Android's resolver and respects Private DNS (#97).
|
||||||
|
- When the server can't be found from your email address, account setup asks
|
||||||
|
for the server address straight away and keeps your email as the username,
|
||||||
|
instead of stopping at an error (#97).
|
||||||
|
|
||||||
## [1.1.0] - 2026-10-10
|
## [1.1.0] - 2026-10-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ required.</p>
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
<a href="https://f-droid.org/packages/de.jeanlucmakiola.agendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||||
|
|
||||||
|
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
||||||
|
|
||||||
<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://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>
|
<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>
|
||||||
@@ -50,7 +54,8 @@ many small *florets* — so the two apps are florets of one bloom.)
|
|||||||
this and the following ones, or the whole series.
|
this and the following ones, or the whole series.
|
||||||
- **Reminders** that fire at the exact time: several per task, separate defaults
|
- **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
|
for timed and all-day tasks (globally and per list), Done and Snooze right on
|
||||||
the notification, and they survive reboots.
|
the notification, and they survive reboots. They sync as standard `VALARM`s,
|
||||||
|
so they also ring in other CalDAV clients, and theirs ring here.
|
||||||
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
|
- **iCalendar import and export** — open an `.ics` or a zip of them, or write any
|
||||||
list out as standard `.ics` files.
|
list out as standard `.ics` files.
|
||||||
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
|
- A **home-screen widget**, launcher shortcuts, a Quick Settings tile, and
|
||||||
@@ -97,6 +102,10 @@ 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,
|
OpenTasks rather than replacing it**. If you already sync through a provider,
|
||||||
Agendula can work on top of it exactly as before.
|
Agendula can work on top of it exactly as before.
|
||||||
|
|
||||||
|
Other apps can read and write the tasks in Agendula's own store through an
|
||||||
|
OpenTasks-compatible provider, once you grant them permission. See
|
||||||
|
[docs/CONTENT_PROVIDER.md](docs/CONTENT_PROVIDER.md).
|
||||||
|
|
||||||
Switching between the two moves nothing — each store keeps its own tasks — so
|
Switching between the two moves nothing — each store keeps its own tasks — so
|
||||||
**Settings → Storage** asks before it switches, and offers to **copy** a
|
**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
|
provider's tasks into Agendula's own store when you want to move over. The copy
|
||||||
@@ -107,10 +116,20 @@ proprietary, and open standards — CalDAV and iCalendar — are the lane.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### F-Droid repository
|
### F-Droid (recommended)
|
||||||
|
|
||||||
Every release is built, signed and published to a self-hosted F-Droid
|
Agendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
||||||
repository. Add it once and your F-Droid client handles updates from then on:
|
for Agendula in any F-Droid client, or
|
||||||
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.agendula/).
|
||||||
|
|
||||||
|
F-Droid builds from source on its own schedule, so a new version usually shows
|
||||||
|
up there a few days after release.
|
||||||
|
|
||||||
|
### Self-hosted F-Droid repo (fastest updates)
|
||||||
|
|
||||||
|
The release pipeline builds, signs and publishes every release to a
|
||||||
|
self-hosted F-Droid repository, so new versions land there first. 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
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
link below on your phone):
|
link below on your phone):
|
||||||
@@ -135,12 +154,21 @@ app.
|
|||||||
Betas of upcoming versions are published there too, as pre-releases; to test
|
Betas of upcoming versions are published there too, as pre-releases; to test
|
||||||
them, switch on *Include prereleases* for Agendula in Obtainium.
|
them, switch on *Include prereleases* for Agendula in Obtainium.
|
||||||
|
|
||||||
|
### Google Play
|
||||||
|
|
||||||
|
Agendula is on
|
||||||
|
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.agendula).
|
||||||
|
|
||||||
|
Play builds are signed with Google's key rather than mine, so switching between
|
||||||
|
Play and any other channel means uninstalling first. Tasks kept only in
|
||||||
|
Agendula's own database go with it, so sync them to a CalDAV account first.
|
||||||
|
|
||||||
### Offline version
|
### Offline version
|
||||||
|
|
||||||
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
Each Codeberg release also carries `agendula-offline_v<version>.apk`: Agendula
|
||||||
without CalDAV sync and **without the network permission**, so it cannot
|
without CalDAV sync and **without the network permission**, so it cannot
|
||||||
connect to anything. It installs alongside the regular app rather than replacing
|
connect to anything. It installs alongside the regular app rather than replacing
|
||||||
it; move tasks between the two with Settings → Storage → Export / Import. In
|
it; move tasks between the two with Settings → Backup & restore → Export / Import. In
|
||||||
Obtainium, pick it with *Filter APKs by regular expression* set to
|
Obtainium, pick it with *Filter APKs by regular expression* set to
|
||||||
`^agendula-offline_v`.
|
`^agendula-offline_v`.
|
||||||
|
|
||||||
@@ -158,8 +186,9 @@ JDK 17 and the Android SDK are all it needs; see
|
|||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
Translations are managed on a self-hosted **Weblate**, and partial ones are
|
||||||
fine — an untranslated string simply falls back to English. Agendula ships in
|
fine — an untranslated string simply falls back to English.
|
||||||
English, German and Brazilian Portuguese so far.
|
|
||||||
|
<a href="https://weblate.dev.jeanlucmakiola.de/engage/agendula/"><img src="https://weblate.dev.jeanlucmakiola.de/widget/agendula/multi-auto.svg" alt="Translation status" width="600"></a>
|
||||||
|
|
||||||
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
**→ [Help translate Agendula](https://weblate.dev.jeanlucmakiola.de/engage/agendula/)**
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ android {
|
|||||||
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
|
||||||
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 1010099
|
versionCode = 1030002
|
||||||
versionName = "1.1.0"
|
versionName = "1.3.0-beta.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
# 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 ...". This first bit us through a
|
# as "Failed to create an instance of ...". This first bit us through a
|
||||||
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
||||||
# issue #1); Glance is gone and Room is now our own task store, so the rule
|
# issue #1); Room is now also our own task store, so the rule matters more,
|
||||||
# matters more, not less — TasksDatabase is built on the first store read.
|
# not less — TasksDatabase is built on the first store read.
|
||||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "725292b38bce25ce9032c4650573ae85",
|
||||||
|
"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, `categories` TEXT NOT NULL DEFAULT '', `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, `reminders_off` INTEGER NOT NULL DEFAULT 0, 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": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindersOff",
|
||||||
|
"columnName": "reminders_off",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, `uid` TEXT NOT NULL DEFAULT '', `raw` 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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"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, '725292b38bce25ce9032c4650573ae85')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 4,
|
||||||
|
"identityHash": "47f3bedbfbf4ddd1db32864c718de83a",
|
||||||
|
"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, `label` 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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "label",
|
||||||
|
"columnName": "label",
|
||||||
|
"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, `categories` TEXT NOT NULL DEFAULT '', `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, `reminders_off` INTEGER NOT NULL DEFAULT 0, 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": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remindersOff",
|
||||||
|
"columnName": "reminders_off",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, `uid` TEXT NOT NULL DEFAULT '', `raw` 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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "raw",
|
||||||
|
"columnName": "raw",
|
||||||
|
"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, '47f3bedbfbf4ddd1db32864c718de83a')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.ContentProviderOperation
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PermissionInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
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 org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/** The provider as another app meets it: manifest, permission, URIs. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AgendulaTaskProviderTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private val authority = "${context.packageName}.tasks"
|
||||||
|
private val readPermission = "${context.packageName}.permission.READ_TASKS"
|
||||||
|
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
|
||||||
|
private val resolver = context.contentResolver
|
||||||
|
|
||||||
|
private fun uri(path: String) = "content://$authority/$path".toUri()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isExportedUnderItsPermissions() {
|
||||||
|
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
|
||||||
|
|
||||||
|
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
|
||||||
|
assertThat(info.exported).isTrue()
|
||||||
|
assertThat(info.readPermission).isEqualTo(readPermission)
|
||||||
|
assertThat(info.writePermission).isEqualTo(writePermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bothPermissionsAreDangerous() {
|
||||||
|
listOf(readPermission, writePermission).forEach { permission ->
|
||||||
|
val info = context.packageManager.getPermissionInfo(permission, 0)
|
||||||
|
|
||||||
|
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
|
||||||
|
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
|
||||||
|
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAppWithoutThePermissionIsDenied() {
|
||||||
|
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
// The shell is another uid that was never granted it.
|
||||||
|
val output = shell("content query --uri content://$authority/tasks")
|
||||||
|
|
||||||
|
assertThat(output).contains("Permission Denial")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAppWithoutTheWritePermissionCannotWrite() {
|
||||||
|
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
|
||||||
|
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
|
||||||
|
|
||||||
|
assertThat(output).contains("Permission Denial")
|
||||||
|
assertThat(output).contains(writePermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reportsOpenTasksMimeTypes() {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
|
||||||
|
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyTableAnswersWithItsColumns() {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
resolver.query(uri(table.path), null, null, null, null)!!.use {
|
||||||
|
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCursorWatchesTheUriItWasQueriedFor() {
|
||||||
|
val tasks = uri("tasks")
|
||||||
|
|
||||||
|
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRowUriReadsOnlyThatRow() {
|
||||||
|
// Ids start at 1, so 0 names nothing whatever the store holds.
|
||||||
|
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownUriIsRejected() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
|
||||||
|
// ContentResolver.getType swallows the provider's exception and answers null.
|
||||||
|
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsAreReadOnly() {
|
||||||
|
val values = ContentValues().apply { put("list_name", "x") }
|
||||||
|
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRowUriTakesNoInsert() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBadWriteReachesTheClientAsIllegalArgument() {
|
||||||
|
// No list has this id, so nothing in the app's real store is touched.
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put("list_id", Long.MAX_VALUE)
|
||||||
|
put("title", "x")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailingBatchWritesNothing() {
|
||||||
|
val missingList = Long.MAX_VALUE
|
||||||
|
val operations = arrayListOf(
|
||||||
|
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
|
||||||
|
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
|
||||||
|
.use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stdout and stderr of [command]; the denial lands on stderr. */
|
||||||
|
private fun shell(command: String): String {
|
||||||
|
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||||
|
.executeShellCommandRwe(command)
|
||||||
|
stdin.close()
|
||||||
|
return listOf(stdout, stderr).joinToString("\n") { pfd ->
|
||||||
|
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
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.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
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 org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ProviderSnapshotSourceTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var resolver: ProviderResolver
|
||||||
|
private lateinit var source: ProviderSnapshotSource
|
||||||
|
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
|
||||||
|
private val changes = LinkedBlockingQueue<Uri>()
|
||||||
|
private val observer = object : ContentObserver(null) {
|
||||||
|
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||||
|
changes += uri ?: Uri.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var listId = 0L
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
|
||||||
|
resolver = ProviderResolver(NoProviderInstalled)
|
||||||
|
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
|
||||||
|
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
|
||||||
|
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
context.contentResolver.unregisterContentObserver(observer)
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
|
||||||
|
source.query(table, null, selection, args, null)
|
||||||
|
|
||||||
|
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
|
||||||
|
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
|
||||||
|
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
|
||||||
|
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
|
||||||
|
|
||||||
|
/** Waits for the coalesced notification, then drains any stragglers. */
|
||||||
|
private fun awaitChange() {
|
||||||
|
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
|
||||||
|
Thread.sleep(700)
|
||||||
|
changes.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoChange() {
|
||||||
|
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun servesTheOwnStore() {
|
||||||
|
val id = insert("Milk")
|
||||||
|
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
|
||||||
|
|
||||||
|
assertThat(titles()).containsExactly("Milk")
|
||||||
|
query(ProviderTable.LISTS).use {
|
||||||
|
it.moveToFirst()
|
||||||
|
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
|
||||||
|
}
|
||||||
|
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDeletedTaskIsNotPublished() {
|
||||||
|
val id = insert("Gone")
|
||||||
|
insert("Kept")
|
||||||
|
db.tasks().markDeleted(id, clock.now())
|
||||||
|
|
||||||
|
assertThat(titles()).containsExactly("Kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWriteNotifiesObserversAndIsServedNext() {
|
||||||
|
source.start()
|
||||||
|
assertThat(titles()).isEmpty()
|
||||||
|
|
||||||
|
insert("Later")
|
||||||
|
|
||||||
|
awaitChange()
|
||||||
|
assertThat(titles()).containsExactly("Later")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBurstOfWritesNotifiesOnce() {
|
||||||
|
source.start()
|
||||||
|
titles()
|
||||||
|
|
||||||
|
repeat(5) { insert("t$it") }
|
||||||
|
|
||||||
|
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
|
||||||
|
assertNoChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun externalModeServesNothing() {
|
||||||
|
insert("Mine")
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
|
||||||
|
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchingModeNotifiesAndRebuilds() {
|
||||||
|
source.start()
|
||||||
|
insert("Mine")
|
||||||
|
assertThat(titles()).containsExactly("Mine")
|
||||||
|
changes.clear()
|
||||||
|
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
awaitChange()
|
||||||
|
assertThat(titles()).isEmpty()
|
||||||
|
|
||||||
|
resolver.storageMode = StorageMode.OWN
|
||||||
|
awaitChange()
|
||||||
|
assertThat(titles()).containsExactly("Mine")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writesInExternalModeDoNotNotify() {
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
source.start()
|
||||||
|
titles()
|
||||||
|
|
||||||
|
insert("Hidden")
|
||||||
|
|
||||||
|
assertNoChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aClientSelectionNarrowsTheRows() {
|
||||||
|
insert("a")
|
||||||
|
insert("b")
|
||||||
|
|
||||||
|
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
|
||||||
|
source.start()
|
||||||
|
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
|
||||||
|
awaitChange()
|
||||||
|
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
|
||||||
|
|
||||||
|
clock.current += 1.days
|
||||||
|
|
||||||
|
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
|
||||||
|
awaitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFreshSnapshotIsReused() {
|
||||||
|
insert("a")
|
||||||
|
titles()
|
||||||
|
|
||||||
|
clock.current += 30.minutes
|
||||||
|
|
||||||
|
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
|
||||||
|
assertNoChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentOriginalTime(): Long =
|
||||||
|
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
|
||||||
|
assertThat(it.moveToFirst()).isTrue()
|
||||||
|
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MutableClock(var current: Instant) : Clock {
|
||||||
|
override fun now(): Instant = current
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+811
@@ -0,0 +1,811 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
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.ProviderEnvironment
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
|
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.TaskStatus
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
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.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Writes from another app, applied to the own store through the published tables. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ProviderWriterTest {
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private lateinit var db: TasksDatabase
|
||||||
|
private lateinit var resolver: ProviderResolver
|
||||||
|
private lateinit var source: ProviderSnapshotSource
|
||||||
|
private lateinit var writer: ProviderWriter
|
||||||
|
private var afterWrites = 0
|
||||||
|
private var home = 0L
|
||||||
|
|
||||||
|
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
|
||||||
|
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
|
||||||
|
resolver = ProviderResolver(NoProvider)
|
||||||
|
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
|
||||||
|
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
|
||||||
|
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() = db.close()
|
||||||
|
|
||||||
|
private fun task(id: Long) = db.tasks().entity(id)
|
||||||
|
|
||||||
|
private fun insert(vararg values: Pair<String, Any?>): Long =
|
||||||
|
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
|
||||||
|
|
||||||
|
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
|
||||||
|
|
||||||
|
private fun synced(readOnly: Boolean = false): Long {
|
||||||
|
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
|
||||||
|
return db.taskLists().insert(
|
||||||
|
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
|
||||||
|
private fun instances(): List<Triple<Long, Long, Int>> =
|
||||||
|
source.rows(
|
||||||
|
ProviderTable.INSTANCES,
|
||||||
|
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
|
||||||
|
|
||||||
|
private fun instanceId(original: Instant): Long =
|
||||||
|
source.rows(
|
||||||
|
ProviderTable.INSTANCES,
|
||||||
|
listOf(Tasks.ID),
|
||||||
|
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
|
||||||
|
arrayOf(original.toEpochMilliseconds().toString()),
|
||||||
|
).single().single() as Long
|
||||||
|
|
||||||
|
// --- inserts ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInsertedTaskIsPublishedAndOwedASync() {
|
||||||
|
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
|
||||||
|
|
||||||
|
val stored = task(id)!!
|
||||||
|
assertThat(stored.title).isEqualTo("Milk")
|
||||||
|
assertThat(stored.priority).isEqualTo(1)
|
||||||
|
assertThat(stored.isDirty).isTrue()
|
||||||
|
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
|
||||||
|
.containsExactly(listOf("Milk"))
|
||||||
|
assertThat(afterWrites).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInsertNeedsAListThatExistsAndIsWritable() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
|
||||||
|
val readOnly = synced(readOnly = true)
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceCannotBeInsertedAsATask() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDuplicateUidIsRejected() {
|
||||||
|
insert(Tasks.UID to "same")
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsAndInstancesTakeNoInserts() {
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
|
||||||
|
}
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
|
||||||
|
}
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownColumnIsRejected() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nothingIsWrittenInExternalMode() {
|
||||||
|
val id = insert(Tasks.TITLE to "Mine")
|
||||||
|
resolver.storageMode = StorageMode.EXTERNAL
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException::class.java) { insert() }
|
||||||
|
assertThrows(IllegalStateException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
|
||||||
|
}
|
||||||
|
assertThat(task(id)!!.title).isEqualTo("Mine")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- updates ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUpdateChangesTheRowsItsSelectionMatches() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b")
|
||||||
|
|
||||||
|
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(1)
|
||||||
|
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(task(b)!!.completedAt).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUpdateMatchingNothingChangesNothing() {
|
||||||
|
insert(Tasks.TITLE to "a")
|
||||||
|
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTaskInAReadOnlyListCannotChange() {
|
||||||
|
val readOnly = synced(readOnly = true)
|
||||||
|
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
|
||||||
|
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
|
||||||
|
}
|
||||||
|
assertThrows(UnsupportedOperationException::class.java) {
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
|
||||||
|
}
|
||||||
|
assertThat(task(id)!!.title).isEqualTo("theirs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTaskMovesBetweenLists() {
|
||||||
|
val work = synced()
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aParentMustBeInTheSameListAndNotADescendant() {
|
||||||
|
val work = synced()
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||||
|
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
|
||||||
|
|
||||||
|
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
assertThat(db.tasks().overrides(series)).hasSize(1)
|
||||||
|
|
||||||
|
writer.update(
|
||||||
|
ProviderTable.TASKS,
|
||||||
|
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
|
||||||
|
byId(ProviderTable.TASKS, series),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aClientCannotTouchSyncState() {
|
||||||
|
val work = synced()
|
||||||
|
val id = db.tasks().insert(
|
||||||
|
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
val stored = task(id)!!
|
||||||
|
assertThat(stored.title).isEqualTo("new")
|
||||||
|
assertThat(stored.href).isEqualTo("/s.ics")
|
||||||
|
assertThat(stored.etag).isEqualTo("e")
|
||||||
|
assertThat(stored.sequence).isEqualTo(2)
|
||||||
|
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
|
||||||
|
assertThat(stored.isDirty).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- instances --------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completingAnOccurrenceTicksOffOnlyThatOne() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(1)
|
||||||
|
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
val override = db.tasks().overrides(series).single()
|
||||||
|
assertThat(override.recurrenceId).isEqualTo(today)
|
||||||
|
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
val statuses = instances().associate { it.first to it.third }
|
||||||
|
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
|
||||||
|
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
|
||||||
|
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceCanBeCancelledAndReopened() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentWrittenToAnOccurrenceEditsTheSeries() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceCannotBeMoved() {
|
||||||
|
dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOccurrenceHasNoPartialProgress() {
|
||||||
|
dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPlainTaskInstanceWritesTheTask() {
|
||||||
|
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
|
||||||
|
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
|
||||||
|
|
||||||
|
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||||
|
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAnOccurrenceExceptsIt() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.exdate).isNotNull()
|
||||||
|
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
|
||||||
|
assertThat(instances()).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAModifiedOccurrenceExceptsItToo() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- deletes ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aParentGoesWithItsSubtasks() {
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||||
|
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
|
||||||
|
val other = insert(Tasks.TITLE to "other")
|
||||||
|
|
||||||
|
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
|
||||||
|
|
||||||
|
assertThat(task(parent)).isNull()
|
||||||
|
assertThat(task(child)).isNull()
|
||||||
|
assertThat(task(grandchild)).isNull()
|
||||||
|
assertThat(task(other)).isNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSyncedTaskLeavesATombstone() {
|
||||||
|
val work = synced()
|
||||||
|
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
assertThat(task(id)!!.isDeleted).isTrue()
|
||||||
|
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
val override = db.tasks().overrides(series).single().id
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
|
||||||
|
|
||||||
|
assertThat(task(series)).isNotNull()
|
||||||
|
assertThat(task(series)!!.exdate).isNotNull()
|
||||||
|
assertThat(instances()).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- properties -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAlarmCanBeAddedChangedAndRemoved() {
|
||||||
|
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
|
||||||
|
db.tasks().update(task(id)!!.copy(isDirty = false))
|
||||||
|
|
||||||
|
val propertyId = writer.insert(
|
||||||
|
ProviderTable.PROPERTIES,
|
||||||
|
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
|
||||||
|
)
|
||||||
|
val alarm = db.alarms().forTask(id).single()
|
||||||
|
assertThat(alarm.minutesBefore).isEqualTo(15)
|
||||||
|
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
|
||||||
|
assertThat(task(id)!!.isDirty).isTrue()
|
||||||
|
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
|
||||||
|
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
|
||||||
|
assertThat(db.alarms().forTask(id)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tagsCanBeAddedRenamedAndRemoved() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
val propertyId = writer.insert(
|
||||||
|
ProviderTable.PROPERTIES,
|
||||||
|
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
|
||||||
|
)
|
||||||
|
assertThat(task(id)!!.categories).containsExactly("errand")
|
||||||
|
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
assertThat(task(id)!!.categories).containsExactly("shopping")
|
||||||
|
|
||||||
|
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
|
||||||
|
assertThat(task(id)!!.categories).containsExactly("shopping")
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
|
||||||
|
assertThat(task(id)!!.categories).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPropertyCannotChangeItsKindOrTask() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
val other = insert(Tasks.TITLE to "o")
|
||||||
|
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
|
||||||
|
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyAlarmsAndTagsCanBeWritten() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
|
||||||
|
}
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- batches ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchIsAllOrNothing() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.batch {
|
||||||
|
insert(Tasks.TITLE to "first")
|
||||||
|
insert(Tasks.TITLE to "second")
|
||||||
|
insert(Tasks.LIST_ID to 999L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
|
||||||
|
writer.batch {
|
||||||
|
insert(Tasks.TITLE to "first")
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(afterWrites).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- review regressions -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b")
|
||||||
|
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
|
||||||
|
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
|
||||||
|
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
|
||||||
|
.map { it.single() as Long }
|
||||||
|
|
||||||
|
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
|
||||||
|
|
||||||
|
assertThat(db.alarms().forTask(a)).isEmpty()
|
||||||
|
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
|
||||||
|
dailySeries()
|
||||||
|
val first = instanceId(today)
|
||||||
|
val second = instanceId(today + 1.days)
|
||||||
|
|
||||||
|
writer.batch {
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(instances().map { it.first }).containsExactly(
|
||||||
|
(today - 1.days).toEpochMilliseconds(),
|
||||||
|
(today + 2.days).toEpochMilliseconds(),
|
||||||
|
(today + 3.days).toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
|
||||||
|
dailySeries()
|
||||||
|
val later = instanceId(today + 2.days)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(instanceId(today + 2.days)).isEqualTo(later)
|
||||||
|
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
|
||||||
|
.isEqualTo(1)
|
||||||
|
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
|
||||||
|
.isEqualTo((today + 2.days).toEpochMilliseconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val longAgo = today - 1000.days
|
||||||
|
val subtask = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
|
||||||
|
timezone = "UTC", parentId = parent),
|
||||||
|
)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
|
||||||
|
|
||||||
|
assertThat(task(subtask)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val subtask = dailySeries("sub")
|
||||||
|
db.tasks().update(task(subtask)!!.copy(parentId = parent))
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
|
||||||
|
|
||||||
|
assertThat(task(subtask)).isNull()
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
val override = db.tasks().overrides(series).single().id
|
||||||
|
val row = readRow(ProviderTable.TASKS, override)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
|
||||||
|
|
||||||
|
assertThat(task(override)!!.title).isEqualTo("Renamed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
|
||||||
|
val series = dailySeries()
|
||||||
|
val instance = instanceId(today)
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instance)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingASubtreeMovesItsSubtasks() {
|
||||||
|
val work = synced()
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
|
||||||
|
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
|
||||||
|
|
||||||
|
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
|
||||||
|
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||||
|
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMoveOntoATakenUidIsRejected() {
|
||||||
|
val work = synced()
|
||||||
|
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
|
||||||
|
val id = insert(Tasks.UID to "same")
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
|
||||||
|
}
|
||||||
|
assertThat(task(id)!!.listId).isEqualTo(home)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
|
||||||
|
val series = dailySeries()
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
val override = db.tasks().overrides(series).single().id
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(task(override)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWriteThatChangesNothingOwesNoSync() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
db.tasks().update(task(id)!!.copy(isDirty = false))
|
||||||
|
val modified = task(id)!!.lastModified
|
||||||
|
|
||||||
|
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
|
||||||
|
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
|
||||||
|
|
||||||
|
assertThat(task(id)!!.isDirty).isFalse()
|
||||||
|
assertThat(task(id)!!.lastModified).isEqualTo(modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningAnOpenOccurrenceForksNothing() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aWriteBeforeStartupSettlesIsRefused() {
|
||||||
|
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
|
||||||
|
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
|
||||||
|
assertThat(db.tasks().live()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSingleRowWriteNeedsNoSnapshot() {
|
||||||
|
val id = insert(Tasks.TITLE to "t")
|
||||||
|
val unknown = 987_654L
|
||||||
|
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
|
||||||
|
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
|
||||||
|
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- second review regressions -----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
|
||||||
|
val series = dailySeries("Standup")
|
||||||
|
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
|
||||||
|
store().setCompletedInstance(series, today, false)
|
||||||
|
val override = db.tasks().overrides(series).single()
|
||||||
|
db.tasks().update(override.copy(title = "Standup (moved room)"))
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Standup")
|
||||||
|
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
|
||||||
|
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
|
||||||
|
val series = dailySeries()
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBadZoneOnAnInstanceReportsTheZone() {
|
||||||
|
dailySeries()
|
||||||
|
val instance = instanceId(today)
|
||||||
|
|
||||||
|
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(error).hasMessageThat().contains("Unknown time zone")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anInProcessSeriesCanBeWrittenBackAsRead() {
|
||||||
|
val series = dailySeries()
|
||||||
|
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
|
||||||
|
assertThat(task(series)!!.title).isEqualTo("Renamed")
|
||||||
|
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||||
|
assertThat(db.tasks().overrides(series)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFloatingSeriesCanBeWrittenBackAsRead() {
|
||||||
|
val series = db.tasks().insert(
|
||||||
|
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
|
||||||
|
)
|
||||||
|
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
|
||||||
|
|
||||||
|
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
|
||||||
|
|
||||||
|
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
|
||||||
|
assertThat(task(series)!!.timezone).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aParentLoopDeletesInsteadOfRecursingForever() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
|
||||||
|
db.tasks().update(task(a)!!.copy(parentId = b))
|
||||||
|
|
||||||
|
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
|
||||||
|
|
||||||
|
assertThat(task(a)).isNull()
|
||||||
|
assertThat(task(b)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
|
||||||
|
val work = synced()
|
||||||
|
// The subtask gets the lower id, so it comes first unless the writer reorders.
|
||||||
|
val child = insert(Tasks.TITLE to "child")
|
||||||
|
val parent = insert(Tasks.TITLE to "parent")
|
||||||
|
db.tasks().update(task(child)!!.copy(parentId = parent))
|
||||||
|
|
||||||
|
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2)
|
||||||
|
assertThat(task(child)!!.listId).isEqualTo(work)
|
||||||
|
assertThat(task(parent)!!.listId).isEqualTo(work)
|
||||||
|
assertThat(task(child)!!.parentId).isEqualTo(parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRowSelectedWithAnArgumentIsWritten() {
|
||||||
|
val a = insert(Tasks.TITLE to "a")
|
||||||
|
val b = insert(Tasks.TITLE to "b")
|
||||||
|
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
|
||||||
|
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
|
||||||
|
|
||||||
|
assertThat(task(a)!!.title).isEqualTo("a")
|
||||||
|
assertThat(task(b)!!.title).isEqualTo("x")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun garbageExceptionDatesAreRejected() {
|
||||||
|
val series = dailySeries()
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
|
||||||
|
}
|
||||||
|
assertThat(task(series)!!.exdate).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store() = RoomTasksDataSource(db)
|
||||||
|
|
||||||
|
/** Every column of one published row, as a client that read it would write it back. */
|
||||||
|
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
|
||||||
|
val columns = table.columns.map { it.first }
|
||||||
|
val row = source.rows(table, columns, byId(table, id), null).single()
|
||||||
|
return columns.zip(row).toMap() - table.idColumn
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoProvider : ProviderEnvironment {
|
||||||
|
override fun packageDeclaring(authority: String): String? = null
|
||||||
|
override fun isGranted(permission: String): Boolean = false
|
||||||
|
override fun appLabel(packageName: String): String? = null
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SnapshotDatabaseTest {
|
||||||
|
|
||||||
|
private val db = SnapshotDatabase()
|
||||||
|
|
||||||
|
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
|
||||||
|
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
|
||||||
|
|
||||||
|
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
|
||||||
|
ProviderSnapshot(
|
||||||
|
ProviderTable.entries.associateWith {
|
||||||
|
when (it) {
|
||||||
|
ProviderTable.TASKS -> tasks.toList()
|
||||||
|
ProviderTable.LISTS -> lists
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Cursor.strings(column: String): List<String?> = use {
|
||||||
|
val index = getColumnIndexOrThrow(column)
|
||||||
|
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNullProjectionReturnsEveryColumnInTableOrder() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
db.query(ProviderTable.TASKS, null, null, null, null).use {
|
||||||
|
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
|
||||||
|
.inOrder()
|
||||||
|
assertThat(it.count).isEqualTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionArgumentsAndSortOrderApply() {
|
||||||
|
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
|
||||||
|
|
||||||
|
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
|
||||||
|
.strings(Tasks.TITLE)
|
||||||
|
|
||||||
|
assertThat(titles).containsExactly("a", "c", "b").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typesSurviveTheCopy() {
|
||||||
|
load(task(1, null, due = 1_700_000_000_000))
|
||||||
|
|
||||||
|
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
|
||||||
|
it.moveToFirst()
|
||||||
|
assertThat(it.isNull(0)).isTrue()
|
||||||
|
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
|
||||||
|
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
|
||||||
|
assertThat(it.getInt(2)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnsupportedColumnReadsAsNull() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
|
||||||
|
.containsExactly(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownProjectionColumnIsRejected() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUnknownSelectionColumnIsRejected() {
|
||||||
|
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSelectionCannotBreakOutOfItsWhereClause() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException::class.java) {
|
||||||
|
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eachTableAnswersOnlyForItsOwnRows() {
|
||||||
|
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
|
||||||
|
|
||||||
|
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
|
||||||
|
.containsExactly("Home")
|
||||||
|
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
|
||||||
|
load(task(1, "old"))
|
||||||
|
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
|
||||||
|
|
||||||
|
load(task(2, "new"))
|
||||||
|
|
||||||
|
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
|
||||||
|
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
|
||||||
|
.containsExactly("new")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEmptySnapshotEmptiesEveryTable() {
|
||||||
|
load(task(1, "a"))
|
||||||
|
|
||||||
|
db.load(ProviderSnapshot.EMPTY)
|
||||||
|
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
-29
@@ -5,7 +5,7 @@ import androidx.test.core.app.ApplicationProvider
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@@ -366,41 +366,23 @@ class RoomTasksDataSourceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
val start = TaskReminder(minutesBefore = 10, fromStart = true)
|
||||||
|
|
||||||
// The whole reminder, not just the minute count: collapsing it to a bare
|
// 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
|
// Int is what fired an imported START-referenced alarm off DUE.
|
||||||
// alarm this seam sets from the UI is always due-referenced.
|
source.setReminders(id, listOf(start, TaskReminder(30)))
|
||||||
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.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
|
||||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
|
|
||||||
|
source.setReminders(id, listOf(TaskReminder(60)))
|
||||||
|
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||||
|
assertThat(source.reminders()[id]).containsExactly(TaskReminder(60))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||||
val id = source.insertTask(form(due = now + 1.days))
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
makeRecurring(id, now)
|
makeRecurring(id, now)
|
||||||
source.setAlarm(id, 30)
|
source.setReminders(id, listOf(TaskReminder(30)))
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
.filter { it.taskId == id }
|
.filter { it.taskId == id }
|
||||||
.first { it.distanceFromCurrent == 1 }
|
.first { it.distanceFromCurrent == 1 }
|
||||||
@@ -411,6 +393,134 @@ class RoomTasksDataSourceTest {
|
|||||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSaveKeepsTheIdentityOfAReminderItDidNotChange() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
|
||||||
|
val original = db.alarms().forTask(id).single()
|
||||||
|
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
|
||||||
|
|
||||||
|
source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60)))
|
||||||
|
|
||||||
|
val rows = db.alarms().forTask(id)
|
||||||
|
val kept = rows.single { it.minutesBefore == 15 }
|
||||||
|
// Same row, same identity on the server, same original text.
|
||||||
|
assertThat(kept.id).isEqualTo(original.id)
|
||||||
|
assertThat(kept.uid).isEqualTo(original.uid)
|
||||||
|
assertThat(kept.raw).contains("X-VENDOR:keep")
|
||||||
|
assertThat(rows.single { it.minutesBefore == 60 }.uid).isNotEqualTo(original.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
|
||||||
|
|
||||||
|
source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45)))
|
||||||
|
|
||||||
|
assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aReminderForOneOccurrenceLeavesTheSeriesUntouched() {
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(due = now + 1.days).copy(listId = work), listOf(TaskReminder(30)))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
val target = source.tasks(TaskQuery(listId = work))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60)))
|
||||||
|
|
||||||
|
// ⚠️ The master is never written, not even briefly: a sync landing in
|
||||||
|
// between would upload the occurrence's reminder for the whole series.
|
||||||
|
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(30)
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
|
||||||
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
|
assertThat(db.alarms().forTask(override.id).map { it.minutesBefore }).containsExactly(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splittingASeriesGivesTheNewSeriesItsOwnReminderIdentities() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(30)))
|
||||||
|
makeRecurring(id, now - 14.days)
|
||||||
|
val master = db.alarms().forTask(id).single()
|
||||||
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
|
.filter { it.taskId == id }
|
||||||
|
.first { it.distanceFromCurrent == 1 }
|
||||||
|
|
||||||
|
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30)))
|
||||||
|
|
||||||
|
assertThat(created).isNotEqualTo(id)
|
||||||
|
val copy = db.alarms().forTask(created).single()
|
||||||
|
assertThat(copy.minutesBefore).isEqualTo(30)
|
||||||
|
// A different UID on the server, so the alarm must not share one either.
|
||||||
|
assertThat(copy.uid).isNotEqualTo(master.uid)
|
||||||
|
assertThat(db.alarms().forTask(id).single().uid).isEqualTo(master.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changingOnlyTheRemindersOwesTheServerAPut() {
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
|
||||||
|
source.setReminders(id, listOf(TaskReminder(15)))
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
||||||
|
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
source.setReminders(id, listOf(TaskReminder(15)))
|
||||||
|
// Nothing changed, so nothing is owed.
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSeriesReminderEditReachesTheOccurrencesThatStillHadTheOldOne() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days), listOf(TaskReminder(15)))
|
||||||
|
makeRecurring(id, now)
|
||||||
|
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||||
|
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
|
||||||
|
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
|
||||||
|
source.setCompletedInstance(id, completed, true)
|
||||||
|
source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5)))
|
||||||
|
|
||||||
|
// Same times and rule, so the series is not reshaped — that drops its overrides.
|
||||||
|
val first = occurrences.first()
|
||||||
|
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
|
||||||
|
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60)))
|
||||||
|
|
||||||
|
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
|
||||||
|
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
|
||||||
|
// A snapshot of the series' reminder follows it…
|
||||||
|
assertThat(leads(completed)).containsExactly(60)
|
||||||
|
// …one set for that occurrence alone does not.
|
||||||
|
assertThat(leads(own)).containsExactly(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTaskWhoseAlarmsWereTurnedOffElsewhereReportsNone() {
|
||||||
|
val id = source.insertTask(form(due = now + 1.days))
|
||||||
|
db.alarms().setRemindersOff(id, true)
|
||||||
|
|
||||||
|
assertThat(source.reminders()[id]).isEmpty()
|
||||||
|
assertThat(source.reminders()).containsKey(id)
|
||||||
|
|
||||||
|
// A reminder of its own again: the flag goes.
|
||||||
|
source.setReminders(id, listOf(TaskReminder(15)))
|
||||||
|
assertThat(db.tasks().entity(id)!!.remindersOff).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingRemindersOwesTheServerAPut() {
|
||||||
|
val work = syncedList()
|
||||||
|
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
|
||||||
|
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
|
||||||
|
|
||||||
|
source.setReminders(id, listOf(TaskReminder(30)))
|
||||||
|
|
||||||
|
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deletingATaskInALocalListRemovesItOutright() {
|
fun deletingATaskInALocalListRemovesItOutright() {
|
||||||
val id = source.insertTask(form())
|
val id = source.insertTask(form())
|
||||||
@@ -439,7 +549,7 @@ class RoomTasksDataSourceTest {
|
|||||||
fun aForkedOccurrenceCarriesTheSeriesReminder() {
|
fun aForkedOccurrenceCarriesTheSeriesReminder() {
|
||||||
val id = source.insertTask(form())
|
val id = source.insertTask(form())
|
||||||
makeRecurring(id, now)
|
makeRecurring(id, now)
|
||||||
source.setAlarm(id, minutesBeforeDue = 30)
|
source.setReminders(id, listOf(TaskReminder(30)))
|
||||||
val target = source.tasks(TaskQuery(listId = listId))
|
val target = source.tasks(TaskQuery(listId = listId))
|
||||||
.filter { it.taskId == id }
|
.filter { it.taskId == id }
|
||||||
.first { it.distanceFromCurrent == 1 }
|
.first { it.distanceFromCurrent == 1 }
|
||||||
@@ -447,7 +557,7 @@ class RoomTasksDataSourceTest {
|
|||||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||||
|
|
||||||
// The fork copies the master's properties, which is what carries the
|
// The fork copies the master's properties, which is what carries the
|
||||||
// reminder across — the repository is what puts the series' own back.
|
// reminder across.
|
||||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||||
}
|
}
|
||||||
@@ -539,4 +649,47 @@ class RoomTasksDataSourceTest {
|
|||||||
|
|
||||||
assertThat(thrown).isNotNull()
|
assertThat(thrown).isNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subtaskIdsNameRowsNotOccurrences() {
|
||||||
|
val parent = source.insertTask(form("parent"))
|
||||||
|
val longAgo = now - 1000.days
|
||||||
|
val outOfWindow = db.tasks().insert(
|
||||||
|
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
|
||||||
|
)
|
||||||
|
val series = db.tasks().insert(
|
||||||
|
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
|
||||||
|
)
|
||||||
|
source.setCompletedInstance(series, now, true)
|
||||||
|
val plain = source.insertTask(form("plain").copy(parentId = parent))
|
||||||
|
|
||||||
|
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingASubtreeEndsOnAParentLoop() {
|
||||||
|
val a = source.insertTask(form("a"))
|
||||||
|
val b = source.insertTask(form("b").copy(parentId = a))
|
||||||
|
val c = source.insertTask(form("c").copy(parentId = b))
|
||||||
|
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
|
||||||
|
|
||||||
|
source.deleteWithSubtasks(a)
|
||||||
|
|
||||||
|
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
|
||||||
|
val start = now
|
||||||
|
val series = db.tasks().insert(
|
||||||
|
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
|
||||||
|
)
|
||||||
|
val child = source.insertTask(form("child").copy(parentId = series))
|
||||||
|
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
|
||||||
|
|
||||||
|
source.deleteFollowing(series, start)
|
||||||
|
|
||||||
|
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
@@ -75,6 +75,93 @@ class TasksDatabaseMigrationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun liftsClaimableAlarmsAndPushesRemindersThatNeverLeftTheDevice() {
|
||||||
|
val future = System.currentTimeMillis() + 7L * 24 * 60 * 60 * 1000
|
||||||
|
val past = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
|
||||||
|
val claimable = "X-FOO:bar\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nX-WR-ALARMUID:srv\r\n" +
|
||||||
|
"TRIGGER;RELATED=END:-PT15M\r\nEND:VALARM\r\n"
|
||||||
|
val appleDefault = "BEGIN:VALARM\r\nACTION:DISPLAY\r\nX-APPLE-DEFAULT-ALARM:TRUE\r\n" +
|
||||||
|
"TRIGGER;RELATED=END:-PT30M\r\nEND:VALARM\r\n"
|
||||||
|
helper.createDatabase(TEST_DB, 2).use { db ->
|
||||||
|
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me')")
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color, account_id) VALUES (1, 'Synced', 0, 1)")
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (2, 'Local', 0)")
|
||||||
|
db.execSQL("INSERT INTO task_lists (id, name, color, account_id, is_read_only) VALUES (3, 'Shared', 0, 1, 1)")
|
||||||
|
fun task(id: Int, list: Int, due: Long, residue: String? = null) = db.execSQL(
|
||||||
|
"INSERT INTO tasks (id, list_id, uid, title, due, unknown_properties) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
arrayOf<Any?>(id, list, "uid-$id", "Task $id", due, residue),
|
||||||
|
)
|
||||||
|
fun alarm(task: Int, minutes: Int) =
|
||||||
|
db.execSQL("INSERT INTO task_alarms (task_id, minutes_before) VALUES (?, ?)", arrayOf<Any?>(task, minutes))
|
||||||
|
// A server alarm, and the same lead copied locally by a fork.
|
||||||
|
task(1, 1, future, claimable)
|
||||||
|
alarm(1, 15)
|
||||||
|
// Set in Agendula, never uploaded — still to fire, already past, device-only.
|
||||||
|
task(2, 1, future)
|
||||||
|
alarm(2, 30)
|
||||||
|
task(3, 1, past)
|
||||||
|
alarm(3, 30)
|
||||||
|
task(4, 2, future)
|
||||||
|
alarm(4, 30)
|
||||||
|
// Somebody else's default alarm: not ours to claim.
|
||||||
|
task(5, 1, future, appleDefault)
|
||||||
|
// A read-only share: dirty would freeze it, never sent and never refreshed.
|
||||||
|
task(6, 3, future)
|
||||||
|
alarm(6, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 3, true, TasksDatabase.MIGRATION_2_3).use { db ->
|
||||||
|
db.query("SELECT task_id, minutes_before, uid, raw FROM task_alarms ORDER BY task_id").use { c ->
|
||||||
|
val rows = buildList { while (c.moveToNext()) add(listOf(c.getLong(0), c.getInt(1), c.getString(2), c.getString(3))) }
|
||||||
|
// One for task 1: the lifted copy won and the local duplicate went.
|
||||||
|
assertThat(rows.map { it[0] }).containsExactly(1L, 2L, 3L, 4L, 6L).inOrder()
|
||||||
|
assertThat(rows[0][2]).isEqualTo("srv")
|
||||||
|
assertThat(rows[0][3] as String).contains("TRIGGER;RELATED=END:-PT15M")
|
||||||
|
// Every pre-existing row got an identity of its own, shaped as a UUID.
|
||||||
|
assertThat(rows.drop(1).map { it[2] as String }.toSet()).hasSize(4)
|
||||||
|
rows.drop(1).forEach {
|
||||||
|
assertThat(it[2] as String).matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||||
|
}
|
||||||
|
assertThat(rows.drop(1).all { (it[2] as String).isNotEmpty() && it[3] == null }).isTrue()
|
||||||
|
}
|
||||||
|
db.query("SELECT id, unknown_properties, is_dirty, last_modified, reminders_off FROM tasks ORDER BY id").use { c ->
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(1)).isEqualTo("X-FOO:bar\r\n")
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
assertThat(c.getInt(4)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(1)
|
||||||
|
// The body changes, so its modification stamp does too.
|
||||||
|
assertThat(c.isNull(3)).isFalse()
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getString(1)).contains("X-APPLE-DEFAULT-ALARM:TRUE")
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
c.moveToNext()
|
||||||
|
assertThat(c.getInt(2)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addsAnEmptyAccountLabel() {
|
||||||
|
helper.createDatabase(TEST_DB, 3).use { db ->
|
||||||
|
db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me@example.org')")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper.runMigrationsAndValidate(TEST_DB, 4, true, TasksDatabase.MIGRATION_3_4).use { db ->
|
||||||
|
db.query("SELECT display_name, label FROM accounts").use { c ->
|
||||||
|
assertThat(c.moveToFirst()).isTrue()
|
||||||
|
assertThat(c.getString(0)).isEqualTo("me@example.org")
|
||||||
|
assertThat(c.isNull(1)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TEST_DB = "migration-test.db"
|
const val TEST_DB = "migration-test.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -11,7 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
@@ -294,23 +294,25 @@ private class FakeExternalStore : TasksDataSource {
|
|||||||
return tasks[listId].orEmpty()
|
return tasks[listId].orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun alarms(): Map<Long, TaskReminder> = alarms
|
override fun reminders(): Map<Long, List<TaskReminder>> = alarms.mapValues { listOf(it.value) }
|
||||||
|
|
||||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||||
override fun task(taskId: Long): Task? = unused()
|
override fun task(taskId: Long): Task? = unused()
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||||
override fun insertTask(form: TaskForm): Long = unused()
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = unused()
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = unused()
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
unused()
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
||||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
||||||
override fun deleteTask(taskId: Long) = unused()
|
override fun deleteTask(taskId: Long) = unused()
|
||||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
||||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, 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 updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
|
unused()
|
||||||
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
|
||||||
|
unused()
|
||||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||||
override fun createLocalList(name: String, color: Int): Long = unused()
|
override fun createLocalList(name: String, color: Int): Long = unused()
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import dagger.multibindings.IntoSet
|
import dagger.multibindings.IntoSet
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.AccountSyncRequests
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||||
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
|
||||||
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.PullSync
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
|
||||||
@@ -79,7 +82,11 @@ abstract class SyncBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
|
abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindManualSync(impl: PullSync): ManualSync
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@IntoSet
|
@IntoSet
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
|||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -35,7 +37,12 @@ interface AccountCreator {
|
|||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
/** An existing account to sign in again, instead of creating one. */
|
/** An existing account to sign in again, instead of creating one. */
|
||||||
reauthenticating: Long? = null,
|
reauthenticating: Long? = null,
|
||||||
|
/** What the user agreed to trust on the way here; saved with the account. */
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
): AccountRepository.Outcome
|
): AccountRepository.Outcome
|
||||||
|
|
||||||
|
/** What [accountId] was set up trusting, so signing in again starts from it. */
|
||||||
|
suspend fun trustFor(accountId: Long): ServerTrust
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +66,7 @@ class AccountRepository @Inject constructor(
|
|||||||
private val quarantine: QuarantineStore,
|
private val quarantine: QuarantineStore,
|
||||||
private val notices: SyncNoticeStore,
|
private val notices: SyncNoticeStore,
|
||||||
private val collectionSupport: CollectionSupportStore,
|
private val collectionSupport: CollectionSupportStore,
|
||||||
|
private val serverTrust: ServerTrustStore,
|
||||||
private val gateway: CalDavGateway,
|
private val gateway: CalDavGateway,
|
||||||
private val availability: SyncAvailability,
|
private val availability: SyncAvailability,
|
||||||
private val settings: SettingsPrefs,
|
private val settings: SettingsPrefs,
|
||||||
@@ -97,6 +105,11 @@ class AccountRepository @Inject constructor(
|
|||||||
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
/** The accounts, observed, so a sync landing updates a screen that is open. */
|
||||||
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
|
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
|
||||||
|
|
||||||
|
/** Names the account on screen; blank goes back to the default (#105). */
|
||||||
|
suspend fun setLabel(accountId: Long, label: String) = withContext(io) {
|
||||||
|
database.accounts().setLabel(accountId, label.trim().ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
/** Every account's synced lists. */
|
/** Every account's synced lists. */
|
||||||
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
fun observeSyncedLists(): Flow<List<TaskListEntity>> = database.taskLists().observeSynced()
|
||||||
|
|
||||||
@@ -115,6 +128,7 @@ class AccountRepository @Inject constructor(
|
|||||||
found: CalDavDiscovery.Outcome.Found,
|
found: CalDavDiscovery.Outcome.Found,
|
||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
reauthenticating: Long?,
|
reauthenticating: Long?,
|
||||||
|
trust: ServerTrust,
|
||||||
): Outcome = withContext(io) {
|
): Outcome = withContext(io) {
|
||||||
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
|
if (!availability.accountsUsable()) return@withContext Outcome.ExternalStorage
|
||||||
// "Sign in again" names its account, so a login name the server spells
|
// "Sign in again" names its account, so a login name the server spells
|
||||||
@@ -122,7 +136,7 @@ class AccountRepository @Inject constructor(
|
|||||||
// Only on the same server: anything else is a different account.
|
// Only on the same server: anything else is a different account.
|
||||||
val target = reauthenticating?.let { database.accounts().account(it) }
|
val target = reauthenticating?.let { database.accounts().account(it) }
|
||||||
if (target != null && sameServer(target, found)) {
|
if (target != null && sameServer(target, found)) {
|
||||||
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected)
|
return@withContext reauthenticate(target, target.displayName, username, appPassword, found, selected, trust)
|
||||||
}
|
}
|
||||||
// Both stores, not just one. There is no unique index on
|
// Both stores, not just one. There is no unique index on
|
||||||
// accounts.display_name and nothing prunes Room when the system account
|
// accounts.display_name and nothing prunes Room when the system account
|
||||||
@@ -139,7 +153,7 @@ class AccountRepository @Inject constructor(
|
|||||||
// same name is still a duplicate, so this can never silently overwrite a
|
// same name is still a duplicate, so this can never silently overwrite a
|
||||||
// working credential.
|
// working credential.
|
||||||
if (existing != null && accountState.needsSignIn(existing.id)) {
|
if (existing != null && accountState.needsSignIn(existing.id)) {
|
||||||
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected)
|
return@withContext reauthenticate(existing, displayName, username, appPassword, found, selected, trust)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing != null) return@withContext Outcome.AlreadyExists
|
if (existing != null) return@withContext Outcome.AlreadyExists
|
||||||
@@ -180,6 +194,15 @@ class AccountRepository @Inject constructor(
|
|||||||
inserted += attach(id, selected)
|
inserted += attach(id, selected)
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
// Inside the rollback's reach: a DataStore failure here would otherwise
|
||||||
|
// leave the row with no credential, the hazard the comment above names.
|
||||||
|
if (!saveTrust(accountId, trust)) {
|
||||||
|
rollback(accountId, inserted)
|
||||||
|
return@withContext Outcome.CredentialFailed(
|
||||||
|
Outcome.Cause.NOT_SAVED,
|
||||||
|
"the server trust could not be stored",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!credentials.put(accountId, appPassword)) {
|
if (!credentials.put(accountId, appPassword)) {
|
||||||
// Never leave a half-made account behind: without a credential it
|
// Never leave a half-made account behind: without a credential it
|
||||||
@@ -207,6 +230,8 @@ class AccountRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun trustFor(accountId: Long): ServerTrust = serverTrust.get(accountId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Points [selected] at [accountId], re-attaching what a previous removal
|
* Points [selected] at [accountId], re-attaching what a previous removal
|
||||||
* left behind rather than inserting a second copy.
|
* left behind rather than inserting a second copy.
|
||||||
@@ -298,6 +323,7 @@ class AccountRepository @Inject constructor(
|
|||||||
val outcome = gateway.discover(
|
val outcome = gateway.discover(
|
||||||
principal.toString(),
|
principal.toString(),
|
||||||
CalDavGateway.Credentials(username, password, principal),
|
CalDavGateway.Credentials(username, password, principal),
|
||||||
|
serverTrust.get(accountId),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
|
is CalDavDiscovery.Outcome.Found -> Collections.Found(outcome.collections, attached)
|
||||||
@@ -306,6 +332,10 @@ class AccountRepository @Inject constructor(
|
|||||||
-> Collections.NeedsSignIn
|
-> Collections.NeedsSignIn
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> Collections.Failed(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> Collections.Failed(outcome.cause)
|
||||||
|
// A certificate that changed since it was trusted. The account offers
|
||||||
|
// "Sign in again" for a certificate failure, and that flow re-asks.
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate ->
|
||||||
|
Collections.Failed(CalDavDiscovery.Outcome.Cause.TLS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +416,7 @@ class AccountRepository @Inject constructor(
|
|||||||
appPassword: String,
|
appPassword: String,
|
||||||
found: CalDavDiscovery.Outcome.Found,
|
found: CalDavDiscovery.Outcome.Found,
|
||||||
selected: Set<TaskCollection>,
|
selected: Set<TaskCollection>,
|
||||||
|
trust: ServerTrust,
|
||||||
): Outcome {
|
): Outcome {
|
||||||
val accountId = existing.id
|
val accountId = existing.id
|
||||||
if (!credentials.put(accountId, appPassword)) {
|
if (!credentials.put(accountId, appPassword)) {
|
||||||
@@ -394,6 +425,11 @@ class AccountRepository @Inject constructor(
|
|||||||
"the device keystore would not store the password",
|
"the device keystore would not store the password",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Before anything else changes, so a failure leaves the account stopped
|
||||||
|
// and the sign-in retryable rather than signed in with the old trust.
|
||||||
|
if (!withContext(NonCancellable) { saveTrust(accountId, trust) }) {
|
||||||
|
return Outcome.CredentialFailed(Outcome.Cause.NOT_SAVED, "the server trust could not be stored")
|
||||||
|
}
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
database.runInTransaction {
|
database.runInTransaction {
|
||||||
database.accounts().update(
|
database.accounts().update(
|
||||||
@@ -437,11 +473,23 @@ class AccountRepository @Inject constructor(
|
|||||||
* and holds the user's tasks; `SET NULL` returns it to being device-only,
|
* and holds the user's tasks; `SET NULL` returns it to being device-only,
|
||||||
* which is exactly where it came from.
|
* which is exactly where it came from.
|
||||||
*/
|
*/
|
||||||
private fun rollback(accountId: Long, inserted: List<Long>) = database.runInTransaction {
|
private suspend fun rollback(accountId: Long, inserted: List<Long>) {
|
||||||
inserted.forEach { database.taskLists().delete(it) }
|
database.runInTransaction {
|
||||||
database.accounts().delete(accountId)
|
inserted.forEach { database.taskLists().delete(it) }
|
||||||
|
database.accounts().delete(accountId)
|
||||||
|
}
|
||||||
|
// Ids are never reused, and a later create overwrites the keys anyway.
|
||||||
|
runCatching { serverTrust.forget(accountId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun saveTrust(accountId: Long, trust: ServerTrust): Boolean =
|
||||||
|
try {
|
||||||
|
serverTrust.put(accountId, trust)
|
||||||
|
true
|
||||||
|
} catch (_: IOException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts every existing account back on the periodic schedule.
|
* Puts every existing account back on the periodic schedule.
|
||||||
*
|
*
|
||||||
@@ -530,6 +578,7 @@ class AccountRepository @Inject constructor(
|
|||||||
// The same reasoning, for what the server said it would let us
|
// The same reasoning, for what the server said it would let us
|
||||||
// create: keyed by an id nothing will ever mention again.
|
// create: keyed by an id nothing will ever mention again.
|
||||||
collectionSupport.forget(accountId)
|
collectionSupport.forget(accountId)
|
||||||
|
serverTrust.forget(accountId)
|
||||||
credentials.clear(accountId)
|
credentials.clear(accountId)
|
||||||
database.accounts().delete(accountId)
|
database.accounts().delete(accountId)
|
||||||
accounts.find(displayName)?.let { accounts.remove(it) }
|
accounts.find(displayName)?.let { accounts.remove(it) }
|
||||||
@@ -557,6 +606,7 @@ class AccountRepository @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
gateway.revokeAppPassword(
|
gateway.revokeAppPassword(
|
||||||
CalDavGateway.Credentials(username, password, origin),
|
CalDavGateway.Credentials(username, password, origin),
|
||||||
|
serverTrust.get(accountId),
|
||||||
)
|
)
|
||||||
} catch (_: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
// Deliberately swallowed. If the caller went away mid-revoke we still
|
// Deliberately swallowed. If the caller went away mid-revoke we still
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** [SyncRequests] by account id; [SyncTrigger] keys on the account's stable display name. */
|
||||||
|
class AccountSyncRequests @Inject constructor(
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
private val trigger: SyncTrigger,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : SyncRequests {
|
||||||
|
|
||||||
|
override suspend fun syncNow(accountId: Long) {
|
||||||
|
val name = withContext(io) { database.accounts().account(accountId)?.displayName } ?: return
|
||||||
|
trigger.enqueue(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.caldav.AppPassword
|
import de.jeanlucmakiola.caldav.AppPassword
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.CalDavHttp
|
import de.jeanlucmakiola.caldav.CalDavHttp
|
||||||
import de.jeanlucmakiola.caldav.DnsJavaResolver
|
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -25,12 +26,22 @@ import javax.inject.Singleton
|
|||||||
interface CalDavGateway {
|
interface CalDavGateway {
|
||||||
|
|
||||||
/** Discovery against [target], optionally carrying credentials. */
|
/** Discovery against [target], optionally carrying credentials. */
|
||||||
suspend fun discover(target: String, credentials: Credentials? = null): CalDavDiscovery.Outcome
|
suspend fun discover(
|
||||||
|
target: String,
|
||||||
|
credentials: Credentials? = null,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): CalDavDiscovery.Outcome
|
||||||
|
|
||||||
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
|
/** Starts Nextcloud Login Flow v2, or returns null if this is not a Nextcloud. */
|
||||||
suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow?
|
suspend fun startLoginFlow(
|
||||||
|
server: HttpUrl,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): NextcloudLoginFlow.Flow?
|
||||||
|
|
||||||
suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult
|
suspend fun pollLoginFlow(
|
||||||
|
flow: NextcloudLoginFlow.Flow,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): NextcloudLoginFlow.PollResult
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hands an app password back to the server, best effort.
|
* Hands an app password back to the server, best effort.
|
||||||
@@ -38,7 +49,10 @@ interface CalDavGateway {
|
|||||||
* Without it, uninstalling never revokes anything — the credential we minted
|
* Without it, uninstalling never revokes anything — the credential we minted
|
||||||
* outlives the app in the user's device list.
|
* outlives the app in the user's device list.
|
||||||
*/
|
*/
|
||||||
suspend fun revokeAppPassword(credentials: Credentials): Boolean
|
suspend fun revokeAppPassword(
|
||||||
|
credentials: Credentials,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same, for a password the login flow just minted.
|
* The same, for a password the login flow just minted.
|
||||||
@@ -49,7 +63,10 @@ interface CalDavGateway {
|
|||||||
* principal, and a subpath install's `https://host/nextcloud/` would collapse
|
* principal, and a subpath install's `https://host/nextcloud/` would collapse
|
||||||
* to `https://host/` — a DELETE that 404s on every one of them.
|
* to `https://host/` — a DELETE that 404s on every one of them.
|
||||||
*/
|
*/
|
||||||
suspend fun revokeIssuedAppPassword(credentials: Credentials): Boolean
|
suspend fun revokeIssuedAppPassword(
|
||||||
|
credentials: Credentials,
|
||||||
|
trust: ServerTrust = ServerTrust.DEFAULT,
|
||||||
|
): Boolean
|
||||||
|
|
||||||
/** Credentials, and the origin whose registrable domain they are scoped to. */
|
/** Credentials, and the origin whose registrable domain they are scoped to. */
|
||||||
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
|
data class Credentials(val username: String, val password: String, val origin: HttpUrl)
|
||||||
@@ -71,16 +88,19 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
override suspend fun discover(
|
override suspend fun discover(
|
||||||
target: String,
|
target: String,
|
||||||
credentials: CalDavGateway.Credentials?,
|
credentials: CalDavGateway.Credentials?,
|
||||||
|
trust: ServerTrust,
|
||||||
): CalDavDiscovery.Outcome = withContext(io) {
|
): CalDavDiscovery.Outcome = withContext(io) {
|
||||||
val client = credentials?.let {
|
val client = credentials?.let {
|
||||||
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
|
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin, trust)
|
||||||
} ?: CalDavHttp.anonymous(userAgent)
|
} ?: CalDavHttp.anonymous(userAgent, trust)
|
||||||
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
|
val job = coroutineContext.job
|
||||||
|
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }, allowCleartext = trust.allowCleartext)
|
||||||
|
.discover(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
|
override suspend fun startLoginFlow(server: HttpUrl, trust: ServerTrust): NextcloudLoginFlow.Flow? =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent), userAgent)
|
NextcloudLoginFlow(CalDavHttp.anonymous(userAgent, trust), userAgent)
|
||||||
.start(server, now())
|
.start(server, now())
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -92,9 +112,12 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
* the shared client's ceiling is sized for a multiget, not for a two-second
|
* 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.
|
* poll loop against a server that answered a moment ago.
|
||||||
*/
|
*/
|
||||||
override suspend fun pollLoginFlow(flow: NextcloudLoginFlow.Flow): NextcloudLoginFlow.PollResult =
|
override suspend fun pollLoginFlow(
|
||||||
|
flow: NextcloudLoginFlow.Flow,
|
||||||
|
trust: ServerTrust,
|
||||||
|
): NextcloudLoginFlow.PollResult =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val client = CalDavHttp.anonymous(userAgent).newBuilder()
|
val client = CalDavHttp.anonymous(userAgent, trust).newBuilder()
|
||||||
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.callTimeout(POLL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
NextcloudLoginFlow(client, userAgent).poll(flow, now())
|
NextcloudLoginFlow(client, userAgent).poll(flow, now())
|
||||||
@@ -102,18 +125,20 @@ class OkHttpCalDavGateway @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun revokeAppPassword(
|
override suspend fun revokeAppPassword(
|
||||||
credentials: CalDavGateway.Credentials,
|
credentials: CalDavGateway.Credentials,
|
||||||
|
trust: ServerTrust,
|
||||||
): Boolean = withContext(io) {
|
): Boolean = withContext(io) {
|
||||||
val client = CalDavHttp.authenticated(
|
val client = CalDavHttp.authenticated(
|
||||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
userAgent, credentials.username, credentials.password, credentials.origin, trust,
|
||||||
)
|
)
|
||||||
AppPassword.revoke(client, credentials.origin)
|
AppPassword.revoke(client, credentials.origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun revokeIssuedAppPassword(
|
override suspend fun revokeIssuedAppPassword(
|
||||||
credentials: CalDavGateway.Credentials,
|
credentials: CalDavGateway.Credentials,
|
||||||
|
trust: ServerTrust,
|
||||||
): Boolean = withContext(io) {
|
): Boolean = withContext(io) {
|
||||||
val client = CalDavHttp.authenticated(
|
val client = CalDavHttp.authenticated(
|
||||||
userAgent, credentials.username, credentials.password, credentials.origin,
|
userAgent, credentials.username, credentials.password, credentials.origin, trust,
|
||||||
)
|
)
|
||||||
AppPassword.revokeAt(client, credentials.origin)
|
AppPassword.revokeAt(client, credentials.origin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ical.AlarmCodec
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
|
import de.jeanlucmakiola.agendula.data.tasks.ical.ResourceValidator
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
@@ -422,8 +424,9 @@ class CollectionSyncer(
|
|||||||
|
|
||||||
/** Null when the resource was rejected before it left the device. */
|
/** Null when the resource was rejected before it left the device. */
|
||||||
private fun serialize(local: LocalResource): String? {
|
private fun serialize(local: LocalResource): String? {
|
||||||
|
val alarms = store.alarmsFor(local.live.map { it.id })
|
||||||
val todos = local.live.map { row ->
|
val todos = local.live.map { row ->
|
||||||
VTodoMapper.write(row, parentUidOf(row), now())
|
VTodoMapper.write(row, parentUidOf(row), now(), alarms[row.id].orEmpty())
|
||||||
}
|
}
|
||||||
val calendar = CalendarResource.build(todos)
|
val calendar = CalendarResource.build(todos)
|
||||||
|
|
||||||
@@ -778,14 +781,17 @@ class CollectionSyncer(
|
|||||||
val losing = existing.values.firstOrNull { it.isDirty }
|
val losing = existing.values.firstOrNull { it.isDirty }
|
||||||
|
|
||||||
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
|
val master = mapped.firstOrNull { it.entity.recurrenceId == null } ?: mapped.first()
|
||||||
val masterRow = upsert(master.entity, existing[master.entity.recurrenceId], null, href, eTag)
|
val masterRow = upsert(master, existing[master.entity.recurrenceId], null, href, eTag)
|
||||||
val masterId = masterRow.id
|
val masterId = masterRow.id
|
||||||
parents[uid] = master.parentUid
|
parents[uid] = master.parentUid
|
||||||
|
|
||||||
val written = mutableListOf(masterRow)
|
val written = mutableListOf(masterRow)
|
||||||
mapped.filter { it !== master }.forEach { override ->
|
mapped.filter { it !== master }.forEach { override ->
|
||||||
|
// ⚠️ An override's alarms are its own: RFC 5545 has an override
|
||||||
|
// replace the component, so one the server sends without a
|
||||||
|
// VALARM has none, whatever its master carries.
|
||||||
written += upsert(
|
written += upsert(
|
||||||
override.entity, existing[override.entity.recurrenceId], masterId, href, eTag,
|
override, existing[override.entity.recurrenceId], masterId, href, eTag,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val kept = written.map { it.recurrenceId }.toSet()
|
val kept = written.map { it.recurrenceId }.toSet()
|
||||||
@@ -832,12 +838,13 @@ class CollectionSyncer(
|
|||||||
|
|
||||||
/** @return the row as it now stands, so the caller can index it. */
|
/** @return the row as it now stands, so the caller can index it. */
|
||||||
private fun upsert(
|
private fun upsert(
|
||||||
incoming: TaskEntity,
|
mapped: VTodoMapper.Mapped,
|
||||||
existing: TaskEntity?,
|
existing: TaskEntity?,
|
||||||
masterId: Long?,
|
masterId: Long?,
|
||||||
href: String,
|
href: String,
|
||||||
eTag: String?,
|
eTag: String?,
|
||||||
): TaskEntity {
|
): TaskEntity {
|
||||||
|
val incoming = mapped.entity
|
||||||
// Local-only columns the server has no opinion about. Taking the
|
// Local-only columns the server has no opinion about. Taking the
|
||||||
// mapper's defaults here would silently reset the user's ordering and
|
// mapper's defaults here would silently reset the user's ordering and
|
||||||
// per-task colour on every download.
|
// per-task colour on every download.
|
||||||
@@ -854,12 +861,42 @@ class CollectionSyncer(
|
|||||||
// set uploads what was just downloaded.
|
// set uploads what was just downloaded.
|
||||||
isDirty = false,
|
isDirty = false,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
|
// The store's to change, from what the alarms do.
|
||||||
|
remindersOff = existing?.remindersOff ?: false,
|
||||||
)
|
)
|
||||||
return if (existing == null) {
|
// The server's alarms replace ours, as every other field does: a
|
||||||
row.copy(id = store.insert(row))
|
// dirty row is never downloaded except to discard its edit.
|
||||||
} else {
|
val alarms = existing?.let { keepLocalPrecision(mapped.alarms, it, row) } ?: mapped.alarms
|
||||||
store.update(row)
|
return row.copy(id = store.upsertWithAlarms(row, alarms))
|
||||||
row
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [incoming] with each alarm we uploaded ourselves put back to the lead
|
||||||
|
* we hold for it.
|
||||||
|
*
|
||||||
|
* ⚠️ The wire is coarser than the row: a lead past the end of its
|
||||||
|
* anchor is clamped, and a before-start reminder on a task without a
|
||||||
|
* start goes out before-due. Taking the round trip at its word would
|
||||||
|
* quietly turn a before-start reminder into a before-due one the first
|
||||||
|
* time the task is downloaded again.
|
||||||
|
* An alarm that comes back as exactly what we would have sent has not
|
||||||
|
* been changed, so the row's own lead stands.
|
||||||
|
*/
|
||||||
|
private fun keepLocalPrecision(
|
||||||
|
incoming: List<TaskAlarmEntity>,
|
||||||
|
existing: TaskEntity,
|
||||||
|
task: TaskEntity,
|
||||||
|
): List<TaskAlarmEntity> {
|
||||||
|
val local = store.alarmsFor(listOf(existing.id))[existing.id].orEmpty()
|
||||||
|
if (local.isEmpty()) return incoming
|
||||||
|
return incoming.map { alarm ->
|
||||||
|
val mine = local.firstOrNull { it.uid == alarm.uid } ?: return@map alarm
|
||||||
|
val sent = AlarmCodec.wireLead(mine, task)
|
||||||
|
if (sent == AlarmCodec.Lead(alarm.minutesBefore, alarm.reference)) {
|
||||||
|
alarm.copy(minutesBefore = mine.minutesBefore, reference = mine.reference)
|
||||||
|
} else {
|
||||||
|
alarm
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -23,8 +24,8 @@ import javax.inject.Singleton
|
|||||||
*/
|
*/
|
||||||
interface LoginFlowRecord {
|
interface LoginFlowRecord {
|
||||||
|
|
||||||
/** Called **before** the browser is handed the URL. */
|
/** Called **before** the browser is handed the URL; [trust] is what reaching it took. */
|
||||||
suspend fun remember(flow: NextcloudLoginFlow.Flow)
|
suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust = ServerTrust.DEFAULT)
|
||||||
|
|
||||||
/** The flow is over, however it ended. */
|
/** The flow is over, however it ended. */
|
||||||
suspend fun forget()
|
suspend fun forget()
|
||||||
@@ -58,12 +59,13 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
private var reclaimed = false
|
private var reclaimed = false
|
||||||
|
|
||||||
/** Records [flow] so a process that dies mid-approval can still finish with it. */
|
/** Records [flow] so a process that dies mid-approval can still finish with it. */
|
||||||
override suspend fun remember(flow: NextcloudLoginFlow.Flow) {
|
override suspend fun remember(flow: NextcloudLoginFlow.Flow, trust: ServerTrust) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
prefs[LOGIN_URL] = flow.loginUrl.toString()
|
prefs[LOGIN_URL] = flow.loginUrl.toString()
|
||||||
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
|
prefs[POLL_ENDPOINT] = flow.pollEndpoint.toString()
|
||||||
prefs[POLL_TOKEN] = flow.pollToken
|
prefs[POLL_TOKEN] = flow.pollToken
|
||||||
prefs[DEADLINE] = flow.deadlineEpochSeconds
|
prefs[DEADLINE] = flow.deadlineEpochSeconds
|
||||||
|
TRUST.write(prefs, trust)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
prefs.remove(POLL_ENDPOINT)
|
prefs.remove(POLL_ENDPOINT)
|
||||||
prefs.remove(POLL_TOKEN)
|
prefs.remove(POLL_TOKEN)
|
||||||
prefs.remove(DEADLINE)
|
prefs.remove(DEADLINE)
|
||||||
|
TRUST.remove(prefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +101,8 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
reclaimed = true
|
reclaimed = true
|
||||||
}
|
}
|
||||||
val flow = pending() ?: return
|
val flow = pending() ?: return
|
||||||
when (val result = gateway.pollLoginFlow(flow)) {
|
val trust = trust()
|
||||||
|
when (val result = gateway.pollLoginFlow(flow, trust)) {
|
||||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||||
// Cleared first: a revocation that fails must not leave a token
|
// Cleared first: a revocation that fails must not leave a token
|
||||||
// that would be polled again, and the 200 is already spent.
|
// that would be polled again, and the 200 is already spent.
|
||||||
@@ -109,6 +113,7 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
password = result.credentials.appPassword,
|
password = result.credentials.appPassword,
|
||||||
origin = result.credentials.server,
|
origin = result.credentials.server,
|
||||||
),
|
),
|
||||||
|
trust,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +141,13 @@ class PendingLoginFlowStore @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun trust(): ServerTrust = TRUST.read(dataStore.data.first())
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val LOGIN_URL = stringPreferencesKey("login_flow_url")
|
val LOGIN_URL = stringPreferencesKey("login_flow_url")
|
||||||
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
|
val POLL_ENDPOINT = stringPreferencesKey("login_flow_poll_endpoint")
|
||||||
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
|
val POLL_TOKEN = stringPreferencesKey("login_flow_poll_token")
|
||||||
val DEADLINE = longPreferencesKey("login_flow_deadline")
|
val DEADLINE = longPreferencesKey("login_flow_deadline")
|
||||||
|
val TRUST = ServerTrustKeys("login_flow_trusted_certificates", "login_flow_cleartext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull to sync over [SyncTrigger]: an expedited sync per account, and
|
||||||
|
* [syncing] held until they finish.
|
||||||
|
*
|
||||||
|
* A sync waiting on its network constraint never starts, so if none is running
|
||||||
|
* after [START_GRACE] the indicator lets go; the work stays queued and runs
|
||||||
|
* once the device is back online.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PullSync @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val accounts: AccountRepository,
|
||||||
|
private val trigger: SyncTrigger,
|
||||||
|
availability: SyncAvailability,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
) : ManualSync {
|
||||||
|
|
||||||
|
override val available: Flow<Boolean> =
|
||||||
|
combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() }
|
||||||
|
|
||||||
|
private val _syncing = MutableStateFlow(false)
|
||||||
|
override val syncing: StateFlow<Boolean> = _syncing
|
||||||
|
|
||||||
|
override fun syncAll() {
|
||||||
|
if (_syncing.value) return
|
||||||
|
_syncing.value = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) }
|
||||||
|
if (names.isNotEmpty()) awaitSyncs(names)
|
||||||
|
} finally {
|
||||||
|
_syncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitSyncs(names: List<String>) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos ->
|
||||||
|
infos.flatMap { it }.map { it.state }
|
||||||
|
}
|
||||||
|
withTimeoutOrNull(MAX_WAIT) {
|
||||||
|
val started = withTimeoutOrNull(START_GRACE) {
|
||||||
|
states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } }
|
||||||
|
} ?: return@withTimeoutOrNull
|
||||||
|
if (started.all { it.isFinished }) return@withTimeoutOrNull
|
||||||
|
states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val START_GRACE = 3.seconds
|
||||||
|
val MAX_WAIT = 2.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.caldav.CollectionOutcome
|
|||||||
import de.jeanlucmakiola.caldav.CollectionSupport
|
import de.jeanlucmakiola.caldav.CollectionSupport
|
||||||
import de.jeanlucmakiola.caldav.DavCollectionAdmin
|
import de.jeanlucmakiola.caldav.DavCollectionAdmin
|
||||||
import de.jeanlucmakiola.caldav.ResourceNames
|
import de.jeanlucmakiola.caldav.ResourceNames
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -53,6 +54,7 @@ class RemoteListRepository @Inject constructor(
|
|||||||
private val accountState: AccountStateStore,
|
private val accountState: AccountStateStore,
|
||||||
private val resolver: ProviderResolver,
|
private val resolver: ProviderResolver,
|
||||||
private val push: PushStore,
|
private val push: PushStore,
|
||||||
|
private val trust: ServerTrustStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : RemoteLists {
|
) : RemoteLists {
|
||||||
|
|
||||||
@@ -252,11 +254,11 @@ class RemoteListRepository @Inject constructor(
|
|||||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
|
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value
|
||||||
?: return null
|
?: return null
|
||||||
return DavCollectionAdmin(client(username, password, origin))
|
return DavCollectionAdmin(client(username, password, origin, trust.get(account.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun client(username: String, password: String, origin: HttpUrl): OkHttpClient =
|
private fun client(username: String, password: String, origin: HttpUrl, trust: ServerTrust): OkHttpClient =
|
||||||
CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust)
|
||||||
|
|
||||||
private suspend fun forgetPerListState(listId: Long) {
|
private suspend fun forgetPerListState(listId: Long) {
|
||||||
val ids = setOf(listId)
|
val ids = setOf(listId)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one mapping between a [ServerTrust] and the key-value stores it is kept
|
||||||
|
* in, so a field added to it cannot be dropped by one of them.
|
||||||
|
*/
|
||||||
|
class ServerTrustKeys(private val certificatesName: String, private val cleartextName: String) {
|
||||||
|
|
||||||
|
private val certificates = stringSetPreferencesKey(certificatesName)
|
||||||
|
private val cleartext = booleanPreferencesKey(cleartextName)
|
||||||
|
|
||||||
|
fun read(prefs: Preferences) = ServerTrust(
|
||||||
|
pinnedCertificates = prefs[certificates].orEmpty(),
|
||||||
|
allowCleartext = prefs[cleartext] ?: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun write(prefs: MutablePreferences, trust: ServerTrust) {
|
||||||
|
if (trust.pinnedCertificates.isEmpty()) {
|
||||||
|
prefs.remove(certificates)
|
||||||
|
} else {
|
||||||
|
prefs[certificates] = trust.pinnedCertificates
|
||||||
|
}
|
||||||
|
if (trust.allowCleartext) prefs[cleartext] = true else prefs.remove(cleartext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(prefs: MutablePreferences) = write(prefs, ServerTrust.DEFAULT)
|
||||||
|
|
||||||
|
fun read(handle: SavedStateHandle) = ServerTrust(
|
||||||
|
pinnedCertificates = handle.get<ArrayList<String>>(certificatesName).orEmpty().toSet(),
|
||||||
|
allowCleartext = handle[cleartextName] ?: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun write(handle: SavedStateHandle, trust: ServerTrust) {
|
||||||
|
handle[certificatesName] = ArrayList(trust.pinnedCertificates)
|
||||||
|
handle[cleartextName] = trust.allowCleartext
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** What each account's user agreed to trust: pinned certificates and plain http. */
|
||||||
|
@Singleton
|
||||||
|
class ServerTrustStore @Inject constructor(
|
||||||
|
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun get(accountId: Long): ServerTrust = read(dataStore.data.first(), accountId)
|
||||||
|
|
||||||
|
fun observe(accountId: Long): Flow<ServerTrust> =
|
||||||
|
dataStore.data.map { read(it, accountId) }.distinctUntilChanged()
|
||||||
|
|
||||||
|
suspend fun put(accountId: Long, trust: ServerTrust) {
|
||||||
|
dataStore.edit { keys(accountId).write(it, trust) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun forget(accountId: Long) {
|
||||||
|
dataStore.edit { keys(accountId).remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun read(prefs: Preferences, accountId: Long) = keys(accountId).read(prefs)
|
||||||
|
|
||||||
|
private fun keys(accountId: Long) =
|
||||||
|
ServerTrustKeys("server_trust_certs_$accountId", "server_trust_cleartext_$accountId")
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class SyncEngine @Inject constructor(
|
|||||||
private val notices: SyncNoticeStore,
|
private val notices: SyncNoticeStore,
|
||||||
private val availability: SyncAvailability,
|
private val availability: SyncAvailability,
|
||||||
private val push: PushRegistrar,
|
private val push: PushRegistrar,
|
||||||
|
private val trust: ServerTrustStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ class SyncEngine @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
val client = CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id))
|
||||||
val subscriptions = push.subscriptionsByHref(account.id)
|
val subscriptions = push.subscriptionsByHref(account.id)
|
||||||
val reports = syncCollections(account) { url ->
|
val reports = syncCollections(account) { url ->
|
||||||
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
|
CalendarCollection(client, url, pushRegistration = subscriptions[url.toString()])
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ data class SyncFailure(val kind: Kind, val httpCode: Int? = null) {
|
|||||||
private val TLS = listOf(
|
private val TLS = listOf(
|
||||||
"SSLHandshakeException",
|
"SSLHandshakeException",
|
||||||
"SSLPeerUnverifiedException",
|
"SSLPeerUnverifiedException",
|
||||||
|
"UntrustedHostException",
|
||||||
"CertPathValidatorException",
|
"CertPathValidatorException",
|
||||||
"CertificateException",
|
"CertificateException",
|
||||||
"SSLException",
|
"SSLException",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.sync
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -11,17 +12,33 @@ import javax.inject.Inject
|
|||||||
* discovery: the reconciliation above this interface is where local edits are
|
* discovery: the reconciliation above this interface is where local edits are
|
||||||
* discarded, tombstones swept and conflicts resolved, and every one of those is
|
* 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
|
* a decision that should be provable without a device. Room's test double is
|
||||||
* Robolectric plus an in-memory database; this is eight methods.
|
* Robolectric plus an in-memory database; this is a dozen methods.
|
||||||
*/
|
*/
|
||||||
interface SyncStore {
|
interface SyncStore {
|
||||||
|
|
||||||
/** Every row in a list, **tombstones included**. */
|
/** Every row in a list, **tombstones included**. */
|
||||||
fun rowsIn(listId: Long): List<TaskEntity>
|
fun rowsIn(listId: Long): List<TaskEntity>
|
||||||
|
|
||||||
fun insert(row: TaskEntity): Long
|
|
||||||
|
|
||||||
fun update(row: TaskEntity)
|
fun update(row: TaskEntity)
|
||||||
|
|
||||||
|
/** The reminders of [taskIds] — one resource's rows — by task id, for the body that uploads them. */
|
||||||
|
fun alarmsFor(taskIds: List<Long>): Map<Long, List<TaskAlarmEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a downloaded row — inserted when its id is 0 — and makes its
|
||||||
|
* reminders exactly [alarms], in one transaction.
|
||||||
|
*
|
||||||
|
* ⚠️ One transaction, not an [update] and a second call. The row lands clean
|
||||||
|
* with the server's ETag, so if the process died between the two, nothing
|
||||||
|
* would ever download it again and the reminders would stay stale for good.
|
||||||
|
*
|
||||||
|
* A row that is gone by the time it is written — deleted on the device
|
||||||
|
* while the sync ran — stays gone, reminders and all.
|
||||||
|
*
|
||||||
|
* @return the row's id.
|
||||||
|
*/
|
||||||
|
fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long
|
||||||
|
|
||||||
fun deleteAll(taskIds: List<Long>)
|
fun deleteAll(taskIds: List<Long>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,12 +73,26 @@ class RoomSyncStore @Inject constructor(
|
|||||||
|
|
||||||
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
override fun rowsIn(listId: Long) = database.tasks().allIn(listId)
|
||||||
|
|
||||||
override fun insert(row: TaskEntity) = database.tasks().insert(row)
|
|
||||||
|
|
||||||
override fun update(row: TaskEntity) {
|
override fun update(row: TaskEntity) {
|
||||||
database.tasks().update(row)
|
database.tasks().update(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun alarmsFor(taskIds: List<Long>) =
|
||||||
|
if (taskIds.isEmpty()) emptyMap() else database.alarms().forTasks(taskIds).groupBy { it.taskId }
|
||||||
|
|
||||||
|
override fun upsertWithAlarms(row: TaskEntity, alarms: List<TaskAlarmEntity>): Long =
|
||||||
|
database.runInTransaction<Long> {
|
||||||
|
if (row.id == 0L) {
|
||||||
|
database.tasks().insert(row).also { database.alarms().replaceFromServer(it, alarms) }
|
||||||
|
} else {
|
||||||
|
// ⚠️ Zero rows means the row was deleted under us. Its alarms
|
||||||
|
// would then name a task that does not exist, and the foreign
|
||||||
|
// key would fail the whole transaction.
|
||||||
|
if (database.tasks().update(row) > 0) database.alarms().replaceFromServer(row.id, alarms)
|
||||||
|
row.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteAll(taskIds: List<Long>) {
|
override fun deleteAll(taskIds: List<Long>) {
|
||||||
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
if (taskIds.isNotEmpty()) database.tasks().deleteAll(taskIds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@Singleton
|
@Singleton
|
||||||
class SyncTrigger @Inject constructor(
|
class SyncTrigger @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) : SyncRequests {
|
) {
|
||||||
|
|
||||||
override fun syncNow(accountName: String) {
|
|
||||||
enqueue(accountName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a sync now.
|
* Starts a sync now.
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import android.net.DnsResolver as PlatformDnsResolver
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.Looper
|
||||||
|
import de.jeanlucmakiola.caldav.DnsAnswers
|
||||||
|
import de.jeanlucmakiola.caldav.DnsRecords
|
||||||
|
import de.jeanlucmakiola.caldav.DnsResolver
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SRV/TXT lookups through the system resolver, so discovery uses the current
|
||||||
|
* network's DNS and honours Private DNS rather than sending plain queries of
|
||||||
|
* its own. A failed or timed-out lookup reads as no records.
|
||||||
|
*
|
||||||
|
* The platform delivers answers through the main looper, so this must run on a
|
||||||
|
* worker thread; waiting on the main one would deadlock until the timeout.
|
||||||
|
* The wait gives up as soon as [isCancelled] says so, and answered lookups are
|
||||||
|
* kept briefly so the authenticated walk does not ask again.
|
||||||
|
*/
|
||||||
|
class SystemDnsResolver(
|
||||||
|
private val isCancelled: () -> Boolean = { false },
|
||||||
|
) : DnsResolver {
|
||||||
|
|
||||||
|
override fun lookup(name: String): DnsRecords {
|
||||||
|
check(!Looper.getMainLooper().isCurrentThread) { "DNS lookup on the main thread" }
|
||||||
|
val now = System.nanoTime()
|
||||||
|
cache[name]?.takeIf { now - it.at < CACHE_NANOS }?.let { return it.records }
|
||||||
|
val srv = Query(name, DnsAnswers.TYPE_SRV)
|
||||||
|
val txt = Query(name, DnsAnswers.TYPE_TXT)
|
||||||
|
val answered = try {
|
||||||
|
val deadline = now + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
|
||||||
|
srv.await(deadline, isCancelled) && txt.await(deadline, isCancelled)
|
||||||
|
} finally {
|
||||||
|
srv.cancel()
|
||||||
|
txt.cancel()
|
||||||
|
}
|
||||||
|
val records = DnsRecords(
|
||||||
|
srv.answer()?.let(DnsAnswers::srv).orEmpty(),
|
||||||
|
txt.answer()?.let(DnsAnswers::txt).orEmpty(),
|
||||||
|
)
|
||||||
|
if (answered) cache[name] = Cached(now, records)
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Cached(val at: Long, val records: DnsRecords)
|
||||||
|
|
||||||
|
private class Query(name: String, type: Int) {
|
||||||
|
private val done = CountDownLatch(1)
|
||||||
|
private val signal = CancellationSignal()
|
||||||
|
private val result = AtomicReference<ByteArray?>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
PlatformDnsResolver.getInstance().rawQuery(
|
||||||
|
null,
|
||||||
|
name,
|
||||||
|
PlatformDnsResolver.CLASS_IN,
|
||||||
|
type,
|
||||||
|
PlatformDnsResolver.FLAG_EMPTY,
|
||||||
|
Runnable::run,
|
||||||
|
signal,
|
||||||
|
object : PlatformDnsResolver.Callback<ByteArray> {
|
||||||
|
override fun onAnswer(answer: ByteArray, rcode: Int) {
|
||||||
|
result.set(answer)
|
||||||
|
done.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(error: PlatformDnsResolver.DnsException) {
|
||||||
|
done.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
done.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the query finished before [deadline] and before [isCancelled]. */
|
||||||
|
fun await(deadline: Long, isCancelled: () -> Boolean): Boolean {
|
||||||
|
while (!isCancelled()) {
|
||||||
|
val left = deadline - System.nanoTime()
|
||||||
|
if (left <= 0) return false
|
||||||
|
if (done.await(minOf(left, SLICE_NANOS), TimeUnit.NANOSECONDS)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() = signal.cancel()
|
||||||
|
|
||||||
|
fun answer(): ByteArray? = result.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TIMEOUT_SECONDS = 10L
|
||||||
|
val SLICE_NANOS = TimeUnit.MILLISECONDS.toNanos(100)
|
||||||
|
val CACHE_NANOS = TimeUnit.MINUTES.toNanos(1)
|
||||||
|
val cache = ConcurrentHashMap<String, Cached>()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
|
import de.jeanlucmakiola.agendula.data.sync.CredentialStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncReport
|
import de.jeanlucmakiola.agendula.data.sync.SyncReport
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
@@ -48,6 +49,7 @@ class PushRegistrar @Inject constructor(
|
|||||||
private val credentials: CredentialStore,
|
private val credentials: CredentialStore,
|
||||||
private val accountState: AccountStateStore,
|
private val accountState: AccountStateStore,
|
||||||
private val availability: SyncAvailability,
|
private val availability: SyncAvailability,
|
||||||
|
private val trust: ServerTrustStore,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -231,7 +233,7 @@ class PushRegistrar @Inject constructor(
|
|||||||
val username = account.username ?: return null
|
val username = account.username ?: return null
|
||||||
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
val origin = account.principalUrl?.toHttpUrlOrNull() ?: return null
|
||||||
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
|
val password = (credentials.get(account.id) as? CredentialStore.Secret.Present)?.value ?: return null
|
||||||
return CalDavHttp.authenticated(USER_AGENT, username, password, origin)
|
return CalDavHttp.authenticated(USER_AGENT, username, password, origin, trust.get(account.id))
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import androidx.compose.material.icons.rounded.Check
|
|||||||
import androidx.compose.material.icons.rounded.Bolt
|
import androidx.compose.material.icons.rounded.Bolt
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
import androidx.compose.material.icons.rounded.CloudSync
|
||||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
|
import androidx.compose.material.icons.rounded.GppMaybe
|
||||||
import androidx.compose.material.icons.rounded.History
|
import androidx.compose.material.icons.rounded.History
|
||||||
import androidx.compose.material.icons.rounded.HistoryToggleOff
|
import androidx.compose.material.icons.rounded.HistoryToggleOff
|
||||||
import androidx.compose.material.icons.rounded.Inventory2
|
import androidx.compose.material.icons.rounded.Inventory2
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material.icons.rounded.SyncProblem
|
import androidx.compose.material.icons.rounded.SyncProblem
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -26,6 +29,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,9 +50,13 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit
|
|||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
|
||||||
import de.jeanlucmakiola.agendula.ui.accounts.add.message
|
import de.jeanlucmakiola.agendula.ui.accounts.add.message
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.NameField
|
||||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||||
|
import de.jeanlucmakiola.caldav.ServerCertificate
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -70,10 +78,13 @@ internal fun AccountDetailScreen(
|
|||||||
onSignInAgain: (AccountEntity) -> Unit,
|
onSignInAgain: (AccountEntity) -> Unit,
|
||||||
onOpenStorage: () -> Unit,
|
onOpenStorage: () -> Unit,
|
||||||
viewModel: AccountsViewModel,
|
viewModel: AccountsViewModel,
|
||||||
|
visible: Boolean,
|
||||||
) {
|
) {
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||||
val push by viewModel.push.collectAsStateWithLifecycle()
|
val push by viewModel.push.collectAsStateWithLifecycle()
|
||||||
|
val trust by remember(accountId) { viewModel.trust(accountId) }
|
||||||
|
.collectAsStateWithLifecycle(ServerTrust.DEFAULT)
|
||||||
OnResume(viewModel::refreshPush)
|
OnResume(viewModel::refreshPush)
|
||||||
val row = accounts?.firstOrNull { it.account.id == accountId }
|
val row = accounts?.firstOrNull { it.account.id == accountId }
|
||||||
|
|
||||||
@@ -89,12 +100,34 @@ internal fun AccountDetailScreen(
|
|||||||
// The confirmation sheet. The removal it starts outlives this screen, and is
|
// The confirmation sheet. The removal it starts outlives this screen, and is
|
||||||
// shown on the accounts list rather than here.
|
// shown on the accounts list rather than here.
|
||||||
var confirming by remember { mutableStateOf(false) }
|
var confirming by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(title = identity.title, onBack = onBack) {
|
if (renaming) {
|
||||||
|
AccountNamePicker(
|
||||||
|
current = account.label.orEmpty(),
|
||||||
|
defaultTitle = account.defaultTitle(),
|
||||||
|
onSave = { name ->
|
||||||
|
viewModel.rename(account, name)
|
||||||
|
renaming = false
|
||||||
|
},
|
||||||
|
onDismiss = { renaming = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) {
|
||||||
AccountHero(identity)
|
AccountHero(identity)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
val actions = buildList<@Composable (Position) -> Unit> {
|
val actions = buildList<@Composable (Position) -> Unit> {
|
||||||
|
add { position ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.accounts_name),
|
||||||
|
summary = identity.title,
|
||||||
|
position = position,
|
||||||
|
leading = { Icon(Icons.Rounded.Edit, contentDescription = null) },
|
||||||
|
onClick = { renaming = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
add { position ->
|
add { position ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_last_sync),
|
title = stringResource(R.string.accounts_last_sync),
|
||||||
@@ -103,6 +136,28 @@ internal fun AccountDetailScreen(
|
|||||||
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
|
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (trust.allowCleartext) {
|
||||||
|
add { position ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.accounts_connection),
|
||||||
|
summary = stringResource(R.string.accounts_connection_cleartext),
|
||||||
|
position = position,
|
||||||
|
leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trust.pinnedCertificates.isNotEmpty()) {
|
||||||
|
add { position ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.accounts_trusted_certificate),
|
||||||
|
summary = trust.pinnedCertificates.sorted().joinToString("\n") {
|
||||||
|
ServerCertificate.formatFingerprint(it.take(PINNED_PREFIX_CHARS)) + ":\u2026"
|
||||||
|
},
|
||||||
|
position = position,
|
||||||
|
leading = { Icon(Icons.Rounded.GppMaybe, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!usable) return@buildList
|
if (!usable) return@buildList
|
||||||
push?.let { pushState ->
|
push?.let { pushState ->
|
||||||
add { position ->
|
add { position ->
|
||||||
@@ -114,7 +169,7 @@ internal fun AccountDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shown.needsSignIn) {
|
if (shown.offersSignIn) {
|
||||||
add { position ->
|
add { position ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_sign_in_again),
|
title = stringResource(R.string.accounts_sign_in_again),
|
||||||
@@ -123,7 +178,8 @@ internal fun AccountDetailScreen(
|
|||||||
onClick = { onSignInAgain(account) },
|
onClick = { onSignInAgain(account) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
if (!shown.needsSignIn) {
|
||||||
add { position ->
|
add { position ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.accounts_sync_now),
|
title = stringResource(R.string.accounts_sync_now),
|
||||||
@@ -479,3 +535,33 @@ private fun errorTitle(text: String): AnnotatedString {
|
|||||||
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
|
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Names an account on screen (#105); left empty, it goes back to [defaultTitle]. */
|
||||||
|
@Composable
|
||||||
|
private fun AccountNamePicker(
|
||||||
|
current: String,
|
||||||
|
defaultTitle: String,
|
||||||
|
onSave: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf(current) }
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.accounts_name),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = { onSave(name) }) { Text(stringResource(R.string.save)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
NameField(
|
||||||
|
name = name,
|
||||||
|
placeholder = defaultTitle,
|
||||||
|
autoFocus = true,
|
||||||
|
onNameChange = { name = it },
|
||||||
|
onImeAction = { onSave(name) },
|
||||||
|
)
|
||||||
|
GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first eight bytes of a pinned fingerprint, enough to compare at a glance. */
|
||||||
|
private const val PINNED_PREFIX_CHARS = 16
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ internal data class AccountIdentity(
|
|||||||
val user: String?,
|
val user: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** The title an account has without a [AccountEntity.label]. */
|
||||||
|
internal fun AccountEntity.defaultTitle(): String {
|
||||||
|
val url = principalUrl?.toHttpUrlOrNull()
|
||||||
|
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||||
|
val host = url?.host?.removePrefix("www.")
|
||||||
|
return if (provider?.hosted == true) provider.label else host ?: displayName
|
||||||
|
}
|
||||||
|
|
||||||
internal fun AccountEntity.identity(): AccountIdentity {
|
internal fun AccountEntity.identity(): AccountIdentity {
|
||||||
val url = principalUrl?.toHttpUrlOrNull()
|
val url = principalUrl?.toHttpUrlOrNull()
|
||||||
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
val provider = url?.let { CalDavProvider.forPrincipal(it) }
|
||||||
@@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity {
|
|||||||
provider = provider,
|
provider = provider,
|
||||||
// The fallback is the stored name: an account with no principal URL
|
// The fallback is the stored name: an account with no principal URL
|
||||||
// never got past discovery, so there is nothing better to call it.
|
// never got past discovery, so there is nothing better to call it.
|
||||||
title = if (hosted) provider.label else host ?: displayName,
|
title = label ?: defaultTitle(),
|
||||||
secondary = if (hosted) host else provider?.label,
|
secondary = if (hosted) host else provider?.label,
|
||||||
user = username?.takeIf { it.isNotBlank() },
|
user = username?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ internal fun AccountsScreen(
|
|||||||
onOpenStorage: () -> Unit,
|
onOpenStorage: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: AccountsViewModel,
|
viewModel: AccountsViewModel,
|
||||||
|
visible: Boolean,
|
||||||
) {
|
) {
|
||||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||||
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
val usable by viewModel.accountsUsable.collectAsStateWithLifecycle()
|
||||||
@@ -60,6 +61,8 @@ internal fun AccountsScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_accounts),
|
title = stringResource(R.string.settings_section_accounts),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
val loaded = accounts ?: return@CollapsingScaffold
|
val loaded = accounts ?: return@CollapsingScaffold
|
||||||
|
|
||||||
@@ -116,7 +119,7 @@ internal fun AccountsScreen(
|
|||||||
when {
|
when {
|
||||||
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
|
row.removing -> CircularProgressIndicator(Modifier.size(20.dp))
|
||||||
!usable -> Unit
|
!usable -> Unit
|
||||||
row.needsSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
row.offersSignIn -> IconButton(onClick = { onSignInAgain(account) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Rounded.Login,
|
Icons.AutoMirrored.Rounded.Login,
|
||||||
contentDescription = stringResource(R.string.accounts_sign_in_again),
|
contentDescription = stringResource(R.string.accounts_sign_in_again),
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
|||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ServerTrustStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
import de.jeanlucmakiola.agendula.data.sync.SyncAvailability
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.SyncFailure
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
import de.jeanlucmakiola.agendula.data.sync.SyncNotice
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
|
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeStore
|
||||||
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
|
||||||
@@ -30,6 +32,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -43,6 +46,7 @@ class AccountsViewModel @Inject constructor(
|
|||||||
private val settings: SettingsPrefs,
|
private val settings: SettingsPrefs,
|
||||||
private val distributors: PushDistributors,
|
private val distributors: PushDistributors,
|
||||||
private val pushRegistrar: PushRegistrar,
|
private val pushRegistrar: PushRegistrar,
|
||||||
|
private val serverTrust: ServerTrustStore,
|
||||||
pushStore: PushStore,
|
pushStore: PushStore,
|
||||||
availability: SyncAvailability,
|
availability: SyncAvailability,
|
||||||
@IoDispatcher io: CoroutineDispatcher,
|
@IoDispatcher io: CoroutineDispatcher,
|
||||||
@@ -188,7 +192,18 @@ class AccountsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val notices: List<SyncNotice> = emptyList(),
|
val notices: List<SyncNotice> = emptyList(),
|
||||||
val lists: ListPush = ListPush.NONE,
|
val lists: ListPush = ListPush.NONE,
|
||||||
)
|
) {
|
||||||
|
/**
|
||||||
|
* The last sync was refused over the server's certificate — typically a
|
||||||
|
* pinned one that was renewed. Nothing sets [needsSignIn] for that, yet
|
||||||
|
* signing in again is the only route that can trust the new one.
|
||||||
|
*/
|
||||||
|
val certificateRefused: Boolean
|
||||||
|
get() = account.lastSyncError?.let { SyncFailure.of(it).kind == SyncFailure.Kind.CERTIFICATE } == true
|
||||||
|
|
||||||
|
/** Whether "Sign in again" is the way forward for this account. */
|
||||||
|
val offersSignIn: Boolean get() = needsSignIn || certificateRefused
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accounts whose removal has started and not finished.
|
* Accounts whose removal has started and not finished.
|
||||||
@@ -248,6 +263,13 @@ class AccountsViewModel @Inject constructor(
|
|||||||
syncTrigger.enqueue(account.displayName, expedited = true)
|
syncTrigger.enqueue(account.displayName, expedited = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun rename(account: AccountEntity, label: String) {
|
||||||
|
viewModelScope.launch { repository.setLabel(account.id, label) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the user agreed to trust for [accountId] beyond the platform's defaults. */
|
||||||
|
fun trust(accountId: Long): Flow<ServerTrust> = serverTrust.observe(accountId)
|
||||||
|
|
||||||
/** The user has read what the last sync changed. */
|
/** The user has read what the last sync changed. */
|
||||||
fun dismissNotices(account: AccountEntity) {
|
fun dismissNotices(account: AccountEntity) {
|
||||||
viewModelScope.launch { notices.dismiss(account.id) }
|
viewModelScope.launch { notices.dismiss(account.id) }
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
|||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.CloudOff
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
import androidx.compose.material.icons.rounded.CloudSync
|
||||||
|
import androidx.compose.material.icons.rounded.GppMaybe
|
||||||
import androidx.compose.material.icons.rounded.Lock
|
import androidx.compose.material.icons.rounded.Lock
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
import androidx.compose.material.icons.rounded.OpenInBrowser
|
import androidx.compose.material.icons.rounded.OpenInBrowser
|
||||||
import androidx.compose.material.icons.rounded.Warning
|
import androidx.compose.material.icons.rounded.Warning
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -179,6 +181,8 @@ internal fun AddAccountScreen(
|
|||||||
is AddAccountStep.Working -> WorkingStep(step)
|
is AddAccountStep.Working -> WorkingStep(step)
|
||||||
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
|
is AddAccountStep.EnterCredentials -> CredentialsStep(step, viewModel)
|
||||||
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
|
is AddAccountStep.ConfirmBrowser -> ConfirmBrowserStep(step)
|
||||||
|
is AddAccountStep.ConfirmCleartext -> ConfirmCleartextStep(step)
|
||||||
|
is AddAccountStep.TrustCertificate -> TrustCertificateStep(step)
|
||||||
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
|
is AddAccountStep.WaitingForBrowser -> BrowserStep(step)
|
||||||
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
|
is AddAccountStep.ChooseLists -> ListsStep(step, viewModel)
|
||||||
is AddAccountStep.Summary -> SummaryStep(step)
|
is AddAccountStep.Summary -> SummaryStep(step)
|
||||||
@@ -228,7 +232,11 @@ private fun AddAccountStep.position(hasSetup: Boolean): Int? {
|
|||||||
return when (this) {
|
return when (this) {
|
||||||
is AddAccountStep.ChooseProvider -> 1
|
is AddAccountStep.ChooseProvider -> 1
|
||||||
is AddAccountStep.PrepareAccess -> 2
|
is AddAccountStep.PrepareAccess -> 2
|
||||||
is AddAccountStep.EnterAddress -> 2 + errand
|
// Both are about reaching the server, so they keep the address's slot.
|
||||||
|
is AddAccountStep.EnterAddress,
|
||||||
|
is AddAccountStep.ConfirmCleartext,
|
||||||
|
is AddAccountStep.TrustCertificate,
|
||||||
|
-> 2 + errand
|
||||||
// One slot, because only one of the three ever happens: a server either
|
// One slot, because only one of the three ever happens: a server either
|
||||||
// hands the sign-in to a browser — asking first, where the URL needs
|
// hands the sign-in to a browser — asking first, where the URL needs
|
||||||
// confirming — or asks for a password.
|
// confirming — or asks for a password.
|
||||||
@@ -272,6 +280,8 @@ private fun StepHero(state: AddAccountUiState) {
|
|||||||
// The warning is the screen, so it is the mark too — the browser icon
|
// The warning is the screen, so it is the mark too — the browser icon
|
||||||
// would say "this is going fine", which is the opposite of the point.
|
// would say "this is going fine", which is the opposite of the point.
|
||||||
state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning
|
state.step is AddAccountStep.ConfirmBrowser -> Icons.Rounded.Warning
|
||||||
|
state.step is AddAccountStep.ConfirmCleartext -> Icons.Rounded.LockOpen
|
||||||
|
state.step is AddAccountStep.TrustCertificate -> Icons.Rounded.GppMaybe
|
||||||
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
|
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
|
||||||
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
|
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
|
||||||
else -> Icons.Rounded.CloudSync
|
else -> Icons.Rounded.CloudSync
|
||||||
@@ -300,7 +310,9 @@ private fun StepTitle(step: AddAccountStep) {
|
|||||||
R.string.add_account_provider_title to R.string.add_account_provider_body
|
R.string.add_account_provider_title to R.string.add_account_provider_body
|
||||||
is AddAccountStep.PrepareAccess ->
|
is AddAccountStep.PrepareAccess ->
|
||||||
step.quirk.setupTitle to R.string.add_account_setup_body
|
step.quirk.setupTitle to R.string.add_account_setup_body
|
||||||
is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) {
|
is AddAccountStep.EnterAddress -> if (step.manualServer) {
|
||||||
|
R.string.add_account_server_title to R.string.add_account_server_fallback_body
|
||||||
|
} else if (step.asksForEmail) {
|
||||||
R.string.add_account_email_title to R.string.add_account_email_body
|
R.string.add_account_email_title to R.string.add_account_email_body
|
||||||
} else {
|
} else {
|
||||||
R.string.add_account_server_title to R.string.add_account_server_body
|
R.string.add_account_server_title to R.string.add_account_server_body
|
||||||
@@ -309,6 +321,10 @@ private fun StepTitle(step: AddAccountStep) {
|
|||||||
R.string.add_account_credentials_title to R.string.add_account_credentials_body
|
R.string.add_account_credentials_title to R.string.add_account_credentials_body
|
||||||
is AddAccountStep.ConfirmBrowser ->
|
is AddAccountStep.ConfirmBrowser ->
|
||||||
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
|
R.string.add_account_browser_confirm_title to R.string.add_account_browser_confirm_body
|
||||||
|
is AddAccountStep.ConfirmCleartext ->
|
||||||
|
R.string.add_account_cleartext_title to R.string.add_account_cleartext_body
|
||||||
|
is AddAccountStep.TrustCertificate ->
|
||||||
|
R.string.add_account_certificate_title to R.string.add_account_certificate_body
|
||||||
is AddAccountStep.ChooseLists ->
|
is AddAccountStep.ChooseLists ->
|
||||||
R.string.add_account_lists_title to R.string.add_account_lists_body
|
R.string.add_account_lists_title to R.string.add_account_lists_body
|
||||||
is AddAccountStep.Summary ->
|
is AddAccountStep.Summary ->
|
||||||
@@ -410,6 +426,34 @@ private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAcco
|
|||||||
) { Text(stringResource(R.string.add_account_browser_use_password)) }
|
) { Text(stringResource(R.string.add_account_browser_use_password)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain http stays a deliberate choice: going forward is primary, as it
|
||||||
|
// is for the browser warning, and changing the address is right below.
|
||||||
|
is AddAccountStep.ConfirmCleartext -> {
|
||||||
|
PrimaryAction(
|
||||||
|
label = stringResource(R.string.add_account_cleartext_connect),
|
||||||
|
enabled = true,
|
||||||
|
onClick = viewModel::onCleartextConfirmed,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.onBackWithin() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.add_account_change_address)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The safe answer stays visible, as it does for plain http: back is
|
||||||
|
// not something every user knows to look for on a security prompt.
|
||||||
|
is AddAccountStep.TrustCertificate -> {
|
||||||
|
PrimaryAction(
|
||||||
|
label = stringResource(R.string.add_account_certificate_trust),
|
||||||
|
enabled = true,
|
||||||
|
onClick = viewModel::onCertificateTrusted,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.onBackWithin() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text(stringResource(R.string.add_account_certificate_decline)) }
|
||||||
|
}
|
||||||
|
|
||||||
is AddAccountStep.WaitingForBrowser -> {
|
is AddAccountStep.WaitingForBrowser -> {
|
||||||
// A failed flow is retried by default. With no browser a retry
|
// A failed flow is retried by default. With no browser a retry
|
||||||
// cannot help; while the server throttles or is in maintenance an
|
// cannot help; while the server throttles or is in maintenance an
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ internal val CalDavDiscovery.Outcome.Cause.message: Int
|
|||||||
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
|
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
|
||||||
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
|
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
|
||||||
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
|
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
|
||||||
|
CalDavDiscovery.Outcome.Cause.TLS -> R.string.add_account_error_tls
|
||||||
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
|
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
|
||||||
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
|
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
|
||||||
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
|
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
|
||||||
|
|||||||
+312
-68
@@ -8,13 +8,18 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator
|
|||||||
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
|
||||||
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
|
||||||
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ServerTrustKeys
|
||||||
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
import de.jeanlucmakiola.caldav.CalDavDiscovery
|
||||||
import de.jeanlucmakiola.caldav.CalDavProvider
|
import de.jeanlucmakiola.caldav.CalDavProvider
|
||||||
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
|
||||||
|
import de.jeanlucmakiola.caldav.ServerCertificate
|
||||||
import de.jeanlucmakiola.caldav.ServerQuirk
|
import de.jeanlucmakiola.caldav.ServerQuirk
|
||||||
|
import de.jeanlucmakiola.caldav.ServerTrust
|
||||||
import de.jeanlucmakiola.caldav.ServiceDiscovery
|
import de.jeanlucmakiola.caldav.ServiceDiscovery
|
||||||
import de.jeanlucmakiola.caldav.TaskCollection
|
import de.jeanlucmakiola.caldav.TaskCollection
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -77,7 +82,12 @@ sealed interface AddAccountStep {
|
|||||||
val input: String = "",
|
val input: String = "",
|
||||||
/** A cause, not a message — the screen owns the wording. */
|
/** A cause, not a message — the screen owns the wording. */
|
||||||
val error: CalDavDiscovery.Outcome.Cause? = null,
|
val error: CalDavDiscovery.Outcome.Cause? = null,
|
||||||
) : AddAccountStep
|
/** The email address found no server, so a hosted choice asks for its URL instead. */
|
||||||
|
val manualServer: Boolean = false,
|
||||||
|
) : AddAccountStep {
|
||||||
|
/** Whether the field takes an email address rather than a server URL. */
|
||||||
|
val asksForEmail: Boolean get() = choice.provider?.hosted == true && !manualServer
|
||||||
|
}
|
||||||
|
|
||||||
data class Working(val message: AddAccountMessage.Progress) : AddAccountStep
|
data class Working(val message: AddAccountMessage.Progress) : AddAccountStep
|
||||||
|
|
||||||
@@ -111,6 +121,12 @@ sealed interface AddAccountStep {
|
|||||||
val insecure: Boolean = false,
|
val insecure: Boolean = false,
|
||||||
) : AddAccountStep
|
) : AddAccountStep
|
||||||
|
|
||||||
|
/** The typed address is plain http; the user has to accept that before anything is sent. */
|
||||||
|
data class ConfirmCleartext(val address: String) : AddAccountStep
|
||||||
|
|
||||||
|
/** The server's certificate is not trusted by the platform; the user may pin it. */
|
||||||
|
data class TrustCertificate(val certificate: ServerCertificate) : AddAccountStep
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The browser has the flow. We poll until the user approves, and offer an
|
* The browser has the flow. We poll until the user approves, and offer an
|
||||||
* explicit way out — Custom Tabs return **no result** when dismissed, and
|
* explicit way out — Custom Tabs return **no result** when dismissed, and
|
||||||
@@ -279,6 +295,11 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
get() = handle[KEY_REAUTH_ACCOUNT]
|
get() = handle[KEY_REAUTH_ACCOUNT]
|
||||||
set(value) { handle[KEY_REAUTH_ACCOUNT] = value }
|
set(value) { handle[KEY_REAUTH_ACCOUNT] = value }
|
||||||
|
|
||||||
|
/** The email that found no server; Back returns to it from the server field. */
|
||||||
|
private var fallbackEmail: String?
|
||||||
|
get() = handle[KEY_FALLBACK_EMAIL]
|
||||||
|
set(value) { handle[KEY_FALLBACK_EMAIL] = value }
|
||||||
|
|
||||||
/** The username the credentials step starts with on a re-authentication. */
|
/** The username the credentials step starts with on a re-authentication. */
|
||||||
private var suggestedUsername: String
|
private var suggestedUsername: String
|
||||||
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
|
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
|
||||||
@@ -317,6 +338,36 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
/** The flow the browser was last sent to, for a retry while it is still open. */
|
/** The flow the browser was last sent to, for a retry while it is still open. */
|
||||||
private var browserFlow: NextcloudLoginFlow.Flow? = null
|
private var browserFlow: NextcloudLoginFlow.Flow? = null
|
||||||
|
|
||||||
|
/** What the user agreed to trust in this flow, handed to every gateway call. */
|
||||||
|
private var trust: ServerTrust
|
||||||
|
get() = TRUST_KEYS.read(handle)
|
||||||
|
set(value) = TRUST_KEYS.write(handle, value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address [trust] was given for. Pins and plain http both belong to it,
|
||||||
|
* so submitting any other address starts from nothing.
|
||||||
|
*/
|
||||||
|
private var trustAddress: String?
|
||||||
|
get() = handle[KEY_TRUST_ADDRESS]
|
||||||
|
set(value) { handle[KEY_TRUST_ADDRESS] = value }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pins in [trust] came from the account being signed in to, not from
|
||||||
|
* this flow. The first certificate trusted now replaces them, so a renewed
|
||||||
|
* certificate retires the one it renews instead of joining it.
|
||||||
|
*/
|
||||||
|
private var inheritedPins: Boolean
|
||||||
|
get() = handle[KEY_INHERITED_PINS] ?: false
|
||||||
|
set(value) { handle[KEY_INHERITED_PINS] = value }
|
||||||
|
|
||||||
|
/** Loading a re-authenticated account's stored trust, which discovery waits for. */
|
||||||
|
private var trustLoad: Job? = null
|
||||||
|
|
||||||
|
/** The open [AddAccountStep.TrustCertificate] question, and where declining it goes. */
|
||||||
|
private class TrustPrompt(val answer: CompletableDeferred<Unit>, val back: AddAccountStep)
|
||||||
|
|
||||||
|
private var trustPrompt: TrustPrompt? = null
|
||||||
|
|
||||||
/** Which hosts asked for credentials, for the cross-domain diagnostic. */
|
/** Which hosts asked for credentials, for the cross-domain diagnostic. */
|
||||||
private var hostsNeedingAuth: List<String>
|
private var hostsNeedingAuth: List<String>
|
||||||
get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty()
|
get() = handle.get<ArrayList<String>>(KEY_HOSTS_NEEDING_AUTH).orEmpty()
|
||||||
@@ -370,6 +421,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// one is not an answer to this one.
|
// one is not an answer to this one.
|
||||||
if (picked != choice) addressInput = ""
|
if (picked != choice) addressInput = ""
|
||||||
choice = picked
|
choice = picked
|
||||||
|
clearFallback()
|
||||||
val quirk = picked.provider?.let(ServerQuirk::forProvider)
|
val quirk = picked.provider?.let(ServerQuirk::forProvider)
|
||||||
val setup = quirk?.hasSetupSteps == true
|
val setup = quirk?.hasSetupSteps == true
|
||||||
updateState {
|
updateState {
|
||||||
@@ -413,6 +465,15 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
reauthenticating = true,
|
reauthenticating = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Starts from what the account already trusts, so a renewed certificate
|
||||||
|
// is the only thing asked about again.
|
||||||
|
val address = addressInput
|
||||||
|
trustLoad = viewModelScope.launch {
|
||||||
|
val stored = runCatching { repository.trustFor(accountId) }.getOrDefault(ServerTrust.DEFAULT)
|
||||||
|
trust = stored
|
||||||
|
trustAddress = address
|
||||||
|
inheritedPins = stored.pinnedCertificates.isNotEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The errand is read; on to the address. */
|
/** The errand is read; on to the address. */
|
||||||
@@ -430,11 +491,10 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
addressInput = value
|
addressInput = value
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.EnterAddress(picked, value),
|
step = addressStep(value),
|
||||||
// The service's own rule outranks the one the typed host implies:
|
// The service's own rule outranks the one the typed host implies:
|
||||||
// it was chosen deliberately, and it is known before a key is hit.
|
// it was chosen deliberately, and it is known before a key is hit.
|
||||||
quirk = picked.provider?.let(ServerQuirk::forProvider)
|
quirk = quirkFor(value),
|
||||||
?: ServerQuirk.forInput(value),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,10 +548,25 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverRoot = ServiceDiscovery.serverRootFor(input)
|
serverRoot = ServiceDiscovery.serverRootFor(input)
|
||||||
|
discoverAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovery for the submitted address, without credentials. */
|
||||||
|
private fun discoverAddress() {
|
||||||
working(AddAccountMessage.Progress.Discovering)
|
working(AddAccountMessage.Progress.Discovering)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val outcome = gateway.discover(typedInput)) {
|
// ⚠️ After the stored trust has landed, not before: reset first and
|
||||||
|
// the load would put the old address's trust back on top.
|
||||||
|
trustLoad?.join()
|
||||||
|
if (typedInput != trustAddress) {
|
||||||
|
trust = ServerTrust.DEFAULT
|
||||||
|
inheritedPins = false
|
||||||
|
trustAddress = typedInput
|
||||||
|
}
|
||||||
|
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.Discovering) {
|
||||||
|
gateway.discover(typedInput, trust = trust)
|
||||||
|
} ?: return@launch
|
||||||
|
when (outcome) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
||||||
|
|
||||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||||
@@ -505,20 +580,117 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// would make it permanently unreachable.
|
// would make it permanently unreachable.
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
|
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
|
||||||
|
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||||
|
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> if (needsCleartextConsent(outcome.cause)) {
|
||||||
|
updateState { it.copy(step = AddAccountStep.ConfirmCleartext(typedInput)) }
|
||||||
|
} else {
|
||||||
|
backToAddressOrServer(outcome.cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked about inside discoverTrusting, which only returns it declined.
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A typed http:// address the user has not yet accepted as unencrypted. */
|
||||||
|
private fun needsCleartextConsent(cause: CalDavDiscovery.Outcome.Cause): Boolean =
|
||||||
|
cause == CalDavDiscovery.Outcome.Cause.INSECURE && !trust.allowCleartext &&
|
||||||
|
typedInput.toHttpUrlOrNull()?.isHttps == false
|
||||||
|
|
||||||
|
/** The warning is read and plain http is wanted anyway. */
|
||||||
|
fun onCleartextConfirmed() {
|
||||||
|
if (current.step !is AddAccountStep.ConfirmCleartext) return
|
||||||
|
trust = trust.copy(allowCleartext = true)
|
||||||
|
trustAddress = typedInput
|
||||||
|
discoverAddress()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fingerprint is checked; pin it and re-run what it stopped. */
|
||||||
|
fun onCertificateTrusted() {
|
||||||
|
val step = current.step as? AddAccountStep.TrustCertificate ?: return
|
||||||
|
val prompt = trustPrompt ?: return
|
||||||
|
trustPrompt = null
|
||||||
|
val fingerprint = step.certificate.fingerprint
|
||||||
|
trust = if (inheritedPins) {
|
||||||
|
trust.copy(pinnedCertificates = setOf(fingerprint.lowercase()))
|
||||||
|
} else {
|
||||||
|
trust.withCertificate(fingerprint)
|
||||||
|
}
|
||||||
|
inheritedPins = false
|
||||||
|
prompt.answer.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [discover], and on a certificate the user could trust asks about it
|
||||||
|
* and runs it again once they have. Null when they declined; the step is
|
||||||
|
* then already [back].
|
||||||
|
*/
|
||||||
|
private suspend fun discoverTrusting(
|
||||||
|
back: AddAccountStep,
|
||||||
|
progress: AddAccountMessage.Progress,
|
||||||
|
discover: suspend () -> CalDavDiscovery.Outcome,
|
||||||
|
): CalDavDiscovery.Outcome? {
|
||||||
|
while (true) {
|
||||||
|
val outcome = discover()
|
||||||
|
if (outcome !is CalDavDiscovery.Outcome.UntrustedCertificate) return outcome
|
||||||
|
val prompt = TrustPrompt(CompletableDeferred(), back)
|
||||||
|
trustPrompt = prompt
|
||||||
|
updateState { it.copy(step = AddAccountStep.TrustCertificate(outcome.certificate), openInBrowser = null) }
|
||||||
|
try {
|
||||||
|
prompt.answer.await()
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
working(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An email address whose domain has no CalDAV server goes straight to the
|
||||||
|
* server field rather than an error with nowhere to go; the email stays as
|
||||||
|
* the username. A domain nothing answered for may also be a typo or no
|
||||||
|
* network, so that message stays visible on the server field. Any other
|
||||||
|
* failure keeps its own message on the email field.
|
||||||
|
*/
|
||||||
|
private fun backToAddressOrServer(cause: CalDavDiscovery.Outcome.Cause) {
|
||||||
|
val email = ServiceDiscovery.emailOf(typedInput)
|
||||||
|
if (fallbackEmail != null || email == null || cause !in NO_SERVER_FOUND) {
|
||||||
|
return backToAddress(cause)
|
||||||
|
}
|
||||||
|
fallbackEmail = email
|
||||||
|
addressInput = ""
|
||||||
|
updateState {
|
||||||
|
it.copy(
|
||||||
|
step = addressStep("", cause.takeIf { it == CalDavDiscovery.Outcome.Cause.UNREACHABLE }),
|
||||||
|
quirk = quirkFor(""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearFallback() {
|
||||||
|
fallbackEmail = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The username the credentials step starts with: a re-authentication's, else
|
||||||
|
* the email that fell back to the server field — or one typed into it.
|
||||||
|
*/
|
||||||
|
private fun prefilledUsername(): String = suggestedUsername.ifBlank {
|
||||||
|
fallbackEmail?.let { ServiceDiscovery.emailOf(typedInput) ?: it }.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
/** Step 3a → re-run discovery with the password the user typed. */
|
/** Step 3a → re-run discovery with the password the user typed. */
|
||||||
fun onCredentialsSubmitted() {
|
fun onCredentialsSubmitted() {
|
||||||
val step = current.step as? AddAccountStep.EnterCredentials ?: return
|
val step = current.step as? AddAccountStep.EnterCredentials ?: return
|
||||||
if (step.username.isBlank() || step.password.isEmpty()) return
|
if (step.username.isBlank() || step.password.isEmpty()) return
|
||||||
username = step.username.trim()
|
username = step.username.trim()
|
||||||
appPassword = step.password
|
appPassword = step.password
|
||||||
|
discoverWithCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun discoverWithCredentials() {
|
||||||
working(AddAccountMessage.Progress.SigningIn)
|
working(AddAccountMessage.Progress.SigningIn)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// ⚠️ An error, not a silent unauthenticated retry. serverRootFor
|
// ⚠️ An error, not a silent unauthenticated retry. serverRootFor
|
||||||
@@ -529,7 +701,11 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
val root = serverRoot
|
val root = serverRoot
|
||||||
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
?: return@launch backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
||||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||||
when (val outcome = gateway.discover(typedInput, credentials)) {
|
val outcome = discoverTrusting(
|
||||||
|
AddAccountStep.EnterCredentials(username = username),
|
||||||
|
AddAccountMessage.Progress.SigningIn,
|
||||||
|
) { gateway.discover(typedInput, credentials, trust) } ?: return@launch
|
||||||
|
when (outcome) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
||||||
|
|
||||||
// A second rejection is the point at which naming the provider's
|
// A second rejection is the point at which naming the provider's
|
||||||
@@ -549,8 +725,9 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
|
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
||||||
|
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,7 +746,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
/** Step 3b → the browser flow, if this looks like a Nextcloud. */
|
/** Step 3b → the browser flow, if this looks like a Nextcloud. */
|
||||||
private suspend fun offerSignIn() {
|
private suspend fun offerSignIn() {
|
||||||
val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
val root = serverRoot ?: return backToAddress(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
|
||||||
val flow = gateway.startLoginFlow(root)
|
val flow = gateway.startLoginFlow(root, trust)
|
||||||
|
|
||||||
if (flow == null) {
|
if (flow == null) {
|
||||||
// Not a Nextcloud, or its login flow is unavailable. Ask for a
|
// Not a Nextcloud, or its login flow is unavailable. Ask for a
|
||||||
@@ -577,7 +754,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.EnterCredentials(
|
step = AddAccountStep.EnterCredentials(
|
||||||
username = suggestedUsername,
|
username = prefilledUsername(),
|
||||||
error = quirkHint(),
|
error = quirkHint(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -630,7 +807,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) {
|
private suspend fun launchBrowser(flow: NextcloudLoginFlow.Flow) {
|
||||||
browserFlow = flow
|
browserFlow = flow
|
||||||
runCatching { pendingFlow.remember(flow) }
|
runCatching { pendingFlow.remember(flow, trust) }
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch),
|
step = AddAccountStep.WaitingForBrowser(hostMismatch = flow.hostMismatch),
|
||||||
@@ -659,7 +836,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// later poll can only report expiry. Bounded by the gateway's
|
// later poll can only report expiry. Bounded by the gateway's
|
||||||
// own per-call budget, and the delay above stays cancellable, so
|
// own per-call budget, and the delay above stays cancellable, so
|
||||||
// the loop still stops promptly.
|
// the loop still stops promptly.
|
||||||
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow) }
|
val result = withContext(NonCancellable) { gateway.pollLoginFlow(flow, trust) }
|
||||||
when (result) {
|
when (result) {
|
||||||
is NextcloudLoginFlow.PollResult.Approved -> {
|
is NextcloudLoginFlow.PollResult.Approved -> {
|
||||||
// ⚠️ Uncancellable. The 200 that carried this password is
|
// ⚠️ Uncancellable. The 200 that carried this password is
|
||||||
@@ -698,47 +875,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
result.hostMismatch?.let { mismatch ->
|
result.hostMismatch?.let { mismatch ->
|
||||||
updateState { it.copy(originMismatch = mismatch) }
|
updateState { it.copy(originMismatch = mismatch) }
|
||||||
}
|
}
|
||||||
working(AddAccountMessage.Progress.ReadingLists)
|
discoverApproved(result.credentials.server)
|
||||||
val outcome = gateway.discover(
|
|
||||||
// The server the credentials were *issued by*, not the
|
|
||||||
// host the user typed — the two differ in exactly the
|
|
||||||
// host-mismatch case this flow already models, and
|
|
||||||
// getting it wrong burns the one-shot app password.
|
|
||||||
target = result.credentials.server.toString(),
|
|
||||||
credentials = CalDavGateway.Credentials(
|
|
||||||
username,
|
|
||||||
appPassword,
|
|
||||||
result.credentials.server,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
when (outcome) {
|
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
|
||||||
// ⚠️ Forward what actually happened. Reporting every
|
|
||||||
// outcome as NO_CALENDARS tells someone whose server
|
|
||||||
// named an unresolvable host that their account holds
|
|
||||||
// no task lists, which is both wrong and unactionable.
|
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
|
||||||
// ⚠️ These two carry no Cause, so they used to land
|
|
||||||
// on "signed in, but no task lists" — said of a
|
|
||||||
// credential the server had just rejected. A 401 here
|
|
||||||
// is either a home set outside the domain the
|
|
||||||
// credential is scoped to, which retrying cannot fix
|
|
||||||
// and which only mints a second password, or the
|
|
||||||
// server having a moment.
|
|
||||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
|
||||||
hostsNeedingAuth = outcome.hosts
|
|
||||||
val stranger = outsideCredentialScope(outcome.hosts)
|
|
||||||
if (stranger != null) {
|
|
||||||
fatal(AddAccountMessage.OutsideCredentialScope(stranger))
|
|
||||||
} else {
|
|
||||||
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalDavDiscovery.Outcome.Unauthenticated ->
|
|
||||||
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +908,52 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Discovery with a password the login flow just minted, at the server that issued it. */
|
||||||
|
private suspend fun discoverApproved(server: HttpUrl) {
|
||||||
|
working(AddAccountMessage.Progress.ReadingLists)
|
||||||
|
val outcome = discoverTrusting(addressStep(typedInput), AddAccountMessage.Progress.ReadingLists) {
|
||||||
|
gateway.discover(
|
||||||
|
// The server the credentials were *issued by*, not the
|
||||||
|
// host the user typed — the two differ in exactly the
|
||||||
|
// host-mismatch case this flow already models, and
|
||||||
|
// getting it wrong burns the one-shot app password.
|
||||||
|
target = server.toString(),
|
||||||
|
credentials = CalDavGateway.Credentials(username, appPassword, server),
|
||||||
|
trust = trust,
|
||||||
|
)
|
||||||
|
} ?: return
|
||||||
|
when (outcome) {
|
||||||
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
|
||||||
|
// ⚠️ Forward what actually happened. Reporting every
|
||||||
|
// outcome as NO_CALENDARS tells someone whose server
|
||||||
|
// named an unresolvable host that their account holds
|
||||||
|
// no task lists, which is both wrong and unactionable.
|
||||||
|
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
||||||
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
||||||
|
// ⚠️ These two carry no Cause, so they used to land
|
||||||
|
// on "signed in, but no task lists" — said of a
|
||||||
|
// credential the server had just rejected. A 401 here
|
||||||
|
// is either a home set outside the domain the
|
||||||
|
// credential is scoped to, which retrying cannot fix
|
||||||
|
// and which only mints a second password, or the
|
||||||
|
// server having a moment.
|
||||||
|
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||||
|
hostsNeedingAuth = outcome.hosts
|
||||||
|
val stranger = outsideCredentialScope(outcome.hosts)
|
||||||
|
if (stranger != null) {
|
||||||
|
fatal(AddAccountMessage.OutsideCredentialScope(stranger))
|
||||||
|
} else {
|
||||||
|
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CalDavDiscovery.Outcome.Unauthenticated ->
|
||||||
|
backToAddress(CalDavDiscovery.Outcome.Cause.SERVER_ERROR)
|
||||||
|
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Another go after the browser sign-in failed.
|
* Another go after the browser sign-in failed.
|
||||||
*
|
*
|
||||||
@@ -803,7 +986,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null),
|
step = AddAccountStep.EnterCredentials(username = prefilledUsername(), error = null),
|
||||||
openInBrowser = null,
|
openInBrowser = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -854,6 +1037,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
found = discovered,
|
found = discovered,
|
||||||
selected = chosen,
|
selected = chosen,
|
||||||
reauthenticating = reauthAccountId,
|
reauthenticating = reauthAccountId,
|
||||||
|
trust = trust,
|
||||||
)
|
)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
// A constraint violation or an IO failure in DataStore must not
|
// A constraint violation or an IO failure in DataStore must not
|
||||||
@@ -916,7 +1100,17 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// Back into the errand when there was one, so the steps can be re-read
|
// Back into the errand when there was one, so the steps can be re-read
|
||||||
// without starting the flow again — that is the screen people return to.
|
// without starting the flow again — that is the screen people return to.
|
||||||
is AddAccountStep.EnterAddress -> {
|
is AddAccountStep.EnterAddress -> {
|
||||||
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
|
val email = fallbackEmail
|
||||||
|
if (email != null) {
|
||||||
|
clearFallback()
|
||||||
|
addressInput = email
|
||||||
|
typedInput = email
|
||||||
|
updateState {
|
||||||
|
it.copy(step = addressStep(email), fatal = null, quirk = quirkFor(email))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,6 +1144,19 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is AddAccountStep.ConfirmCleartext -> {
|
||||||
|
updateState { it.copy(step = addressStep(typedInput), fatal = null) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
is AddAccountStep.TrustCertificate -> {
|
||||||
|
val prompt = trustPrompt
|
||||||
|
trustPrompt = null
|
||||||
|
prompt?.answer?.cancel()
|
||||||
|
updateState { it.copy(step = prompt?.back ?: addressStep(typedInput), fatal = null) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,6 +1197,14 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
hostsNeedingAuth = emptyList()
|
hostsNeedingAuth = emptyList()
|
||||||
reauthAccountId = null
|
reauthAccountId = null
|
||||||
suggestedUsername = ""
|
suggestedUsername = ""
|
||||||
|
fallbackEmail = null
|
||||||
|
trustLoad?.cancel()
|
||||||
|
trustLoad = null
|
||||||
|
trust = ServerTrust.DEFAULT
|
||||||
|
trustAddress = null
|
||||||
|
inheritedPins = false
|
||||||
|
trustPrompt?.answer?.cancel()
|
||||||
|
trustPrompt = null
|
||||||
current = AddAccountUiState()
|
current = AddAccountUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,9 +1228,10 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
private fun discardMintedPassword() {
|
private fun discardMintedPassword() {
|
||||||
val credentials = minted ?: return
|
val credentials = minted ?: return
|
||||||
minted = null
|
minted = null
|
||||||
|
val trust = trust
|
||||||
// The scope has no exception handler, so anything escaping here takes the
|
// The scope has no exception handler, so anything escaping here takes the
|
||||||
// app down — for a courtesy call whose failures are deliberately silent.
|
// app down — for a courtesy call whose failures are deliberately silent.
|
||||||
appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials) } }
|
appScope.launch { runCatching { gateway.revokeIssuedAppPassword(credentials, trust) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------- saved state
|
// -------------------------------------------------------- saved state
|
||||||
@@ -1040,6 +1256,8 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
// what deals with that on the next open. Recording them as
|
// what deals with that on the next open. Recording them as
|
||||||
// themselves would only mean deciding the same thing twice.
|
// themselves would only mean deciding the same thing twice.
|
||||||
is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS
|
is AddAccountStep.ConfirmBrowser, is AddAccountStep.WaitingForBrowser -> STEP_ADDRESS
|
||||||
|
// Both re-ask on the next submit, which is where a restore puts them.
|
||||||
|
is AddAccountStep.ConfirmCleartext, is AddAccountStep.TrustCertificate -> STEP_ADDRESS
|
||||||
is AddAccountStep.Working -> STEP_WORKING
|
is AddAccountStep.Working -> STEP_WORKING
|
||||||
is AddAccountStep.ChooseLists -> STEP_LISTS
|
is AddAccountStep.ChooseLists -> STEP_LISTS
|
||||||
is AddAccountStep.Summary -> STEP_SUMMARY
|
is AddAccountStep.Summary -> STEP_SUMMARY
|
||||||
@@ -1119,8 +1337,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
reauthenticating = reauthAccountId != null,
|
reauthenticating = reauthAccountId != null,
|
||||||
// Derived rather than saved: it is a pure function of the choice and the
|
// Derived rather than saved: it is a pure function of the choice and the
|
||||||
// address, both of which are.
|
// address, both of which are.
|
||||||
quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
quirk = quirkFor(addressInput),
|
||||||
?: ServerQuirk.forInput(addressInput),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1146,10 +1363,13 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
val credentials = CalDavGateway.Credentials(username, appPassword, root)
|
||||||
when (val outcome = gateway.discover(target, credentials)) {
|
val outcome = discoverTrusting(addressStep(addressInput), AddAccountMessage.Progress.ReadingLists) {
|
||||||
|
gateway.discover(target, credentials, trust)
|
||||||
|
} ?: return@launch
|
||||||
|
when (outcome) {
|
||||||
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
|
||||||
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
|
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
|
||||||
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
|
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
|
||||||
// The credential we were holding is no longer accepted, which on
|
// The credential we were holding is no longer accepted, which on
|
||||||
// this path is indistinguishable from never having had one.
|
// this path is indistinguishable from never having had one.
|
||||||
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
|
||||||
@@ -1157,6 +1377,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
credentialsRejected()
|
credentialsRejected()
|
||||||
}
|
}
|
||||||
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
|
||||||
|
is CalDavDiscovery.Outcome.UntrustedCertificate -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1471,7 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
choice = choice ?: ProviderChoice.OtherServer,
|
choice = choice ?: ProviderChoice.OtherServer,
|
||||||
input = input,
|
input = input,
|
||||||
error = error,
|
error = error,
|
||||||
|
manualServer = fallbackEmail != null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What was actually set up, named the way the accounts list will name it. */
|
/** What was actually set up, named the way the accounts list will name it. */
|
||||||
@@ -1286,12 +1508,13 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
updateState { it.copy(step = transform(step)) }
|
updateState { it.copy(step = transform(step)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chosen service's quirk, else the one the typed host implies. */
|
||||||
|
private fun quirkFor(input: String): ServerQuirk? =
|
||||||
|
choice?.provider?.let(ServerQuirk::forProvider) ?: ServerQuirk.forInput(input)
|
||||||
|
|
||||||
/** The provider-specific reason a correct-looking password gets rejected. */
|
/** The provider-specific reason a correct-looking password gets rejected. */
|
||||||
private fun quirkHint(): AddAccountMessage? =
|
private fun quirkHint(): AddAccountMessage? =
|
||||||
when (
|
when (val quirk = quirkFor(typedInput)) {
|
||||||
val quirk = choice?.provider?.let(ServerQuirk::forProvider)
|
|
||||||
?: ServerQuirk.forInput(typedInput)
|
|
||||||
) {
|
|
||||||
ServerQuirk.FASTMAIL_APP_PASSWORD,
|
ServerQuirk.FASTMAIL_APP_PASSWORD,
|
||||||
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
|
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
|
||||||
-> AddAccountMessage.Quirk(quirk)
|
-> AddAccountMessage.Quirk(quirk)
|
||||||
@@ -1330,10 +1553,14 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The port is part of the name: two servers on one host are two accounts (#105).
|
||||||
private fun accountName(): String =
|
private fun accountName(): String =
|
||||||
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.host.orEmpty()}" }
|
username.takeIf { it.isNotBlank() }?.let { "$it@${serverRoot?.authority().orEmpty()}" }
|
||||||
?: typedInput
|
?: typedInput
|
||||||
|
|
||||||
|
private fun HttpUrl.authority(): String =
|
||||||
|
if (port == HttpUrl.defaultPort(scheme)) host else "$host:$port"
|
||||||
|
|
||||||
internal companion object {
|
internal companion object {
|
||||||
const val POLL_INTERVAL_MILLIS = 2_000L
|
const val POLL_INTERVAL_MILLIS = 2_000L
|
||||||
|
|
||||||
@@ -1377,6 +1604,23 @@ class AddAccountViewModel @Inject constructor(
|
|||||||
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
|
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
|
||||||
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
|
||||||
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
|
||||||
|
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
|
||||||
|
const val KEY_TRUSTED_CERTIFICATES = "add_account.trusted_certificates"
|
||||||
|
const val KEY_CLEARTEXT = "add_account.cleartext"
|
||||||
|
const val KEY_TRUST_ADDRESS = "add_account.trust_address"
|
||||||
|
const val KEY_INHERITED_PINS = "add_account.inherited_pins"
|
||||||
|
|
||||||
|
val TRUST_KEYS = ServerTrustKeys(KEY_TRUSTED_CERTIFICATES, KEY_CLEARTEXT)
|
||||||
|
|
||||||
|
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
|
||||||
|
val NO_SERVER_FOUND = setOf(
|
||||||
|
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
|
||||||
|
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||||
|
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
|
||||||
|
// The domain's own host would not set up TLS: usually its website,
|
||||||
|
// not a CalDAV server, so the server field is the way on.
|
||||||
|
CalDavDiscovery.Outcome.Cause.TLS,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What to type for a server, from the principal an account stored.
|
* What to type for a server, from the principal an account stored.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import de.jeanlucmakiola.agendula.R
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
|
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
|
||||||
val hosted = step.choice.provider?.hosted == true
|
val hosted = step.asksForEmail
|
||||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
FieldRow(
|
FieldRow(
|
||||||
label = if (hosted) {
|
label = if (hosted) {
|
||||||
@@ -30,7 +30,7 @@ internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccoun
|
|||||||
},
|
},
|
||||||
value = step.input,
|
value = step.input,
|
||||||
onValueChange = viewModel::onAddressChanged,
|
onValueChange = viewModel::onAddressChanged,
|
||||||
placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" }
|
placeholder = step.choice.provider?.primaryDomain?.takeIf { hosted }?.let { "you@$it" }
|
||||||
?: stringResource(R.string.add_account_server_hint),
|
?: stringResource(R.string.add_account_server_hint),
|
||||||
error = step.error?.let { stringResource(it.message) },
|
error = step.error?.let { stringResource(it.message) },
|
||||||
// An email address is still typed with the URI keyboard: it is the
|
// An email address is still typed with the URI keyboard: it is the
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.accounts.add
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Badge
|
||||||
|
import androidx.compose.material.icons.rounded.Event
|
||||||
|
import androidx.compose.material.icons.rounded.Fingerprint
|
||||||
|
import androidx.compose.material.icons.rounded.LockOpen
|
||||||
|
import androidx.compose.material.icons.rounded.Verified
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/** The typed address is plain http: what that exposes, before anything is sent. */
|
||||||
|
@Composable
|
||||||
|
internal fun ConfirmCleartextStep(step: AddAccountStep.ConfirmCleartext) {
|
||||||
|
GroupedRow(
|
||||||
|
title = step.address,
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { Icon(Icons.Rounded.LockOpen, contentDescription = null) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
QuirkNote(text = stringResource(R.string.add_account_cleartext_note))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A certificate the phone does not trust, laid out for comparing against the
|
||||||
|
* server's own. The fingerprint is the part that matters, so it comes first and
|
||||||
|
* can be selected.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun TrustCertificateStep(step: AddAccountStep.TrustCertificate) {
|
||||||
|
val certificate = step.certificate
|
||||||
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
|
val expires = DateFormat.getDateInstance(DateFormat.MEDIUM, locale).format(Date(certificate.notAfterMillis))
|
||||||
|
val rows = listOf(
|
||||||
|
Triple(Icons.Rounded.Fingerprint, R.string.add_account_certificate_fingerprint, certificate.displayFingerprint),
|
||||||
|
Triple(Icons.Rounded.Badge, R.string.add_account_certificate_subject, certificate.subject),
|
||||||
|
Triple(Icons.Rounded.Verified, R.string.add_account_certificate_issuer, certificate.issuer),
|
||||||
|
Triple(Icons.Rounded.Event, R.string.add_account_certificate_expires, expires),
|
||||||
|
)
|
||||||
|
SelectionContainer {
|
||||||
|
Column {
|
||||||
|
rows.forEachIndexed { index, (icon, title, value) ->
|
||||||
|
GroupedRow(
|
||||||
|
title = AnnotatedString(stringResource(title)),
|
||||||
|
summary = if (index == 0) {
|
||||||
|
buildAnnotatedString { withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(value) } }
|
||||||
|
} else {
|
||||||
|
AnnotatedString(value)
|
||||||
|
},
|
||||||
|
position = positionOf(index, rows.size),
|
||||||
|
leading = { Icon(icon, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
@@ -31,8 +31,8 @@ internal fun AccountSections(
|
|||||||
// Which account the detail screen is showing; the section alone cannot say.
|
// Which account the detail screen is showing; the section alone cannot say.
|
||||||
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
var openAccount by rememberSaveable(initialAccountId) { mutableStateOf(initialAccountId) }
|
||||||
|
|
||||||
// Accounts stays composed under Add account, for the same reason Storage
|
// Accounts stays composed under Add account, for the same reason Backup
|
||||||
// stays composed under Export: the deeper screen slides over it.
|
// stays composed under export and import: the deeper screen slides over it.
|
||||||
val accountsOpen = section == SettingsSection.Accounts ||
|
val accountsOpen = section == SettingsSection.Accounts ||
|
||||||
section?.parent == SettingsSection.Accounts
|
section?.parent == SettingsSection.Accounts
|
||||||
SlideInSection(visible = accountsOpen) {
|
SlideInSection(visible = accountsOpen) {
|
||||||
@@ -50,9 +50,11 @@ internal fun AccountSections(
|
|||||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||||
onBack = { onSection(null) },
|
onBack = { onSection(null) },
|
||||||
viewModel = accountsViewModel,
|
viewModel = accountsViewModel,
|
||||||
|
visible = accountsOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SlideInSection(visible = section == SettingsSection.Account) {
|
val accountOpen = section == SettingsSection.Account
|
||||||
|
SlideInSection(visible = accountOpen) {
|
||||||
openAccount?.let { id ->
|
openAccount?.let { id ->
|
||||||
AccountDetailScreen(
|
AccountDetailScreen(
|
||||||
accountId = id,
|
accountId = id,
|
||||||
@@ -61,6 +63,7 @@ internal fun AccountSections(
|
|||||||
onSignInAgain = signInAgain,
|
onSignInAgain = signInAgain,
|
||||||
onOpenStorage = { onSection(SettingsSection.Storage) },
|
onOpenStorage = { onSection(SettingsSection.Storage) },
|
||||||
viewModel = accountsViewModel,
|
viewModel = accountsViewModel,
|
||||||
|
visible = accountOpen,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
default trust anchors. That breaks exactly the self-hosting audience this app
|
default trust anchors. That breaks exactly the self-hosting audience this app
|
||||||
is for, so they are added back here.
|
is for, so they are added back here.
|
||||||
|
|
||||||
Cleartext stays off. It has been the default since API 28, and CalDavDiscovery
|
Cleartext is permitted here and gated in code instead, per account: every
|
||||||
refuses a typed http:// address for the same reason: credentials are never
|
client CalDavHttp builds leaves ConnectionSpec.CLEARTEXT out unless the user
|
||||||
sent over an unencrypted connection. Any escape hatch has to be a narrow,
|
opted that account into plain http behind an explicit warning, so OkHttp
|
||||||
warned, per-account opt-in — Play's User Data policy requires modern
|
itself refuses an http:// URL for everyone else. This file cannot express a
|
||||||
cryptography in transit — and this file is not the place for it.
|
per-account choice, only a per-host one, and the hosts are the user's.
|
||||||
|
|
||||||
|
⚠️ That makes CalDavHttp the only gate. A client built any other way would
|
||||||
|
speak http silently, so HttpClientConstructionTest fails the build on one.
|
||||||
|
|
||||||
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
|
⚠️ KNOWN TRADE-OFF, and it is the widest form of this. base-config applies to
|
||||||
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
|
*all* traffic, so any CA in the user store — a corporate MDM profile, a "free
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
sit alongside it.
|
sit alongside it.
|
||||||
-->
|
-->
|
||||||
<network-security-config>
|
<network-security-config>
|
||||||
<base-config cleartextTrafficPermitted="false">
|
<base-config cleartextTrafficPermitted="true">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
<certificates src="user" />
|
<certificates src="user" />
|
||||||
|
|||||||
@@ -9,13 +9,26 @@
|
|||||||
selected External mode. Both are dangerous-level.
|
selected External mode. Both are dangerous-level.
|
||||||
|
|
||||||
StorageMode.OWN needs nothing here: it is a Room database in our own data
|
StorageMode.OWN needs nothing here: it is a Room database in our own data
|
||||||
directory. Agendula publishes no ContentProvider and declares no
|
directory. -->
|
||||||
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" />
|
||||||
|
|
||||||
|
<!-- What another app requests to read or write the own store through
|
||||||
|
AgendulaTaskProvider. Dangerous, like OpenTasks' own: the user grants
|
||||||
|
them per app. Suffixed per build, so debug and release can coexist. -->
|
||||||
|
<permission
|
||||||
|
android:name="${applicationId}.permission.READ_TASKS"
|
||||||
|
android:description="@string/permission_read_tasks_description"
|
||||||
|
android:label="@string/permission_read_tasks_label"
|
||||||
|
android:protectionLevel="dangerous" />
|
||||||
|
<permission
|
||||||
|
android:name="${applicationId}.permission.WRITE_TASKS"
|
||||||
|
android:description="@string/permission_write_tasks_description"
|
||||||
|
android:label="@string/permission_write_tasks_label"
|
||||||
|
android:protectionLevel="dangerous" />
|
||||||
|
|
||||||
<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. User-granted: USE_EXACT_ALARM is reserved for
|
<!-- Exact due-time reminders. User-granted: USE_EXACT_ALARM is reserved for
|
||||||
@@ -37,6 +50,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".AgendulaApp"
|
android:name=".AgendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -51,10 +65,8 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
|
||||||
<action android:name="android.intent.action.MAIN" />
|
name can be switched at runtime (#104). -->
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<!-- Shared text becomes a new task's title. -->
|
<!-- Shared text becomes a new task's title. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
@@ -75,6 +87,32 @@
|
|||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".DefaultNameAlias"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".TasksNameAlias"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name_tasks_alias"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
|
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
|
||||||
<service
|
<service
|
||||||
@@ -233,6 +271,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- The own store under the OpenTasks contract. Empty, and refusing
|
||||||
|
writes, while External mode is active. -->
|
||||||
|
<provider
|
||||||
|
android:name=".data.tasks.provider.AgendulaTaskProvider"
|
||||||
|
android:authorities="${applicationId}.tasks"
|
||||||
|
android:exported="true"
|
||||||
|
android:readPermission="${applicationId}.permission.READ_TASKS"
|
||||||
|
android:writePermission="${applicationId}.permission.WRITE_TASKS"
|
||||||
|
tools:ignore="SystemPermissionTypo" />
|
||||||
|
|
||||||
<!-- WorkManager's on-demand initialisation. Removing the default
|
<!-- WorkManager's on-demand initialisation. Removing the default
|
||||||
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
initializer is what lets AgendulaApp supply a HiltWorkerFactory, so
|
||||||
@HiltWorker workers can take injected dependencies. -->
|
@HiltWorker workers can take injected dependencies. -->
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
|||||||
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.provider.ProviderSnapshotSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
|
||||||
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
import de.jeanlucmakiola.agendula.widget.TaskWidgetUpdater
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
@@ -87,6 +88,7 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
scheduler.sync()
|
scheduler.sync()
|
||||||
}
|
}
|
||||||
runCatching { entryPoint.taskWidgetUpdater().start() }
|
runCatching { entryPoint.taskWidgetUpdater().start() }
|
||||||
|
runCatching { entryPoint.providerSnapshotSource().start() }
|
||||||
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
runCatching { ReminderMaintenanceWorker.schedule(this@AgendulaApp) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,5 +124,6 @@ class AgendulaApp : Application(), Configuration.Provider {
|
|||||||
fun taskNotifier(): TaskNotifier
|
fun taskNotifier(): TaskNotifier
|
||||||
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
fun channelRefreshers(): Set<@JvmSuppressWildcards ChannelRefresher>
|
||||||
fun taskWidgetUpdater(): TaskWidgetUpdater
|
fun taskWidgetUpdater(): TaskWidgetUpdater
|
||||||
|
fun providerSnapshotSource(): ProviderSnapshotSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
|
|||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
|
||||||
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.agendula.ui.RootScreen
|
import de.jeanlucmakiola.agendula.ui.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.ui.navigation.NavRequest
|
||||||
@@ -88,15 +87,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
val darkTheme = when (ui.settings.themeMode) {
|
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
|
||||||
ThemeMode.LIGHT -> false
|
|
||||||
ThemeMode.DARK -> true
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
|
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
|
||||||
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
|
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
|
||||||
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
AgendulaTheme(
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
dynamicColor = ui.settings.dynamicColor,
|
||||||
|
pureBlack = ui.settings.themeMode.isPureBlack,
|
||||||
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalFirstDayOfWeek provides firstDayOfWeek,
|
LocalFirstDayOfWeek provides firstDayOfWeek,
|
||||||
@@ -105,6 +104,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
navRequest = navRequest,
|
navRequest = navRequest,
|
||||||
onNavRequestConsumed = { navRequest = null },
|
onNavRequestConsumed = { navRequest = null },
|
||||||
|
onReturnHome = ::finish,
|
||||||
)
|
)
|
||||||
pendingCrashReport?.let { report ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
|
||||||
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_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 EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
|
||||||
|
private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
|
||||||
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
|
||||||
private const val SHARED_TITLE_LIMIT = 500
|
private const val SHARED_TITLE_LIMIT = 500
|
||||||
private const val NO_OCCURRENCE = -1L
|
private const val NO_OCCURRENCE = -1L
|
||||||
@@ -161,10 +162,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
|
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
|
||||||
* picks the occurrence of a recurring task.
|
* picks the occurrence of a recurring task.
|
||||||
*/
|
*/
|
||||||
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
|
fun taskIntent(
|
||||||
|
context: Context,
|
||||||
|
taskId: Long,
|
||||||
|
occurrenceStart: Long? = null,
|
||||||
|
returnHome: Boolean = false,
|
||||||
|
): 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)
|
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
|
||||||
|
putExtra(EXTRA_RETURN_HOME, returnHome)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +223,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
ACTION_NEW_TASK -> return NavRequest.NewTask(
|
||||||
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
|
||||||
|
returnHome = true,
|
||||||
)
|
)
|
||||||
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
|
||||||
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
|
||||||
@@ -230,12 +238,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
|
||||||
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
|
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
|
||||||
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
|
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true)
|
||||||
}
|
}
|
||||||
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
|
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
|
||||||
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
|
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
|
||||||
.takeIf { it != NO_OCCURRENCE }
|
.takeIf { it != NO_OCCURRENCE }
|
||||||
return NavRequest.OpenTask(taskId, occurrence)
|
return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.appname
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The label the launcher shows for the app (#104). */
|
||||||
|
enum class LauncherName { AGENDULA, TASKS }
|
||||||
|
|
||||||
|
/** The two launcher aliases declared in the manifest. */
|
||||||
|
enum class LauncherAlias { DEFAULT, TASKS }
|
||||||
|
|
||||||
|
/** One component-enable change in an [aliasWritePlan]. */
|
||||||
|
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
|
||||||
|
* enable means "Tasks"; never toggled and disabled both mean the manifest default.
|
||||||
|
*/
|
||||||
|
fun launcherNameFor(tasksAliasState: Int): LauncherName =
|
||||||
|
if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
LauncherName.TASKS
|
||||||
|
} else {
|
||||||
|
LauncherName.AGENDULA
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The steps to switch to [target]: enable the target alias first, then disable
|
||||||
|
* the other, so the launcher can briefly see two entries but never none.
|
||||||
|
*/
|
||||||
|
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||||
|
LauncherName.TASKS -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.TASKS, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
)
|
||||||
|
LauncherName.AGENDULA -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.TASKS, enabled = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the launcher label by enabling one of the two `<activity-alias>`
|
||||||
|
* components. The component state is the only source of truth; nothing is
|
||||||
|
* stored in prefs.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LauncherNameManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val packageManager: PackageManager get() = context.packageManager
|
||||||
|
|
||||||
|
// The package is the (suffixed) applicationId, the class is namespace-qualified.
|
||||||
|
private fun component(alias: LauncherAlias): ComponentName {
|
||||||
|
val simpleName = when (alias) {
|
||||||
|
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||||
|
LauncherAlias.TASKS -> "TasksNameAlias"
|
||||||
|
}
|
||||||
|
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun current(): LauncherName =
|
||||||
|
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS)))
|
||||||
|
|
||||||
|
fun set(name: LauncherName) {
|
||||||
|
for (change in aliasWritePlan(name)) {
|
||||||
|
val state = if (change.enabled) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP)
|
||||||
|
}
|
||||||
|
// Dynamic shortcuts hang off the launcher activity, which just changed.
|
||||||
|
AppShortcuts.publish(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */
|
||||||
|
const val NAMESPACE = "de.jeanlucmakiola.agendula"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ object DataProvideModule {
|
|||||||
// backed-up `.db` does not. The backup rules carry all three files and
|
// backed-up `.db` does not. The backup rules carry all three files and
|
||||||
// the app checkpoints on ON_STOP.
|
// the app checkpoints on ON_STOP.
|
||||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||||
.addMigrations(TasksDatabase.MIGRATION_1_2)
|
.addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import de.jeanlucmakiola.agendula.domain.SavedFilter
|
|||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
|
import de.jeanlucmakiola.agendula.domain.asTaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.asReminderLead
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||||
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
|
||||||
@@ -27,7 +32,18 @@ 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 }
|
/** [BLACK] is dark with pure-black surfaces, for AMOLED screens. */
|
||||||
|
enum class ThemeMode {
|
||||||
|
SYSTEM, LIGHT, DARK, BLACK;
|
||||||
|
|
||||||
|
fun isDark(systemDark: Boolean): Boolean = when (this) {
|
||||||
|
SYSTEM -> systemDark
|
||||||
|
LIGHT -> false
|
||||||
|
DARK, BLACK -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
val isPureBlack: Boolean get() = this == BLACK
|
||||||
|
}
|
||||||
|
|
||||||
/** Clock convention: AUTO follows the device's 24-hour switch. */
|
/** Clock convention: AUTO follows the device's 24-hour switch. */
|
||||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||||
@@ -39,6 +55,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
|
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
|
||||||
|
|
||||||
/** The snooze lengths Settings offers. */
|
/** The snooze lengths Settings offers. */
|
||||||
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
@@ -57,10 +74,10 @@ data class Settings(
|
|||||||
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 reminders, as minutes before due (0 = at due time); empty = none. */
|
/** Default reminders (0 before due = at due time); empty = none. */
|
||||||
val defaultReminderMinutes: List<Int> = listOf(0),
|
val defaultReminders: List<ReminderLead> = listOf(ReminderLead(0)),
|
||||||
/** Default reminders for all-day tasks, as days-scale minutes before [allDayReminderMinuteOfDay]. */
|
/** Default reminders for all-day tasks, in whole days, rung at [allDayReminderMinuteOfDay]. */
|
||||||
val defaultAllDayReminderMinutes: List<Int> = listOf(0),
|
val defaultAllDayReminders: List<ReminderLead> = listOf(ReminderLead(0)),
|
||||||
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
|
/** Local time (minutes from midnight) an all-day task's reminder counts back from. */
|
||||||
val allDayReminderMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
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. */
|
||||||
@@ -74,13 +91,13 @@ data class Settings(
|
|||||||
*/
|
*/
|
||||||
val bottomAddBar: Boolean = false,
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [defaultReminderMinutes]: a list present in the map
|
* Per-list overrides of [defaultReminders]: 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.
|
* inherit.
|
||||||
*/
|
*/
|
||||||
val perListReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
val perListReminderOverride: Map<Long, List<ReminderLead>> = emptyMap(),
|
||||||
/** Per-list overrides of [defaultAllDayReminderMinutes], same shape as [perListReminderOverride]. */
|
/** Per-list overrides of [defaultAllDayReminders], same shape as [perListReminderOverride]. */
|
||||||
val perListAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
val perListAllDayReminderOverride: Map<Long, List<ReminderLead>> = 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,
|
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
|
||||||
@@ -99,6 +116,8 @@ data class Settings(
|
|||||||
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
val hiddenFromSmartLists: Set<Long> = emptySet(),
|
||||||
/** Pre-fill a new task's start with today's date. */
|
/** Pre-fill a new task's start with today's date. */
|
||||||
val defaultStartToday: Boolean = false,
|
val defaultStartToday: Boolean = false,
|
||||||
|
/** Task-list sections folded away; Done starts folded. */
|
||||||
|
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
|
||||||
) {
|
) {
|
||||||
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
|
||||||
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
|
||||||
@@ -109,12 +128,16 @@ data class Settings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The lead times for a task in [listId]: its override if set, else the global default. */
|
/** The lead times for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadsFor(listId: Long): List<Int> =
|
fun reminderLeadsFor(listId: Long): List<ReminderLead> =
|
||||||
perListReminderOverride.reminderLeadsFor(listId, defaultReminderMinutes)
|
perListReminderOverride.reminderLeadsFor(listId, defaultReminders)
|
||||||
|
|
||||||
/** The lead times for an all-day task in [listId]. */
|
/** The lead times for an all-day task in [listId]. */
|
||||||
fun allDayReminderLeadsFor(listId: Long): List<Int> =
|
fun allDayReminderLeadsFor(listId: Long): List<ReminderLead> =
|
||||||
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminderMinutes)
|
perListAllDayReminderOverride.reminderLeadsFor(listId, defaultAllDayReminders)
|
||||||
|
|
||||||
|
/** The default reminders a task in [listId] takes — the one rule for which default applies. */
|
||||||
|
fun defaultRemindersFor(listId: Long, allDay: Boolean): List<TaskReminder> =
|
||||||
|
(if (allDay) allDayReminderLeadsFor(listId) else reminderLeadsFor(listId)).map { it.asTaskReminder() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@@ -130,16 +153,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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 },
|
||||||
// The single lead of earlier versions carries over until a list is saved.
|
// The single lead of earlier versions carries over until a list is saved.
|
||||||
defaultReminderMinutes = p[DEFAULT_REMINDERS]?.let(::parseMinutes)
|
defaultReminders = p[DEFAULT_REMINDERS]?.let(::parseLeads)
|
||||||
?: listOf(p[REMINDER_LEAD] ?: 0),
|
?: listOf(ReminderLead(p[REMINDER_LEAD] ?: 0)),
|
||||||
defaultAllDayReminderMinutes = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseMinutes) ?: listOf(0),
|
defaultAllDayReminders = p[DEFAULT_ALL_DAY_REMINDERS]?.let(::parseLeads)?.inWholeDays()
|
||||||
|
?: listOf(ReminderLead(0)),
|
||||||
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
|
allDayReminderMinuteOfDay = p[ALL_DAY_REMINDER_MINUTE]?.takeIf { it in 0 until 24 * 60 }
|
||||||
?: DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
?: 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.parseLeads(p[LIST_REMINDER_OVERRIDE]),
|
||||||
perListAllDayReminderOverride = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]),
|
perListAllDayReminderOverride = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE])
|
||||||
|
.mapValues { it.value.inWholeDays() },
|
||||||
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(),
|
||||||
@@ -158,9 +183,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.toSet()
|
.toSet()
|
||||||
},
|
},
|
||||||
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
|
||||||
|
collapsedSections = p[COLLAPSED_SECTIONS]
|
||||||
|
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
|
||||||
|
?.toSet()
|
||||||
|
?: DEFAULT_COLLAPSED_SECTIONS,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
|
||||||
|
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
|
||||||
|
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
|
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 setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
|
||||||
@@ -185,12 +219,12 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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 setDefaultReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
suspend fun setDefaultReminders(leads: List<ReminderLead>) = dataStore.edit {
|
||||||
it[DEFAULT_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
it[DEFAULT_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = dataStore.edit {
|
suspend fun setDefaultAllDayReminders(leads: List<ReminderLead>) = dataStore.edit {
|
||||||
it[DEFAULT_ALL_DAY_REMINDERS] = minutes.normalizeReminders().joinToString(",")
|
it[DEFAULT_ALL_DAY_REMINDERS] = leads.normalizeReminders().joinToString(",") { lead -> lead.toToken() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
|
suspend fun setAllDayReminderMinuteOfDay(minuteOfDay: Int) =
|
||||||
@@ -243,16 +277,16 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
val current = reminderCodec.parseLeads(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
current.applyReminderOverride(listId, override)
|
current.applyReminderOverride(listId, override)
|
||||||
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set (or clear) a list's all-day reminder override. */
|
/** Set (or clear) a list's all-day reminder override. */
|
||||||
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
suspend fun setListAllDayReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
val current = reminderCodec.parse(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
|
val current = reminderCodec.parseLeads(p[LIST_ALL_DAY_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
current.applyReminderOverride(listId, override)
|
current.applyReminderOverride(listId, override)
|
||||||
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
p[LIST_ALL_DAY_REMINDER_OVERRIDE] = reminderCodec.serializeLeads(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
|
suspend fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = dataStore.edit { p ->
|
||||||
@@ -318,6 +352,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
|
||||||
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
|
||||||
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
|
||||||
|
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
|
||||||
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
|
||||||
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
|
||||||
}
|
}
|
||||||
@@ -330,6 +365,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
|
|
||||||
/** `5,30` → [5, 30]; an empty string is an explicit "no reminder". */
|
/** `s5,30` → [5 before start, 30 before due]; an empty string is an explicit "no reminder". */
|
||||||
private fun parseMinutes(stored: String): List<Int> =
|
private fun parseLeads(stored: String): List<ReminderLead> =
|
||||||
stored.split(',').mapNotNull { it.trim().toIntOrNull()?.takeIf { m -> m >= 0 } }.normalizeReminders()
|
stored.split(',').mapNotNull { ReminderLead.parse(it)?.takeIf { lead -> lead.minutes >= 0 } }.normalizeReminders()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All-day defaults as whole days. Before 1.2 an all-day default could be hours
|
||||||
|
* before the all-day time; a lead that is not whole days now names a time of day.
|
||||||
|
*/
|
||||||
|
private fun List<ReminderLead>.inWholeDays(): List<ReminderLead> =
|
||||||
|
map { it.asTaskReminder().inWholeDays().asReminderLead() }.normalizeReminders()
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.reminders
|
package de.jeanlucmakiola.agendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.AllDayReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.calendarDate
|
import de.jeanlucmakiola.agendula.domain.calendarDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -36,31 +37,53 @@ internal object ReminderPlanner {
|
|||||||
// One reminder per *occurrence* and lead: a recurring series yields a row
|
// 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
|
// per occurrence, all sharing a taskId, so this is a Set rather than a
|
||||||
// taskId-keyed Map.
|
// taskId-keyed Map.
|
||||||
.filter { !it.isClosed && it.due != null }
|
.filter { !it.isClosed && (it.due != null || it.start != null) }
|
||||||
.flatMap { task ->
|
.flatMap { task ->
|
||||||
// Reminders set on the task itself win — every one of them, not just
|
// Reminders set on the task itself win — every one of them;
|
||||||
// the one the editor shows; otherwise the task's list may override the
|
// otherwise the task's list may override the global lead, or opt
|
||||||
// global lead, or opt out entirely (override = null). All-day tasks
|
// out entirely (override = null). All-day tasks have their own
|
||||||
// have their own day-scale defaults.
|
// day-scale defaults.
|
||||||
val reminders = perTask[task.taskId].orEmpty().ifEmpty {
|
//
|
||||||
val leads = if (task.isAllDay) settings.allDayReminderLeadsFor(task.listId)
|
// ⚠️ Present-but-empty is "none", not "the default": the store's
|
||||||
else settings.reminderLeadsFor(task.listId)
|
// answer for a task whose alarms another client turned off. A
|
||||||
leads.map { TaskReminder(it) }
|
// default here would keep ringing for what the user switched off.
|
||||||
}
|
// A default counts only from a date the task has; its own reminders
|
||||||
reminders.map { reminder ->
|
// stand in each date for the other when the task lacks it.
|
||||||
// A stored reminder says what it counts back from. Ours are always
|
val own = perTask[task.taskId]
|
||||||
// before due, but an imported dmfs alarm or another client's can be
|
val reminders = own ?: settings.defaultRemindersFor(task.listId, task.isAllDay)
|
||||||
// before *start*.
|
.filter { (if (it.fromStart) task.start else task.due) != null }
|
||||||
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due!!
|
val created = task.created?.toEpochMilliseconds()
|
||||||
|
reminders.mapNotNull { reminder ->
|
||||||
|
val anchor = if (reminder.fromStart) task.start ?: task.due!! else task.due ?: task.start!!
|
||||||
|
val trigger = triggerAt(
|
||||||
|
anchor,
|
||||||
|
task.isAllDay,
|
||||||
|
reminder.minutesBefore,
|
||||||
|
settings.allDayReminderMinuteOfDay,
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
// A default never rings for a moment before the task existed:
|
||||||
|
// that is a task saved after its default had passed, not a
|
||||||
|
// reminder missed while the device was off.
|
||||||
|
if (own == null && created != null && trigger < created) return@mapNotNull null
|
||||||
|
// Dismissed on another client already: firing it here within
|
||||||
|
// the missed-reminder grace would ring for something done with.
|
||||||
|
//
|
||||||
|
// ⚠️ An all-day reminder without a time of its own is compared
|
||||||
|
// from the start of its day, not from this device's time of
|
||||||
|
// day: other clients ring it at midnight, or at their own hour,
|
||||||
|
// so a dismissal at 07:00 must still silence the 09:00 this
|
||||||
|
// device would ring it at.
|
||||||
|
val earliest = if (task.isAllDay && reminder.allDay.minuteOfDay == null) {
|
||||||
|
triggerAt(anchor, true, reminder.minutesBefore, 0, zone)
|
||||||
|
} else {
|
||||||
|
trigger
|
||||||
|
}
|
||||||
|
val acknowledged = reminder.acknowledgedAt?.let { earliest <= it.toEpochMilliseconds() } == true
|
||||||
|
if (acknowledged) return@mapNotNull null
|
||||||
ScheduledReminder(
|
ScheduledReminder(
|
||||||
taskId = task.taskId,
|
taskId = task.taskId,
|
||||||
triggerAt = triggerAt(
|
triggerAt = trigger,
|
||||||
anchor,
|
|
||||||
task.isAllDay,
|
|
||||||
reminder.minutesBefore,
|
|
||||||
settings.allDayReminderMinuteOfDay,
|
|
||||||
zone,
|
|
||||||
),
|
|
||||||
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
|
occurrenceStart = task.occurrenceStart?.toEpochMilliseconds() ?: ScheduledReminder.NO_OCCURRENCE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,8 +103,9 @@ internal object ReminderPlanner {
|
|||||||
*
|
*
|
||||||
* ⚠️ An all-day anchor is UTC midnight of its date (see `AllDayTime.kt`), a
|
* ⚠️ 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
|
* 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
|
* Berlin and the evening before in New York. It is read as its calendar
|
||||||
* at [allDayMinuteOfDay] local time instead, and the lead counts back from that.
|
* date instead, and the lead as [TaskReminder.allDay]: whole days ring at
|
||||||
|
* [allDayMinuteOfDay] local time, anything else at the time it names.
|
||||||
*/
|
*/
|
||||||
fun triggerAt(
|
fun triggerAt(
|
||||||
anchor: Instant,
|
anchor: Instant,
|
||||||
@@ -90,12 +114,11 @@ internal object ReminderPlanner {
|
|||||||
allDayMinuteOfDay: Int,
|
allDayMinuteOfDay: Int,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Long {
|
): Long {
|
||||||
val base = if (allDay) {
|
if (!allDay) return anchor.toEpochMilliseconds() - leadMinutes.coerceAtLeast(0) * 60_000L
|
||||||
val time = LocalTime.of(allDayMinuteOfDay / 60 % 24, allDayMinuteOfDay % 60)
|
val (days, minuteOfDay) = AllDayReminder.of(leadMinutes)
|
||||||
anchor.calendarDate(allDay = true).atTime(time).atZone(zone).toInstant().toEpochMilli()
|
val minute = minuteOfDay ?: allDayMinuteOfDay
|
||||||
} else {
|
val time = LocalTime.of(minute / 60 % 24, minute % 60)
|
||||||
anchor.toEpochMilliseconds()
|
return anchor.calendarDate(allDay = true).minusDays(days.toLong())
|
||||||
}
|
.atTime(time).atZone(zone).toInstant().toEpochMilli()
|
||||||
return base - leadMinutes.coerceAtLeast(0) * 60_000L
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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.Settings
|
||||||
|
import de.jeanlucmakiola.agendula.domain.readableDescription
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
@@ -84,6 +85,7 @@ class TaskNotifier @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val tapIntent = PendingIntent.getActivity(
|
val tapIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
task.taskId.toInt(),
|
task.taskId.toInt(),
|
||||||
@@ -93,7 +95,10 @@ class TaskNotifier @Inject constructor(
|
|||||||
val notification = NotificationCompat.Builder(context, channelId)
|
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 { (text ?: description)?.let(::setContentText) }
|
||||||
|
.apply {
|
||||||
|
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
|
||||||
|
}
|
||||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -240,3 +245,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The expanded body of a due reminder: the due line, then the task's description. */
|
||||||
|
internal fun expandedText(dueLine: String?, description: String?): String? = when {
|
||||||
|
description == null -> null
|
||||||
|
dueLine == null -> description
|
||||||
|
else -> "$dueLine\n\n$description"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.sync
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */
|
||||||
|
interface ManualSync {
|
||||||
|
/** Whether there is anything to sync: our own store, with at least one account. */
|
||||||
|
val available: Flow<Boolean>
|
||||||
|
|
||||||
|
/** True from [syncAll] until the syncs it started have finished. */
|
||||||
|
val syncing: StateFlow<Boolean>
|
||||||
|
|
||||||
|
fun syncAll()
|
||||||
|
|
||||||
|
object None : ManualSync {
|
||||||
|
override val available: Flow<Boolean> = flowOf(false)
|
||||||
|
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
|
||||||
|
override fun syncAll() = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync
|
|||||||
|
|
||||||
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
/** Asks for an account to be synced now. A no-op in the `offline` flavor. */
|
||||||
fun interface SyncRequests {
|
fun interface SyncRequests {
|
||||||
fun syncNow(accountName: String)
|
suspend fun syncNow(accountId: Long)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import android.content.ContentProviderOperation
|
import android.content.ContentProviderOperation
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
@@ -85,6 +86,27 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
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()))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read from the `tasks` table, so a recurring subtask with no occurrence in
|
||||||
|
* the window still counts. All columns and a lenient `_deleted`, as in
|
||||||
|
* [exportTasks], since the table's shape varies across providers.
|
||||||
|
*/
|
||||||
|
override fun subtaskIds(parentTaskId: Long): List<Long> = resolver.query(
|
||||||
|
TasksContract.tasksUri(authority()),
|
||||||
|
null,
|
||||||
|
"${Tasks.PARENT_ID} = ? AND (${Tasks.DELETED} IS NULL OR ${Tasks.DELETED} = 0)",
|
||||||
|
arrayOf(parentTaskId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
if (r.getLong(Tasks.ORIGINAL_INSTANCE_ID) != null) continue
|
||||||
|
r.getLong(Tasks.ID)?.takeIf { it != parentTaskId }?.let(::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
private fun queryInstances(selection: String?, args: Array<String>?): List<Task> {
|
||||||
val uri = TasksContract.instancesUri(authority())
|
val uri = TasksContract.instancesUri(authority())
|
||||||
// projection = null (all columns): the instances view differs across
|
// projection = null (all columns): the instances view differs across
|
||||||
@@ -127,9 +149,10 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
|
|
||||||
// --- writes ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||||
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.seriesValues(form, ZoneId.systemDefault().id)
|
||||||
// One batch, so a refused tag leaves no task behind for a retry to duplicate.
|
// One batch, so a refused tag or alarm leaves no task behind for a retry
|
||||||
|
// to duplicate.
|
||||||
val authority = authority()
|
val authority = authority()
|
||||||
val ops = arrayListOf(
|
val ops = arrayListOf(
|
||||||
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
ContentProviderOperation.newInsert(TasksContract.tasksUri(authority))
|
||||||
@@ -142,20 +165,35 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
.withValueBackReference(Properties.TASK_ID, 0)
|
.withValueBackReference(Properties.TASK_ID, 0)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
reminders.orEmpty().forEach { reminder ->
|
||||||
|
ops += ContentProviderOperation.newInsert(TasksContract.propertiesUri(authority))
|
||||||
|
.withValues(
|
||||||
|
TaskWriteMapper.alarmValues(0, reminder.minutesBefore, reminder.fromStart)
|
||||||
|
.toContentValues().apply { remove(Properties.TASK_ID) },
|
||||||
|
)
|
||||||
|
.withValueBackReference(Properties.TASK_ID, 0)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
val results = runCatching { resolver.applyBatch(authority, ops) }
|
val results = runCatching { resolver.applyBatch(authority, ops) }
|
||||||
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
.getOrElse { throw TaskWriteFailedException("insert task", it) }
|
||||||
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
return results.firstOrNull()?.uri?.lastPathSegment?.toLongOrNull()
|
||||||
?: throw TaskWriteFailedException("insert task: no id")
|
?: throw TaskWriteFailedException("insert task: no id")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
val values = TaskWriteMapper.seriesValues(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")
|
||||||
setCategories(taskId, form.tags)
|
setCategories(taskId, form.tags)
|
||||||
|
writeReminders(taskId, reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) {
|
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
||||||
|
if (reminders == null) return
|
||||||
|
setReminders(taskId, reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
val occurrence = task(seriesId, occurrenceStart)
|
val occurrence = task(seriesId, occurrenceStart)
|
||||||
val anchors = resolver.query(
|
val anchors = resolver.query(
|
||||||
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
taskUri(authority(), seriesId), arrayOf(Tasks.DTSTART, Tasks.DUE), null, null, null,
|
||||||
@@ -175,6 +213,7 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
val rows = resolver.update(taskUri(authority(), seriesId), values.toContentValues(), null, null)
|
||||||
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
if (rows == 0) throw TaskWriteFailedException("update series $seriesId")
|
||||||
setCategories(seriesId, form.tags)
|
setCategories(seriesId, form.tags)
|
||||||
|
writeReminders(seriesId, reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,10 +222,10 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
* task. Per-occurrence edits past the split point are dropped rather than
|
* task. Per-occurrence edits past the split point are dropped rather than
|
||||||
* carried over — the provider offers no way to re-parent an exception.
|
* carried over — the provider offers no way to re-parent an exception.
|
||||||
*/
|
*/
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||||
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
val spec = seriesSpec(seriesId) ?: throw TaskWriteFailedException("split series $seriesId")
|
||||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
if (spec.rrule == null || occurrenceStart <= spec.anchor) {
|
||||||
updateSeries(seriesId, occurrenceStart, form)
|
updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
return seriesId
|
return seriesId
|
||||||
}
|
}
|
||||||
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
// COUNT counts before EXDATE is applied (RFC 5545 §3.8.5.3).
|
||||||
@@ -196,12 +235,12 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
).size
|
).size
|
||||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||||
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
val rule = form.rrule?.let { if (it == spec.rrule) TaskFormWriter.remainingCount(it, spent) else it }
|
||||||
return insertTask(form.copy(rrule = rule, percentComplete = null))
|
return insertTask(form.copy(rrule = rule, percentComplete = null), reminders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) {
|
||||||
val spec = seriesSpec(seriesId) ?: return
|
val spec = seriesSpec(seriesId) ?: return
|
||||||
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteTask(seriesId)
|
if (spec.rrule == null || occurrenceStart <= spec.anchor) return deleteWithSubtasks(seriesId)
|
||||||
endSeriesBefore(seriesId, spec, occurrenceStart)
|
endSeriesBefore(seriesId, spec, occurrenceStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +301,23 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
resolver.delete(TasksContract.instanceUri(authority(), instanceId), null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) {
|
||||||
|
if (reminders == null) return writeInstance(taskId, occurrenceStart, form)
|
||||||
|
// ⚠️ 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 = alarmRows(taskId).map { it.second }
|
||||||
|
writeReminders(taskId, reminders)
|
||||||
|
try {
|
||||||
|
writeInstance(taskId, occurrenceStart, form)
|
||||||
|
} finally {
|
||||||
|
setReminders(taskId, seriesReminders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||||
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
val instanceId = instanceIdFor(taskId, occurrenceStart)
|
||||||
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
?: throw TaskWriteFailedException("update instance $taskId@$occurrenceStart: no such occurrence")
|
||||||
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
val values = TaskWriteMapper.instanceValues(form, ZoneId.systemDefault().id)
|
||||||
@@ -315,21 +370,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>) {
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) {
|
||||||
val uri = TasksContract.propertiesUri(authority())
|
val uri = TasksContract.propertiesUri(authority())
|
||||||
resolver.delete(
|
resolver.delete(
|
||||||
@@ -346,9 +386,6 @@ class AndroidTasksDataSource @Inject constructor(
|
|||||||
override fun reminders(): Map<Long, List<TaskReminder>> =
|
override fun reminders(): Map<Long, List<TaskReminder>> =
|
||||||
readAlarms(null).groupBy({ it.first }) { it.third }
|
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. */
|
/** `(property_id, reminder)` for [taskId]'s alarm rows, in stored order. */
|
||||||
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
|
private fun alarmRows(taskId: Long): List<Pair<Long, TaskReminder>> =
|
||||||
readAlarms(taskId).map { it.second to it.third }
|
readAlarms(taskId).map { it.second to it.third }
|
||||||
|
|||||||
+12
-10
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
@@ -34,23 +35,24 @@ class ModeRoutingTasksDataSource(
|
|||||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||||
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
override fun task(taskId: Long, occurrenceStart: Instant?): Task? = active().task(taskId, occurrenceStart)
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) =
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
active().updateSeries(seriesId, occurrenceStart, form)
|
active().updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long =
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
|
||||||
active().splitSeries(seriesId, occurrenceStart, form)
|
active().splitSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) =
|
||||||
active().deleteOccurrence(seriesId, occurrenceStart)
|
active().deleteOccurrence(seriesId, occurrenceStart)
|
||||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) =
|
||||||
active().deleteFollowing(seriesId, occurrenceStart)
|
active().deleteFollowing(seriesId, occurrenceStart)
|
||||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||||
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
override fun subtaskIds(parentTaskId: Long): List<Long> = active().subtaskIds(parentTaskId)
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
override fun deleteWithSubtasks(taskId: Long) = active().deleteWithSubtasks(taskId)
|
||||||
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long = active().insertTask(form, reminders)
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
|
active().updateTask(taskId, form, reminders)
|
||||||
|
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
|
||||||
active().updateInstance(taskId, occurrenceStart, form)
|
active().updateInstance(taskId, occurrenceStart, form, reminders)
|
||||||
|
|
||||||
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 reminders(): Map<Long, List<TaskReminder>> = active().reminders()
|
||||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) =
|
||||||
active().setReminders(taskId, reminders)
|
active().setReminders(taskId, reminders)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
internal object StoredReminders {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminders to store for [form], given the task as it was.
|
||||||
|
*
|
||||||
|
* A date the task gets in this write brings the list's or the app's
|
||||||
|
* default for it *as the task's own reminders*. The default is this
|
||||||
|
* device's setting, which nothing else can see; stored on the task, it
|
||||||
|
* travels with it to the server and fires on every client. The cost is
|
||||||
|
* tasks.org's: changing the default later does not reach the tasks that
|
||||||
|
* already have it.
|
||||||
|
*
|
||||||
|
* A task getting its first date takes the defaults only when the form
|
||||||
|
* names no reminder; one that already had a date takes those of the new
|
||||||
|
* date that no reminder of the form counts from. Otherwise the form is
|
||||||
|
* kept, so an edit that clears every reminder means "use the default
|
||||||
|
* here" as before.
|
||||||
|
*
|
||||||
|
* The form's reminders are already of its kind, timed or all-day — see
|
||||||
|
* [switched]. Reminders the editor [chose][TaskForm.remindersChosen] are
|
||||||
|
* kept as they are: it showed these defaults already.
|
||||||
|
*/
|
||||||
|
fun of(form: TaskForm, current: Task?, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> {
|
||||||
|
val wanted = form.reminders.map { it.lead }.distinct()
|
||||||
|
if (form.remindersChosen) return wanted
|
||||||
|
return withDefaults(form, current?.start, current?.due, settings, now, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [of] for a task whose dates were [start] and [due], whatever the form chose. */
|
||||||
|
fun withDefaults(
|
||||||
|
form: TaskForm,
|
||||||
|
start: Instant?,
|
||||||
|
due: Instant?,
|
||||||
|
settings: Settings,
|
||||||
|
now: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): List<TaskReminder> {
|
||||||
|
val wanted = form.reminders.map { it.lead }.distinct()
|
||||||
|
val had = anchorsOf(start, due)
|
||||||
|
val gained = anchorsOf(form.start, form.due) - had
|
||||||
|
if (gained.isEmpty()) return wanted
|
||||||
|
if (had.isEmpty() || wanted.isEmpty()) return wanted.ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||||
|
val named = wanted.map { it.fromStart }.toSet()
|
||||||
|
return wanted + defaultsFor(form, settings, now, zone).filter { it.fromStart in gained && it.fromStart !in named }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [reminders] for [form] once it has switched between timed and all-day.
|
||||||
|
* Reminders that are exactly the old kind's default become the new kind's;
|
||||||
|
* the rest keep what still means something: whole days, rounded to them on
|
||||||
|
* an all-day task.
|
||||||
|
*/
|
||||||
|
fun switched(
|
||||||
|
reminders: List<TaskReminder>,
|
||||||
|
form: TaskForm,
|
||||||
|
settings: Settings,
|
||||||
|
now: Long,
|
||||||
|
zone: ZoneId,
|
||||||
|
): List<TaskReminder> {
|
||||||
|
if (reminders.isEmpty()) return reminders
|
||||||
|
if (reminders.toSet() == settings.defaultRemindersFor(form.listId, !form.isAllDay).toSet()) {
|
||||||
|
return defaultsFor(form, settings, now, zone)
|
||||||
|
}
|
||||||
|
val kept = if (form.isAllDay) {
|
||||||
|
reminders.map { it.inWholeDays() }
|
||||||
|
} else {
|
||||||
|
reminders.filter { it.allDay.minuteOfDay == null }
|
||||||
|
}
|
||||||
|
return kept.distinct().ifEmpty { defaultsFor(form, settings, now, zone) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dates a reminder can count from, as [TaskReminder.fromStart]. */
|
||||||
|
private fun anchorsOf(start: Any?, due: Any?): Set<Boolean> =
|
||||||
|
setOfNotNull(true.takeIf { start != null }, false.takeIf { due != null })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defaults [form] takes: those counting from a date it has. A one-off
|
||||||
|
* task leaves out what is already past — a start of "now" would ring at once.
|
||||||
|
*/
|
||||||
|
private fun defaultsFor(form: TaskForm, settings: Settings, now: Long, zone: ZoneId): List<TaskReminder> =
|
||||||
|
settings.defaultRemindersFor(form.listId, form.isAllDay).filter { reminder ->
|
||||||
|
val anchor = (if (reminder.fromStart) form.start else form.due) ?: return@filter false
|
||||||
|
form.rrule != null || ReminderPlanner.triggerAt(
|
||||||
|
anchor,
|
||||||
|
form.isAllDay,
|
||||||
|
reminder.minutesBefore,
|
||||||
|
settings.allDayReminderMinuteOfDay,
|
||||||
|
zone,
|
||||||
|
) >= now
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,12 @@ object TasksContract {
|
|||||||
|
|
||||||
const val UID = "_uid"
|
const val UID = "_uid"
|
||||||
const val DELETED = "_deleted"
|
const val DELETED = "_deleted"
|
||||||
|
|
||||||
|
const val HAS_ALARMS = "has_alarms"
|
||||||
|
const val ORIGINAL_INSTANCE_ALLDAY = "original_instance_allday"
|
||||||
|
const val LIST_OWNER = "list_owner"
|
||||||
|
const val LIST_ACCESS_LEVEL = "list_access_level"
|
||||||
|
const val VISIBLE = "visible"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +109,7 @@ 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"
|
||||||
|
const val INSTANCE_DURATION = "instance_duration"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
* The occurrence's `RECURRENCE-ID` — the time this occurrence was
|
||||||
@@ -118,6 +125,10 @@ object TasksContract {
|
|||||||
const val PROPERTY_ID = "property_id"
|
const val PROPERTY_ID = "property_id"
|
||||||
const val TASK_ID = "task_id"
|
const val TASK_ID = "task_id"
|
||||||
const val MIMETYPE = "mimetype"
|
const val MIMETYPE = "mimetype"
|
||||||
|
const val DATA0 = "data0"
|
||||||
|
const val DATA1 = "data1"
|
||||||
|
const val DATA2 = "data2"
|
||||||
|
const val DATA3 = "data3"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,6 +175,9 @@ object TasksContract {
|
|||||||
|
|
||||||
/** `data1` — the tag as spelled. */
|
/** `data1` — the tag as spelled. */
|
||||||
const val NAME = "data1"
|
const val NAME = "data1"
|
||||||
|
|
||||||
|
/** `data2` — the tag's colour, if any. */
|
||||||
|
const val COLOR = "data2"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||||
|
|||||||
@@ -3,26 +3,9 @@ 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 de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import kotlin.time.Instant
|
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(
|
||||||
val listId: Long? = null,
|
val listId: Long? = null,
|
||||||
@@ -46,8 +29,38 @@ interface TasksDataSource {
|
|||||||
fun task(taskId: Long, occurrenceStart: Instant?): Task? = task(taskId)
|
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 updateTask(taskId: Long, form: TaskForm)
|
* The task rows directly under [parentTaskId]: one id per subtask, its series
|
||||||
|
* master when it recurs, whether or not any occurrence falls in the window
|
||||||
|
* [subtasks] expands.
|
||||||
|
*/
|
||||||
|
fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||||
|
subtasks(parentTaskId).map { it.seriesId ?: it.taskId }.distinct().filter { it != parentTaskId }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete [taskId] and its subtasks, all the way down; left behind they would
|
||||||
|
* hang off a tombstone. Each task once, so a parent loop an import brought in
|
||||||
|
* ends instead of recursing forever.
|
||||||
|
*/
|
||||||
|
fun deleteWithSubtasks(taskId: Long) {
|
||||||
|
val seen = HashSet<Long>()
|
||||||
|
fun delete(id: Long) {
|
||||||
|
if (!seen.add(id)) return
|
||||||
|
subtaskIds(id).forEach(::delete)
|
||||||
|
deleteTask(id)
|
||||||
|
}
|
||||||
|
delete(taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The write paths take the task's reminders in the same write as the task
|
||||||
|
* itself, so a sync can never upload one without the other.
|
||||||
|
*
|
||||||
|
* Each takes the task's whole set of [reminders], before-start and
|
||||||
|
* before-due alike, and `null` leaves the stored ones alone.
|
||||||
|
*/
|
||||||
|
fun insertTask(form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
||||||
|
fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a single occurrence of a recurring task, addressed by the task row and
|
* Update a single occurrence of a recurring task, addressed by the task row and
|
||||||
@@ -60,27 +73,19 @@ interface TasksDataSource {
|
|||||||
* instance row id keeps this seam independent of any one store's row
|
* instance row id keeps this seam independent of any one store's row
|
||||||
* numbering; External mode maps it back to an instance row itself.
|
* numbering; External mode maps it back to an instance row itself.
|
||||||
*/
|
*/
|
||||||
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm)
|
fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||||
/**
|
|
||||||
* 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]:
|
* Edit the whole series [seriesId] through its occurrence at [occurrenceStart]:
|
||||||
* content from [form], and the series moved by however far that occurrence moved.
|
* content from [form], and the series moved by however far that occurrence moved.
|
||||||
*/
|
*/
|
||||||
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm)
|
fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
* "This and following": end [seriesId] before [occurrenceStart] and continue
|
||||||
* from there as a new series built from [form]. Returns the new series' id.
|
* from there as a new series built from [form]. Returns the new series' id.
|
||||||
*/
|
*/
|
||||||
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long
|
fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>? = null): Long
|
||||||
|
|
||||||
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
/** Delete one generated occurrence of [seriesId] (an `EXDATE`). */
|
||||||
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant)
|
||||||
@@ -89,14 +94,15 @@ interface TasksDataSource {
|
|||||||
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
|
fun deleteFollowing(seriesId: Long, occurrenceStart: Instant)
|
||||||
|
|
||||||
|
|
||||||
/** Replace every reminder on [taskId] with [reminders], in order. */
|
/** Make [taskId]'s reminders exactly [reminders]. A synced task is owed a PUT for it. */
|
||||||
fun setReminders(taskId: Long, reminders: List<TaskReminder>)
|
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 reminders, by task id, in stored order. One query, for the
|
||||||
|
* scheduler. A task missing from the map takes the default; one present
|
||||||
/** Every task's [editable] reminder, by task id. */
|
* with an empty list has none (see `TaskEntity.remindersOff`).
|
||||||
fun alarms(): Map<Long, TaskReminder>
|
*/
|
||||||
|
fun reminders(): Map<Long, List<TaskReminder>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
* Every task in [listId] read from the **`tasks` table**, for export. Masters,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
import de.jeanlucmakiola.agendula.domain.SavedFilter
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
@@ -82,13 +83,6 @@ interface TasksRepository {
|
|||||||
scope: RecurringScope = RecurringScope.AllOccurrences,
|
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. */
|
/** Every reminder stored on the task, in order — including start-relative ones. */
|
||||||
suspend fun remindersFor(taskId: Long): List<TaskReminder>
|
suspend fun remindersFor(taskId: Long): List<TaskReminder>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.time.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
@@ -21,6 +22,7 @@ import kotlinx.coroutines.channels.consumeEach
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -109,11 +111,18 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun createTask(form: TaskForm): Long =
|
override suspend fun createTask(form: TaskForm): Long =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val id = dataSource.insertTask(form)
|
dataSource.insertTask(form, storedRemindersFor(form, current = null))
|
||||||
writeReminders(id, form)
|
|
||||||
id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun storedRemindersFor(form: TaskForm, current: Task?): List<TaskReminder> =
|
||||||
|
StoredReminders.of(
|
||||||
|
form,
|
||||||
|
current,
|
||||||
|
settingsPrefs.settings.first(),
|
||||||
|
Clock.System.now().toEpochMilliseconds(),
|
||||||
|
ZoneId.systemDefault(),
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
override suspend fun duplicateTask(taskId: Long, occurrenceStart: Instant?): Long =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
|
val task = checkNotNull(dataSource.task(taskId, occurrenceStart)) { "Task $taskId is gone" }
|
||||||
@@ -121,6 +130,9 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
private fun copyOf(task: Task, parentId: Long?, depth: Int): Long {
|
||||||
|
// In the same write as the copy: inserted first and given its reminders
|
||||||
|
// after, a sync landing in between uploads the copy without them.
|
||||||
|
val reminders = dataSource.reminders()[task.taskId].orEmpty().map { it.lead }
|
||||||
val id = dataSource.insertTask(
|
val id = dataSource.insertTask(
|
||||||
TaskForm(
|
TaskForm(
|
||||||
title = task.title,
|
title = task.title,
|
||||||
@@ -135,9 +147,8 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
url = task.url,
|
url = task.url,
|
||||||
tags = task.tags,
|
tags = task.tags,
|
||||||
),
|
),
|
||||||
|
reminders,
|
||||||
)
|
)
|
||||||
val reminders = dataSource.reminders()[task.taskId].orEmpty()
|
|
||||||
if (reminders.isNotEmpty()) dataSource.setReminders(id, reminders)
|
|
||||||
if (depth < MAX_COPY_DEPTH) {
|
if (depth < MAX_COPY_DEPTH) {
|
||||||
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
|
SeriesCollapse.visible(dataSource.subtasks(task.taskId))
|
||||||
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
|
.filter { it.taskId != task.taskId && it.status != TaskStatus.CANCELLED }
|
||||||
@@ -146,9 +157,6 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
return id
|
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> =
|
override suspend fun remindersFor(taskId: Long): List<TaskReminder> =
|
||||||
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
|
withContext(io) { runCatching { dataSource.reminders()[taskId].orEmpty() }.getOrDefault(emptyList()) }
|
||||||
|
|
||||||
@@ -172,18 +180,17 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
val seen = current?.lastModified
|
val seen = current?.lastModified
|
||||||
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
if (seen != null && seen != expectedLastModified) throw TaskConflictException(taskId)
|
||||||
}
|
}
|
||||||
|
val reminders = storedRemindersFor(form, current)
|
||||||
val seriesId = current?.seriesId
|
val seriesId = current?.seriesId
|
||||||
val occurrence = current?.occurrenceStart
|
val occurrence = current?.occurrenceStart
|
||||||
if (seriesId != null && occurrence != null) {
|
if (seriesId != null && occurrence != null) {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringScope.AllOccurrences -> {
|
RecurringScope.AllOccurrences -> {
|
||||||
writeReminders(seriesId, form)
|
dataSource.updateSeries(seriesId, occurrence, form, reminders)
|
||||||
dataSource.updateSeries(seriesId, occurrence, form)
|
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
RecurringScope.ThisAndFollowing -> {
|
RecurringScope.ThisAndFollowing -> {
|
||||||
val created = dataSource.splitSeries(seriesId, occurrence, form)
|
dataSource.splitSeries(seriesId, occurrence, form, reminders)
|
||||||
writeReminders(created, form)
|
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
RecurringScope.ThisOccurrence -> Unit
|
RecurringScope.ThisOccurrence -> Unit
|
||||||
@@ -191,31 +198,11 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
// A recurring task's start/due are one occurrence's resolved times, so
|
// 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.
|
// writing them back to the task row would re-anchor the whole series.
|
||||||
// Going through the occurrence forks an override instead.
|
// Going through the occurrence forks an override instead, which takes
|
||||||
|
// the reminders on its own row and leaves the series' alone.
|
||||||
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
val instance = current?.takeIf { it.isRecurring }?.occurrenceStart
|
||||||
if (instance == null) {
|
if (instance == null) dataSource.updateTask(taskId, form, reminders)
|
||||||
writeReminders(taskId, form)
|
else dataSource.updateInstance(taskId, instance, form, reminders)
|
||||||
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) =
|
override suspend fun setCancelled(taskId: Long, occurrenceStart: Instant?, cancelled: Boolean) =
|
||||||
@@ -248,7 +235,7 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
val current = dataSource.task(taskId, occurrenceStart)
|
val current = dataSource.task(taskId, occurrenceStart)
|
||||||
val seriesId = current?.seriesId
|
val seriesId = current?.seriesId
|
||||||
val occurrence = current?.occurrenceStart
|
val occurrence = current?.occurrenceStart
|
||||||
if (seriesId == null || occurrence == null) return@withContext deleteWithSubtasks(taskId)
|
if (seriesId == null || occurrence == null) return@withContext dataSource.deleteWithSubtasks(taskId)
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringScope.ThisOccurrence ->
|
RecurringScope.ThisOccurrence ->
|
||||||
// An override row deletes itself into an EXDATE; a generated
|
// An override row deletes itself into an EXDATE; a generated
|
||||||
@@ -256,18 +243,10 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
if (current.taskId != seriesId) dataSource.deleteTask(current.taskId)
|
||||||
else dataSource.deleteOccurrence(seriesId, occurrence)
|
else dataSource.deleteOccurrence(seriesId, occurrence)
|
||||||
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
RecurringScope.ThisAndFollowing -> dataSource.deleteFollowing(seriesId, occurrence)
|
||||||
RecurringScope.AllOccurrences -> deleteWithSubtasks(seriesId)
|
RecurringScope.AllOccurrences -> dataSource.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) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderPlanner
|
||||||
|
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.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 de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||||
|
import de.jeanlucmakiola.agendula.domain.MIN_ALL_DAY_LEAD
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `VALARM` ↔ [TaskAlarmEntity].
|
||||||
|
*
|
||||||
|
* ## What is claimed
|
||||||
|
*
|
||||||
|
* Only an alarm Agendula can fire exactly as written becomes a reminder row.
|
||||||
|
* Everything else stays in the task's residue and round-trips untouched — it
|
||||||
|
* still fires in the client that wrote it, just not here:
|
||||||
|
*
|
||||||
|
* - `ACTION` other than `DISPLAY`/`AUDIO`;
|
||||||
|
* - Apple's default alarms (`X-APPLE-DEFAULT-ALARM:TRUE`). They are that Mac's
|
||||||
|
* preference, not task data: claimed, they would come back to the Mac as an
|
||||||
|
* explicit alarm without the flag after the first edit here;
|
||||||
|
* - location alarms (`X-APPLE-PROXIMITY`, RFC 9074 `PROXIMITY`, a nested
|
||||||
|
* component) and Apple's `19760401T005545Z` sentinel trigger they carry;
|
||||||
|
* - repeating alarms (`REPEAT`/`DURATION`) and RFC 9074 snoozes (`RELATED-TO`);
|
||||||
|
* - a trigger *after* its anchor — on an all-day task, after the end of its
|
||||||
|
* day. The planner coerces a negative lead to 0, so it would fire at the
|
||||||
|
* wrong time;
|
||||||
|
* - an absolute trigger on a recurring task, or on one with no `DUE` to count
|
||||||
|
* back from.
|
||||||
|
*
|
||||||
|
* ## What "unchanged" means
|
||||||
|
*
|
||||||
|
* A claimed row keeps the `VALARM` it was read from in [TaskAlarmEntity.raw].
|
||||||
|
* On write, the row is "unchanged" when that original still [resolve]s to the
|
||||||
|
* row's lead against the task *as it is now* — so `-PT15M` and `-P0DT0H15M0S`
|
||||||
|
* are the same, a relative alarm survives a due-date edit, and an absolute one
|
||||||
|
* stops matching once `DUE` moves. An unchanged row is re-emitted verbatim; a
|
||||||
|
* changed one is authored fresh under its existing uid.
|
||||||
|
*
|
||||||
|
* ## Anchors
|
||||||
|
*
|
||||||
|
* `RELATED=END` counts from `DUE`. `RELATED=START` (the default) counts from
|
||||||
|
* `DTSTART` — except when `DTSTART` equals `DUE`, or when there is no
|
||||||
|
* `DTSTART` but there is a `DUE`. Apple writes the latter shape (against §3.8.6.3,
|
||||||
|
* which wants `DTSTART` present).
|
||||||
|
*
|
||||||
|
* ## All-day tasks
|
||||||
|
*
|
||||||
|
* A relative trigger counts from the date's midnight, so the lead is the
|
||||||
|
* reminder as [de.jeanlucmakiola.agendula.domain.TaskReminder.allDay] reads it:
|
||||||
|
* `-P1D` is a day before at this device's all-day time, which this mapper cannot
|
||||||
|
* see, and `PT9H` is 09:00 on the day. An absolute trigger has no zone to read
|
||||||
|
* a time of day in, so it rounds to the nearest whole day.
|
||||||
|
*/
|
||||||
|
object AlarmCodec {
|
||||||
|
|
||||||
|
/** Apple's placeholder trigger for location alarms. Never a real time. */
|
||||||
|
private const val APPLE_SENTINEL = "19760401T005545Z"
|
||||||
|
|
||||||
|
/** What a `VALARM` means to the scheduler. */
|
||||||
|
data class Lead(val minutesBefore: Int, val reference: AlarmReference)
|
||||||
|
|
||||||
|
/** A `VALARM` as a reminder row — `taskId` is the caller's — or null if it is not ours to claim. */
|
||||||
|
fun fromVAlarm(alarm: ICalComponent, task: TaskEntity): TaskAlarmEntity? {
|
||||||
|
val lead = resolve(alarm, task) ?: return null
|
||||||
|
val raw = ICalSerializer.serialize(alarm)
|
||||||
|
// Too large to keep verbatim means it could not be re-emitted, and an
|
||||||
|
// alarm that is authored fresh on every write loses what made it large.
|
||||||
|
if (raw.toByteArray(Charsets.UTF_8).size > VTodoMapper.MAX_RESIDUE_BYTES) return null
|
||||||
|
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val base = TaskAlarmEntity(
|
||||||
|
taskId = 0,
|
||||||
|
minutesBefore = lead.minutesBefore,
|
||||||
|
reference = lead.reference,
|
||||||
|
raw = raw,
|
||||||
|
)
|
||||||
|
return if (uid == null) base else base.copy(uid = uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead [alarm] stands for on [task], or null when it is not claimable. */
|
||||||
|
fun resolve(alarm: ICalComponent, task: TaskEntity): Lead? {
|
||||||
|
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
|
||||||
|
if (action != "DISPLAY" && action != "AUDIO") return null
|
||||||
|
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
|
||||||
|
val excluded = listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO")
|
||||||
|
if (excluded.any { alarm.property(it) != null }) return null
|
||||||
|
if (alarm.components.isNotEmpty()) return null
|
||||||
|
|
||||||
|
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
|
||||||
|
val value = trigger.value.trim()
|
||||||
|
if (value.startsWith(APPLE_SENTINEL)) return null
|
||||||
|
|
||||||
|
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
|
||||||
|
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
|
||||||
|
return if (absolute) absoluteLead(trigger, task) else relativeLead(trigger, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relativeLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
||||||
|
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
|
||||||
|
val reference = when {
|
||||||
|
trigger.param("RELATED").equals("END", ignoreCase = true) ->
|
||||||
|
if (task.due != null) AlarmReference.DUE else return null
|
||||||
|
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
|
||||||
|
task.due != null -> AlarmReference.DUE
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val before = -seconds
|
||||||
|
// A lead in seconds is not one the planner can reproduce.
|
||||||
|
if (before % 60 != 0L) return null
|
||||||
|
val minutes = before / 60
|
||||||
|
val latest = if (task.isAllDay) MIN_ALL_DAY_LEAD.toLong() else 0L
|
||||||
|
return minutes.takeIf { it in latest..Int.MAX_VALUE }?.let { Lead(it.toInt(), reference) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun absoluteLead(trigger: ICalProperty, task: TaskEntity): Lead? {
|
||||||
|
if (task.rrule != null || task.rdate != null) return null
|
||||||
|
val due = task.due ?: return null
|
||||||
|
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
|
||||||
|
?.takeIf { it.tzid == null } // §3.8.6.3: an absolute trigger MUST be UTC
|
||||||
|
?.instant ?: return null
|
||||||
|
val before = (due - at).inWholeSeconds
|
||||||
|
val minutes = if (task.isAllDay) {
|
||||||
|
// An all-day DUE is stored as UTC midnight of its date, which is
|
||||||
|
// the calendar date the trigger counts back from.
|
||||||
|
roundToDays(before)
|
||||||
|
} else {
|
||||||
|
Math.floorDiv(before, 60L)
|
||||||
|
}
|
||||||
|
return minutes.toIntOrNull()?.let { Lead(it, AlarmReference.DUE) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [read] as builds before 1.2 stored it on an all-day task: rounded to whole
|
||||||
|
* days. Such a row keeps the other client's time of day instead of overwriting it.
|
||||||
|
*/
|
||||||
|
private fun readAsWholeDays(read: Lead, task: TaskEntity): Lead? =
|
||||||
|
if (task.isAllDay && read.minutesBefore % DAY_MINUTES != 0) {
|
||||||
|
read.copy(minutesBefore = roundToDays(read.minutesBefore * 60L).coerceAtLeast(0).toInt())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest whole day, in minutes. */
|
||||||
|
private fun roundToDays(seconds: Long): Long =
|
||||||
|
(seconds / 86_400.0).roundToLong() * DAY_MINUTES
|
||||||
|
|
||||||
|
/** Negative is after the anchor, which is not claimed. */
|
||||||
|
private fun Long.toIntOrNull(): Int? = takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [row] as a `VALARM` on [task], or null when it cannot be written: its
|
||||||
|
* anchor is missing. That row stays on the device — the planner ignores it
|
||||||
|
* too — because `RELATED=END` without `DUE` is a resource the server
|
||||||
|
* refuses ([VTodoMapper.validate]).
|
||||||
|
*
|
||||||
|
* ⚠️ A row whose anchor the task has lost is written against the other
|
||||||
|
* date, which is what the planner fires it from. Leaving it out would drop
|
||||||
|
* it from every other client, and the next download would then take it off
|
||||||
|
* this device too.
|
||||||
|
*
|
||||||
|
* [now] marks an authored alarm `ACKNOWLEDGED` once it is past even the
|
||||||
|
* planner's missed-reminder grace, so a client that honours RFC 9074 does
|
||||||
|
* not greet it as a missed alarm. Not sooner: the stamp comes back on the
|
||||||
|
* next download and would silence the late reminder this device still owes.
|
||||||
|
*/
|
||||||
|
fun toVAlarm(row: TaskAlarmEntity, task: TaskEntity, now: Instant): ICalComponent? {
|
||||||
|
val reference = when {
|
||||||
|
row.reference == AlarmReference.START && task.dtstart == null -> AlarmReference.DUE
|
||||||
|
row.reference == AlarmReference.DUE && task.due == null -> AlarmReference.START
|
||||||
|
else -> row.reference
|
||||||
|
}
|
||||||
|
val lead = Lead(row.minutesBefore, reference)
|
||||||
|
row.raw?.let(::parse)?.let { original ->
|
||||||
|
val read = resolve(original, task)
|
||||||
|
if (read == lead || read != null && readAsWholeDays(read, task) == lead) return original
|
||||||
|
}
|
||||||
|
|
||||||
|
val anchor = when (reference) {
|
||||||
|
AlarmReference.DUE -> task.due
|
||||||
|
AlarmReference.START -> task.dtstart
|
||||||
|
} ?: return null
|
||||||
|
val related = if (reference == AlarmReference.DUE) "END" else "START"
|
||||||
|
val minutes = row.minutesBefore.coerceAtLeast(if (task.isAllDay) MIN_ALL_DAY_LEAD else 0).toLong()
|
||||||
|
val seconds = -minutes * 60
|
||||||
|
|
||||||
|
val properties = mutableListOf(
|
||||||
|
ICalProperty("UID", value = row.uid),
|
||||||
|
ICalProperty("X-WR-ALARMUID", value = row.uid),
|
||||||
|
ICalProperty("ACTION", value = "DISPLAY"),
|
||||||
|
ICalProperty("DESCRIPTION", value = ICalValues.escapeText(row.message ?: "Reminder")),
|
||||||
|
ICalProperty("TRIGGER", listOf(ICalParam("RELATED", related)), ICalValues.formatDuration(seconds)),
|
||||||
|
)
|
||||||
|
val recurring = task.rrule != null || task.rdate != null
|
||||||
|
if (!recurring && anchor + seconds.seconds + ReminderPlanner.MISSED_GRACE_MS.milliseconds < now) {
|
||||||
|
properties += ICalProperty("ACKNOWLEDGED", value = ICalValues.formatDateTime(now, null))
|
||||||
|
}
|
||||||
|
return ICalComponent("VALARM", properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What [row] reads back as once it has been to the server and back — not
|
||||||
|
* always the row itself, because the wire is coarser: a lead past the end
|
||||||
|
* of its anchor is clamped, and a before-start row on a task without a
|
||||||
|
* start goes out before-due. A download that brings back exactly this has not changed the
|
||||||
|
* reminder, and the row's own lead must be kept rather than taken from it.
|
||||||
|
*
|
||||||
|
* ⚠️ As authored from the row, not re-emitted from [TaskAlarmEntity.raw]:
|
||||||
|
* a row rounded to whole days before 1.2 re-emits a time of day it does
|
||||||
|
* not hold, and would keep its rounded lead against the very alarm that
|
||||||
|
* carries it.
|
||||||
|
*/
|
||||||
|
fun wireLead(row: TaskAlarmEntity, task: TaskEntity): Lead? =
|
||||||
|
toVAlarm(row.copy(raw = null), task, Instant.DISTANT_PAST)?.let { resolve(it, task) }
|
||||||
|
|
||||||
|
private fun parse(raw: String): ICalComponent? =
|
||||||
|
runCatching { ICalParser.parse(raw) }.getOrNull()?.takeIf { it.name.equals("VALARM", ignoreCase = true) }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks.ical
|
package de.jeanlucmakiola.agendula.data.tasks.ical
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
@@ -52,10 +53,11 @@ import kotlin.time.Instant
|
|||||||
*
|
*
|
||||||
* ## Alarms
|
* ## Alarms
|
||||||
*
|
*
|
||||||
* `VALARM`s round-trip in the residue and are never authored here. Local
|
* A `VALARM` [AlarmCodec] can fire exactly as written is claimed into
|
||||||
* reminders live in `task_alarms` and are never serialised. The two stores are
|
* [Mapped.alarms] — rows for `task_alarms`, carrying the original verbatim —
|
||||||
* disjoint, so neither can destroy the other. Merging them is a later
|
* and [write] authors the task's rows back. Any other `VALARM` (Apple's default
|
||||||
* decision, not a silent one.
|
* and location alarms, repeating ones, snoozes) stays in the residue and is
|
||||||
|
* re-emitted untouched after the authored ones.
|
||||||
*/
|
*/
|
||||||
object VTodoMapper {
|
object VTodoMapper {
|
||||||
|
|
||||||
@@ -95,6 +97,8 @@ object VTodoMapper {
|
|||||||
val uidWasMissing: Boolean,
|
val uidWasMissing: Boolean,
|
||||||
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
/** Residue that exceeded [MAX_RESIDUE_BYTES] and had to be dropped. */
|
||||||
val droppedResidue: Boolean,
|
val droppedResidue: Boolean,
|
||||||
|
/** The claimed `VALARM`s, `taskId` unset. Not counted against the residue cap. */
|
||||||
|
val alarms: List<TaskAlarmEntity> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- read
|
// ---------------------------------------------------------------- read
|
||||||
@@ -263,8 +267,18 @@ object VTodoMapper {
|
|||||||
.filterIndexed { index, _ -> index !in claimed }
|
.filterIndexed { index, _ -> index !in claimed }
|
||||||
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
.filterNot { it.name.equals("DTSTAMP", ignoreCase = true) }
|
||||||
|
|
||||||
|
// Claimed against the entity as read, so the anchors are the ones the
|
||||||
|
// rows will be written back against.
|
||||||
|
val alarms = mutableListOf<TaskAlarmEntity>()
|
||||||
|
val residueComponents = vtodo.components.filter { component ->
|
||||||
|
if (!component.name.equals("VALARM", ignoreCase = true)) return@filter true
|
||||||
|
val row = AlarmCodec.fromVAlarm(component, entity) ?: return@filter true
|
||||||
|
alarms += row
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
val residueText = ICalSerializer.serializeProperties(residueProperties) +
|
||||||
ICalSerializer.serializeAll(vtodo.components)
|
ICalSerializer.serializeAll(residueComponents)
|
||||||
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
val tooLarge = residueText.toByteArray(Charsets.UTF_8).size > MAX_RESIDUE_BYTES
|
||||||
|
|
||||||
return Mapped(
|
return Mapped(
|
||||||
@@ -274,6 +288,7 @@ object VTodoMapper {
|
|||||||
parentUid = parentUid,
|
parentUid = parentUid,
|
||||||
uidWasMissing = uid.isEmpty(),
|
uidWasMissing = uid.isEmpty(),
|
||||||
droppedResidue = tooLarge,
|
droppedResidue = tooLarge,
|
||||||
|
alarms = alarms,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,12 +298,14 @@ object VTodoMapper {
|
|||||||
* Serialises [entity] back to a VTODO.
|
* Serialises [entity] back to a VTODO.
|
||||||
*
|
*
|
||||||
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
* [parentUid] is the parent's `UID`, which the entity holds only as a row id.
|
||||||
* [now] is the `DTSTAMP`.
|
* [now] is the `DTSTAMP`. [alarms] are the task's reminder rows; one whose
|
||||||
|
* anchor the task no longer has is left out (see [AlarmCodec.toVAlarm]).
|
||||||
*/
|
*/
|
||||||
fun write(
|
fun write(
|
||||||
entity: TaskEntity,
|
entity: TaskEntity,
|
||||||
parentUid: String? = null,
|
parentUid: String? = null,
|
||||||
now: Instant = Clock.System.now(),
|
now: Instant = Clock.System.now(),
|
||||||
|
alarms: List<TaskAlarmEntity> = emptyList(),
|
||||||
): ICalComponent {
|
): ICalComponent {
|
||||||
val residue = parseResidue(entity.unknownProperties)
|
val residue = parseResidue(entity.unknownProperties)
|
||||||
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
val residueCategories = normalizeTags(residue.properties("CATEGORIES").flatMap(::categoryValues))
|
||||||
@@ -349,7 +366,7 @@ object VTodoMapper {
|
|||||||
return ICalComponent(
|
return ICalComponent(
|
||||||
name = "VTODO",
|
name = "VTODO",
|
||||||
properties = properties + keptResidue,
|
properties = properties + keptResidue,
|
||||||
components = residue.components,
|
components = alarms.mapNotNull { AlarmCodec.toVAlarm(it, entity, now) } + residue.components,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentProviderOperation
|
||||||
|
import android.content.ContentProviderResult
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.UriMatcher
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's own store, published under the OpenTasks contract (`tasklists`,
|
||||||
|
* `tasks`, `instances`, `properties`) at `<applicationId>.tasks`, so an app that
|
||||||
|
* already works with OpenTasks or tasks.org works with Agendula by swapping the
|
||||||
|
* authority. Reads are guarded by `<applicationId>.permission.READ_TASKS`, writes
|
||||||
|
* by `<applicationId>.permission.WRITE_TASKS`; [ProviderWriter] applies them. See
|
||||||
|
* `docs/CONTENT_PROVIDER.md`.
|
||||||
|
*/
|
||||||
|
class AgendulaTaskProvider : ContentProvider() {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface ProviderEntryPoint {
|
||||||
|
fun snapshotSource(): ProviderSnapshotSource
|
||||||
|
fun writer(): ProviderWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
private val entryPoint by lazy {
|
||||||
|
EntryPointAccessors.fromApplication(requireNotNull(context), ProviderEntryPoint::class.java)
|
||||||
|
}
|
||||||
|
private val source: ProviderSnapshotSource by lazy { entryPoint.snapshotSource() }
|
||||||
|
private val writer: ProviderWriter by lazy { entryPoint.writer() }
|
||||||
|
|
||||||
|
private val matcher by lazy {
|
||||||
|
UriMatcher(UriMatcher.NO_MATCH).apply {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
addURI(source.authority, table.path, table.ordinal * 2)
|
||||||
|
addURI(source.authority, "${table.path}/#", table.ordinal * 2 + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean = true
|
||||||
|
|
||||||
|
override fun query(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
sortOrder: String?,
|
||||||
|
): Cursor {
|
||||||
|
val (table, id) = match(uri)
|
||||||
|
return source.query(table, projection, table.selectionFor(id, selection), selectionArgs, sortOrder).apply {
|
||||||
|
setNotificationUri(requireNotNull(context).contentResolver, uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getType(uri: Uri): String {
|
||||||
|
val (table, id) = match(uri)
|
||||||
|
return table.mimeType(single = id != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||||
|
val (table, id) = match(uri)
|
||||||
|
require(id == null) { "Insert into ${table.path}, not into one of its rows" }
|
||||||
|
return ContentUris.withAppendedId(uri, writer.insert(table, values.toMap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||||
|
val (table, id) = match(uri)
|
||||||
|
return writer.update(table, values.toMap(), table.selectionFor(id, selection), selectionArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||||
|
val (table, id) = match(uri)
|
||||||
|
return writer.delete(table, table.selectionFor(id, selection), selectionArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All or nothing, as against OpenTasks, and observers hear of it once. */
|
||||||
|
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> =
|
||||||
|
writer.batch { super.applyBatch(operations) }
|
||||||
|
|
||||||
|
private fun match(uri: Uri): Pair<ProviderTable, Long?> {
|
||||||
|
val code = matcher.match(uri)
|
||||||
|
require(code != UriMatcher.NO_MATCH) { "Unknown URI $uri" }
|
||||||
|
val table = ProviderTable.entries[code / 2]
|
||||||
|
if (code % 2 == 0) return table to null
|
||||||
|
val id = requireNotNull(uri.lastPathSegment?.toLongOrNull()) { "Unknown URI $uri" }
|
||||||
|
return table to id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ContentValues?.toMap(): ProviderValues =
|
||||||
|
this?.keySet()?.associateWith { get(it) }.orEmpty()
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
|
||||||
|
/** One table [AgendulaTaskProvider] serves, with its columns in cursor order. */
|
||||||
|
enum class ProviderTable(val path: String, val columns: List<Pair<String, String>>) {
|
||||||
|
LISTS(
|
||||||
|
Lists.PATH,
|
||||||
|
listOf(
|
||||||
|
Lists.ID to "INTEGER PRIMARY KEY",
|
||||||
|
Lists.NAME to "TEXT",
|
||||||
|
Lists.COLOR to "INTEGER",
|
||||||
|
Lists.ACCESS_LEVEL to "INTEGER",
|
||||||
|
Lists.VISIBLE to "INTEGER",
|
||||||
|
Lists.SYNC_ENABLED to "INTEGER",
|
||||||
|
Lists.OWNER to "TEXT",
|
||||||
|
Lists.ACCOUNT_NAME to "TEXT",
|
||||||
|
Lists.ACCOUNT_TYPE to "TEXT",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TASKS(Tasks.PATH, listOf(Tasks.ID to "INTEGER PRIMARY KEY") + TASK_COLUMNS),
|
||||||
|
INSTANCES(
|
||||||
|
Instances.PATH,
|
||||||
|
listOf(
|
||||||
|
Tasks.ID to "INTEGER PRIMARY KEY",
|
||||||
|
Instances.TASK_ID to "INTEGER",
|
||||||
|
Instances.INSTANCE_START to "INTEGER",
|
||||||
|
Instances.INSTANCE_START_SORTING to "INTEGER",
|
||||||
|
Instances.INSTANCE_DUE to "INTEGER",
|
||||||
|
Instances.INSTANCE_DUE_SORTING to "INTEGER",
|
||||||
|
Instances.INSTANCE_DURATION to "INTEGER",
|
||||||
|
Instances.INSTANCE_ORIGINAL_TIME to "INTEGER",
|
||||||
|
Instances.DISTANCE_FROM_CURRENT to "INTEGER",
|
||||||
|
Instances.IS_RECURRING to "INTEGER",
|
||||||
|
) + TASK_COLUMNS,
|
||||||
|
),
|
||||||
|
PROPERTIES(
|
||||||
|
Properties.PATH,
|
||||||
|
listOf(
|
||||||
|
Properties.PROPERTY_ID to "INTEGER PRIMARY KEY",
|
||||||
|
Properties.TASK_ID to "INTEGER",
|
||||||
|
Properties.MIMETYPE to "TEXT",
|
||||||
|
Properties.DATA0 to "TEXT",
|
||||||
|
Properties.DATA1 to "TEXT",
|
||||||
|
Properties.DATA2 to "TEXT",
|
||||||
|
Properties.DATA3 to "TEXT",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
;
|
||||||
|
|
||||||
|
val idColumn: String get() = columns.first().first
|
||||||
|
|
||||||
|
/** [selection] narrowed to the row [id] names, for a `/<id>` URI. */
|
||||||
|
fun selectionFor(id: Long?, selection: String?): String? =
|
||||||
|
if (id == null) selection else listOfNotNull("$idColumn = $id", selection?.let { "($it)" }).joinToString(" AND ")
|
||||||
|
|
||||||
|
/** OpenTasks' MIME type for a whole table, or for one row of it. */
|
||||||
|
fun mimeType(single: Boolean): String =
|
||||||
|
"${if (single) "vnd.android.cursor.item" else "vnd.android.cursor.dir"}/org.dmfs.tasks.$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every `tasks` column but `_id`; the instances view carries them too. */
|
||||||
|
private val TASK_COLUMNS: List<Pair<String, String>>
|
||||||
|
get() = listOf(
|
||||||
|
Tasks.LIST_ID to "INTEGER",
|
||||||
|
Tasks.LIST_NAME to "TEXT",
|
||||||
|
Tasks.LIST_COLOR to "INTEGER",
|
||||||
|
Tasks.LIST_OWNER to "TEXT",
|
||||||
|
Tasks.LIST_ACCESS_LEVEL to "INTEGER",
|
||||||
|
Tasks.VISIBLE to "INTEGER",
|
||||||
|
Tasks.ACCOUNT_NAME to "TEXT",
|
||||||
|
Tasks.ACCOUNT_TYPE to "TEXT",
|
||||||
|
Tasks.UID to "TEXT",
|
||||||
|
Tasks.DELETED to "INTEGER",
|
||||||
|
Tasks.TITLE to "TEXT",
|
||||||
|
Tasks.DESCRIPTION to "TEXT",
|
||||||
|
Tasks.LOCATION to "TEXT",
|
||||||
|
Tasks.URL to "TEXT",
|
||||||
|
Tasks.PRIORITY to "INTEGER",
|
||||||
|
Tasks.CLASSIFICATION to "INTEGER",
|
||||||
|
Tasks.STATUS to "INTEGER",
|
||||||
|
Tasks.PERCENT_COMPLETE to "INTEGER",
|
||||||
|
Tasks.COMPLETED to "INTEGER",
|
||||||
|
Tasks.IS_CLOSED to "INTEGER",
|
||||||
|
Tasks.IS_NEW to "INTEGER",
|
||||||
|
Tasks.TASK_COLOR to "INTEGER",
|
||||||
|
Tasks.DTSTART to "INTEGER",
|
||||||
|
Tasks.DUE to "INTEGER",
|
||||||
|
Tasks.DURATION to "TEXT",
|
||||||
|
Tasks.IS_ALLDAY to "INTEGER",
|
||||||
|
Tasks.TZ to "TEXT",
|
||||||
|
Tasks.RRULE to "TEXT",
|
||||||
|
Tasks.RDATE to "TEXT",
|
||||||
|
Tasks.EXDATE to "TEXT",
|
||||||
|
Tasks.ORIGINAL_INSTANCE_ID to "INTEGER",
|
||||||
|
Tasks.ORIGINAL_INSTANCE_TIME to "INTEGER",
|
||||||
|
Tasks.ORIGINAL_INSTANCE_ALLDAY to "INTEGER",
|
||||||
|
Tasks.PARENT_ID to "INTEGER",
|
||||||
|
Tasks.SORTING to "INTEGER",
|
||||||
|
Tasks.HAS_ALARMS to "INTEGER",
|
||||||
|
Tasks.CREATED to "INTEGER",
|
||||||
|
Tasks.LAST_MODIFIED to "INTEGER",
|
||||||
|
) + UNSUPPORTED_TASK_COLUMNS.map { it to "TEXT" }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenTasks columns Agendula has nothing for. Published as `NULL` so a client
|
||||||
|
* that projects them reads nothing rather than failing the query.
|
||||||
|
*/
|
||||||
|
private val UNSUPPORTED_TASK_COLUMNS = listOf(
|
||||||
|
"geo", "organizer", "pinned", "has_properties", "completed_is_allday", "original_instance_sync_id",
|
||||||
|
"version", "_dirty", "_sync_id", "sync_version",
|
||||||
|
"sync1", "sync2", "sync3", "sync4", "sync5", "sync6", "sync7", "sync8",
|
||||||
|
)
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import de.jeanlucmakiola.agendula.domain.toInt
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
typealias ProviderRow = Map<String, Any?>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids that stay put for as long as what they name exists, so a client can read
|
||||||
|
* an id and write to it later, or several in one batch. The one exception is
|
||||||
|
* two tags on one task whose spellings hash alike (about one in 2³¹): removing
|
||||||
|
* the one that sorts first moves the other onto its id.
|
||||||
|
*
|
||||||
|
* An instance is its series and its original time; a plain task's one
|
||||||
|
* instance has none. An alarm property is the alarm row; a tag property is its
|
||||||
|
* task and its spelling. Alarms take even ids and tags odd ones, so the two
|
||||||
|
* never meet.
|
||||||
|
*/
|
||||||
|
object ProviderIds {
|
||||||
|
|
||||||
|
fun instance(seriesId: Long, originalTime: Instant?): Long =
|
||||||
|
(seriesId shl 32) or ((originalTime?.epochSeconds ?: 0L) and 0xFFFFFFFFL)
|
||||||
|
|
||||||
|
fun instanceSeries(instanceId: Long): Long = instanceId ushr 32
|
||||||
|
|
||||||
|
fun instanceSeconds(instanceId: Long): Long = instanceId and 0xFFFFFFFFL
|
||||||
|
|
||||||
|
fun alarm(alarmId: Long): Long = alarmId shl 1
|
||||||
|
|
||||||
|
fun isAlarm(propertyId: Long): Boolean = propertyId and 1L == 0L
|
||||||
|
|
||||||
|
fun alarmRow(propertyId: Long): Long = propertyId ushr 1
|
||||||
|
|
||||||
|
/** The property id of each of [taskId]'s [categories], by spelling. */
|
||||||
|
fun categories(taskId: Long, categories: List<String>): Map<String, Long> =
|
||||||
|
categories(taskId, categories, ::tagHash)
|
||||||
|
|
||||||
|
internal fun categories(taskId: Long, categories: List<String>, hash: (String) -> Long): Map<String, Long> {
|
||||||
|
val used = HashSet<Long>()
|
||||||
|
// Two spellings on one task with one hash: the one that sorts later takes
|
||||||
|
// the next free slot, wrapping inside the tag bits so it never reaches the
|
||||||
|
// task's. Sorting keeps that independent of the tags' order.
|
||||||
|
val ids = categories.sorted().associateWith { name ->
|
||||||
|
var bits = hash(name) and TAG_BITS
|
||||||
|
while (!used.add(bits)) bits = (bits + 1) and TAG_BITS
|
||||||
|
(((taskId shl 31) or bits) shl 1) or 1L
|
||||||
|
}
|
||||||
|
return categories.associateWith(ids::getValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun categoryTask(propertyId: Long): Long = propertyId ushr 32
|
||||||
|
|
||||||
|
private const val TAG_BITS = 0x7FFFFFFFL
|
||||||
|
|
||||||
|
/** 64-bit FNV-1a over the spelling's UTF-8, folded to 31 bits. */
|
||||||
|
private fun tagHash(name: String): Long {
|
||||||
|
var hash = -0x340d631b7bdddcdbL
|
||||||
|
name.encodeToByteArray().forEach { byte ->
|
||||||
|
hash = (hash xor (byte.toLong() and 0xFF)) * 0x100000001b3L
|
||||||
|
}
|
||||||
|
return (hash xor (hash ushr 31) xor (hash ushr 62)) and TAG_BITS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Room store laid out as OpenTasks' tables, ready to be queried. */
|
||||||
|
class ProviderSnapshot(val rows: Map<ProviderTable, List<ProviderRow>>) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val EMPTY = ProviderSnapshot(ProviderTable.entries.associateWith { emptyList() })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays out the store. [tasks] must hold no tombstones. [accountType] is
|
||||||
|
* what a list attached to an account reports; a device-only list reports
|
||||||
|
* OpenTasks' local account so clients treat it the way they already do.
|
||||||
|
*/
|
||||||
|
fun build(
|
||||||
|
lists: List<TaskListRow>,
|
||||||
|
tasks: List<TaskEntity>,
|
||||||
|
alarms: List<TaskAlarmEntity>,
|
||||||
|
accountType: String,
|
||||||
|
now: Instant,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
): ProviderSnapshot {
|
||||||
|
val listsById = lists.associateBy { it.list.id }
|
||||||
|
val alarmsByTask = alarms.groupBy { it.taskId }
|
||||||
|
val taskRows = tasks.mapNotNull { task ->
|
||||||
|
val list = listsById[task.listId] ?: return@mapNotNull null
|
||||||
|
task.id to taskColumns(task, list, alarmsByTask[task.id].orEmpty().isNotEmpty(), accountType)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
val overrides = tasks.filter { it.masterId != null }.groupBy { it.masterId }
|
||||||
|
val instances = tasks.filter { it.masterId == null && it.id in taskRows }.flatMap { task ->
|
||||||
|
occurrences(task, overrides[task.id].orEmpty(), now).mapNotNull { occurrence ->
|
||||||
|
val columns = taskRows[occurrence.row.id] ?: return@mapNotNull null
|
||||||
|
columns + instanceColumns(ProviderIds.instance(task.id, occurrence.originalTime), occurrence, zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderSnapshot(
|
||||||
|
mapOf(
|
||||||
|
ProviderTable.LISTS to lists.map { listColumns(it, accountType) },
|
||||||
|
ProviderTable.TASKS to taskRows.map { (id, columns) -> columns + (Tasks.ID to id) },
|
||||||
|
ProviderTable.INSTANCES to instances,
|
||||||
|
ProviderTable.PROPERTIES to properties(tasks.filter { it.id in taskRows }, alarms),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listColumns(row: TaskListRow, accountType: String): ProviderRow = mapOf(
|
||||||
|
Lists.ID to row.list.id,
|
||||||
|
Lists.NAME to row.list.name,
|
||||||
|
Lists.COLOR to row.list.color,
|
||||||
|
Lists.ACCESS_LEVEL to 0,
|
||||||
|
Lists.VISIBLE to row.list.isVisible.bit,
|
||||||
|
Lists.SYNC_ENABLED to row.list.isSynced.bit,
|
||||||
|
Lists.OWNER to row.list.owner,
|
||||||
|
Lists.ACCOUNT_NAME to accountName(row),
|
||||||
|
Lists.ACCOUNT_TYPE to accountTypeOf(row, accountType),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun taskColumns(task: TaskEntity, list: TaskListRow, hasAlarms: Boolean, accountType: String) =
|
||||||
|
mapOf(
|
||||||
|
Tasks.LIST_ID to task.listId,
|
||||||
|
Tasks.LIST_NAME to list.list.name,
|
||||||
|
Tasks.LIST_COLOR to list.list.color,
|
||||||
|
Tasks.LIST_OWNER to list.list.owner,
|
||||||
|
Tasks.LIST_ACCESS_LEVEL to 0,
|
||||||
|
Tasks.VISIBLE to list.list.isVisible.bit,
|
||||||
|
Tasks.ACCOUNT_NAME to accountName(list),
|
||||||
|
Tasks.ACCOUNT_TYPE to accountTypeOf(list, accountType),
|
||||||
|
Tasks.UID to task.uid,
|
||||||
|
Tasks.DELETED to 0,
|
||||||
|
Tasks.TITLE to task.title,
|
||||||
|
Tasks.DESCRIPTION to task.description,
|
||||||
|
Tasks.LOCATION to task.location,
|
||||||
|
Tasks.URL to task.url,
|
||||||
|
Tasks.PRIORITY to task.priority,
|
||||||
|
Tasks.CLASSIFICATION to task.classification,
|
||||||
|
Tasks.STATUS to task.status.toInt(),
|
||||||
|
Tasks.PERCENT_COMPLETE to task.percentComplete,
|
||||||
|
Tasks.COMPLETED to task.completedAt?.toEpochMilliseconds(),
|
||||||
|
Tasks.IS_CLOSED to (task.status == TaskStatus.COMPLETED || task.status == TaskStatus.CANCELLED).bit,
|
||||||
|
Tasks.IS_NEW to (task.status == TaskStatus.NEEDS_ACTION && task.percentComplete == null).bit,
|
||||||
|
Tasks.TASK_COLOR to task.color,
|
||||||
|
Tasks.DTSTART to task.dtstart?.toEpochMilliseconds(),
|
||||||
|
Tasks.DUE to task.due?.toEpochMilliseconds(),
|
||||||
|
Tasks.DURATION to task.duration,
|
||||||
|
Tasks.IS_ALLDAY to task.isAllDay.bit,
|
||||||
|
Tasks.TZ to task.timezone,
|
||||||
|
Tasks.RRULE to task.rrule,
|
||||||
|
Tasks.RDATE to task.rdate,
|
||||||
|
Tasks.EXDATE to task.exdate,
|
||||||
|
Tasks.ORIGINAL_INSTANCE_ID to task.masterId,
|
||||||
|
Tasks.ORIGINAL_INSTANCE_TIME to task.recurrenceId?.toEpochMilliseconds(),
|
||||||
|
Tasks.ORIGINAL_INSTANCE_ALLDAY to task.masterId?.let { task.isAllDay.bit },
|
||||||
|
Tasks.PARENT_ID to task.parentId,
|
||||||
|
Tasks.SORTING to task.sortOrder,
|
||||||
|
Tasks.HAS_ALARMS to hasAlarms.bit,
|
||||||
|
Tasks.CREATED to task.createdAt?.toEpochMilliseconds(),
|
||||||
|
Tasks.LAST_MODIFIED to task.lastModified?.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One instance: the row it reads from, its times, and where it sits in its series. */
|
||||||
|
private class Occurrence(
|
||||||
|
val row: TaskEntity,
|
||||||
|
val start: Instant?,
|
||||||
|
val due: Instant?,
|
||||||
|
val originalTime: Instant?,
|
||||||
|
val distance: Int,
|
||||||
|
val recurring: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun occurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<Occurrence> {
|
||||||
|
val series = seriesOccurrences(master, overrides, now)
|
||||||
|
?: return listOf(Occurrence(master, master.dtstart, master.due, null, 0, recurring = false))
|
||||||
|
return series.mapIndexed { index, occurrence ->
|
||||||
|
Occurrence(
|
||||||
|
row = occurrence.override ?: master,
|
||||||
|
start = occurrence.start,
|
||||||
|
due = occurrence.due,
|
||||||
|
originalTime = occurrence.anchor,
|
||||||
|
distance = occurrence.distance ?: index,
|
||||||
|
recurring = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun instanceColumns(id: Long, occurrence: Occurrence, zone: ZoneId): ProviderRow {
|
||||||
|
val allDay = occurrence.row.isAllDay
|
||||||
|
val start = occurrence.start?.toEpochMilliseconds()
|
||||||
|
val due = occurrence.due?.toEpochMilliseconds()
|
||||||
|
return mapOf(
|
||||||
|
Tasks.ID to id,
|
||||||
|
Instances.TASK_ID to occurrence.row.id,
|
||||||
|
Instances.INSTANCE_START to start,
|
||||||
|
Instances.INSTANCE_START_SORTING to start?.let { sorting(it, allDay, zone) },
|
||||||
|
Instances.INSTANCE_DUE to due,
|
||||||
|
Instances.INSTANCE_DUE_SORTING to due?.let { sorting(it, allDay, zone) },
|
||||||
|
Instances.INSTANCE_DURATION to if (start != null && due != null) due - start else null,
|
||||||
|
Instances.INSTANCE_ORIGINAL_TIME to occurrence.originalTime?.toEpochMilliseconds(),
|
||||||
|
Instances.DISTANCE_FROM_CURRENT to occurrence.distance,
|
||||||
|
Instances.IS_RECURRING to occurrence.recurring.bit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An all-day date is stored as UTC midnight; OpenTasks' sorting columns
|
||||||
|
* move it to local midnight so it sorts among the timed tasks of that day.
|
||||||
|
*/
|
||||||
|
private fun sorting(millis: Long, allDay: Boolean, zone: ZoneId): Long {
|
||||||
|
if (!allDay) return millis
|
||||||
|
val offset = zone.rules.getOffset(java.time.Instant.ofEpochMilli(millis)).totalSeconds * 1000L
|
||||||
|
return millis - offset
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun properties(tasks: List<TaskEntity>, alarms: List<TaskAlarmEntity>): List<ProviderRow> {
|
||||||
|
val live = tasks.mapTo(HashSet()) { it.id }
|
||||||
|
val alarmRows = alarms.filter { it.taskId in live }.map {
|
||||||
|
mapOf(
|
||||||
|
Properties.PROPERTY_ID to ProviderIds.alarm(it.id),
|
||||||
|
Properties.TASK_ID to it.taskId,
|
||||||
|
Properties.MIMETYPE to Alarm.MIMETYPE,
|
||||||
|
Alarm.MINUTES_BEFORE to it.minutesBefore.toString(),
|
||||||
|
Alarm.REFERENCE to when (it.reference) {
|
||||||
|
AlarmReference.DUE -> Alarm.REFERENCE_DUE
|
||||||
|
AlarmReference.START -> Alarm.REFERENCE_START
|
||||||
|
}.toString(),
|
||||||
|
Alarm.MESSAGE to it.message,
|
||||||
|
Alarm.ALARM_TYPE to Alarm.TYPE_MESSAGE.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val categoryRows = tasks.flatMap { task ->
|
||||||
|
ProviderIds.categories(task.id, task.categories).map { (name, id) ->
|
||||||
|
mapOf(
|
||||||
|
Properties.PROPERTY_ID to id,
|
||||||
|
Properties.TASK_ID to task.id,
|
||||||
|
Properties.MIMETYPE to Category.MIMETYPE,
|
||||||
|
Category.NAME to name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alarmRows + categoryRows
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accountName(row: TaskListRow): String =
|
||||||
|
row.accountDisplayName ?: TasksContract.LOCAL_ACCOUNT_NAME
|
||||||
|
|
||||||
|
private fun accountTypeOf(row: TaskListRow, accountType: String): String =
|
||||||
|
if (row.list.accountId == null) TasksContract.LOCAL_ACCOUNT_TYPE else accountType
|
||||||
|
|
||||||
|
private val Boolean.bit: Int get() = if (this) 1 else 0
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.room.InvalidationTracker
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StartupGate
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.OBSERVED_TABLES
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListRow
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Rebuilt at least this often, so the expansion window and `distance_from_current` follow the clock. */
|
||||||
|
private val MAX_AGE = 1.hours
|
||||||
|
|
||||||
|
private val STARTUP_WAIT = 10.seconds
|
||||||
|
|
||||||
|
private val NOTIFY_DELAY = 500.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves [AgendulaTaskProvider]: keeps a [SnapshotDatabase] of the Room store,
|
||||||
|
* rebuilt lazily after a write or a storage-mode switch, and tells observers of
|
||||||
|
* the provider's authority when either happens.
|
||||||
|
*
|
||||||
|
* Empty in [StorageMode.EXTERNAL]: the Room store is not the user's data then,
|
||||||
|
* and clients should read OpenTasks or tasks.org directly.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ProviderSnapshotSource internal constructor(
|
||||||
|
private val context: Context,
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
|
private val awaitStartup: suspend () -> Unit,
|
||||||
|
private val clock: Clock = Clock.System,
|
||||||
|
private val startupWait: Duration = STARTUP_WAIT,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
database: TasksDatabase,
|
||||||
|
resolver: ProviderResolver,
|
||||||
|
startupGate: StartupGate,
|
||||||
|
) : this(context, database, resolver, startupGate::awaitReady)
|
||||||
|
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
private val snapshot by lazy { SnapshotDatabase() }
|
||||||
|
private val generation = AtomicLong(1)
|
||||||
|
private var builtGeneration = 0L
|
||||||
|
private var builtAt: Instant? = null
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val notify = Runnable {
|
||||||
|
context.contentResolver.notifyChange(TasksContract.authorityUri(authority), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val authority: String get() = "${context.packageName}.$AUTHORITY_SUFFIX"
|
||||||
|
|
||||||
|
/** Starts watching for changes. Idempotent; touches the database, so not from the main thread. */
|
||||||
|
fun start() {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
database.invalidationTracker.addObserver(
|
||||||
|
object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
||||||
|
override fun onInvalidated(tables: Set<String>) {
|
||||||
|
if (resolver.mode() == StorageMode.OWN) changed()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resolver.onModeChanged(::changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun query(
|
||||||
|
table: ProviderTable,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
sortOrder: String?,
|
||||||
|
): Cursor {
|
||||||
|
refresh()
|
||||||
|
return snapshot.query(table, projection, selection, selectionArgs, sortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [block] holding the snapshot's lock, once startup has settled which
|
||||||
|
* store is the user's. A write refuses to run without that, where a read
|
||||||
|
* falls back to answering from whichever store autoMode() picks.
|
||||||
|
*/
|
||||||
|
fun <T> locked(block: () -> T): T {
|
||||||
|
start()
|
||||||
|
checkNotNull(runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }) {
|
||||||
|
"Agendula is still starting; try again"
|
||||||
|
}
|
||||||
|
return synchronized(this, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The [columns] of every row of [table] that [selection] matches, as of now. */
|
||||||
|
@Synchronized
|
||||||
|
fun rows(table: ProviderTable, columns: List<String>, selection: String?, selectionArgs: Array<out String>?): List<List<Any?>> =
|
||||||
|
query(table, columns.toTypedArray(), selection, selectionArgs, null).use { cursor ->
|
||||||
|
buildList {
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
add(
|
||||||
|
List(cursor.columnCount) { column ->
|
||||||
|
when (cursor.getType(column)) {
|
||||||
|
Cursor.FIELD_TYPE_NULL -> null
|
||||||
|
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(column)
|
||||||
|
else -> cursor.getString(column)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the snapshot stale at once. A provider write calls this so the next
|
||||||
|
* operation of the same batch sees it, before Room's own invalidation lands.
|
||||||
|
*/
|
||||||
|
fun invalidate() {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun refresh() {
|
||||||
|
start()
|
||||||
|
runBlocking { withTimeoutOrNull(startupWait) { awaitStartup() } }
|
||||||
|
val now = clock.now()
|
||||||
|
val current = generation.get()
|
||||||
|
val expired = builtAt.let { it == null || now - it > MAX_AGE }
|
||||||
|
if (current != builtGeneration || expired) {
|
||||||
|
snapshot.load(build(now))
|
||||||
|
builtGeneration = current
|
||||||
|
// Instance ids and distances move with the clock, so a timed rebuild is a change too.
|
||||||
|
if (expired && builtAt != null) notifyObservers()
|
||||||
|
builtAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(now: Instant): ProviderSnapshot {
|
||||||
|
if (resolver.mode() != StorageMode.OWN) return ProviderSnapshot.EMPTY
|
||||||
|
// Read consistently in one transaction; expand outside it, so the app's
|
||||||
|
// own writes do not wait on recurrence expansion.
|
||||||
|
val (lists, tasks, alarms) = database.runInTransaction<Triple<List<TaskListRow>, List<TaskEntity>, List<TaskAlarmEntity>>> {
|
||||||
|
Triple(database.taskLists().lists(), database.tasks().live(), database.alarms().all())
|
||||||
|
}
|
||||||
|
return ProviderSnapshot.build(lists, tasks, alarms, context.getString(R.string.account_type), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changed() {
|
||||||
|
generation.incrementAndGet()
|
||||||
|
notifyObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coalesced, so a sync committing many small transactions wakes each client once. */
|
||||||
|
private fun notifyObservers() {
|
||||||
|
handler.removeCallbacks(notify)
|
||||||
|
handler.postDelayed(notify, NOTIFY_DELAY.inWholeMilliseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY_SUFFIX = "tasks"
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.forAllDay
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import de.jeanlucmakiola.agendula.domain.statusFromInt
|
||||||
|
import org.dmfs.rfc5545.recur.RecurrenceRule
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** A client's `ContentValues`, as plain Kotlin values. */
|
||||||
|
typealias ProviderValues = Map<String, Any?>
|
||||||
|
|
||||||
|
/** The `tasks` columns a client may write. Everything else the table publishes is ignored on a write. */
|
||||||
|
val WRITABLE_TASK_COLUMNS: Set<String> = setOf(
|
||||||
|
Tasks.LIST_ID, Tasks.UID, Tasks.TITLE, Tasks.DESCRIPTION, Tasks.LOCATION, Tasks.URL, Tasks.PRIORITY,
|
||||||
|
Tasks.CLASSIFICATION, Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED, Tasks.TASK_COLOR, Tasks.DTSTART,
|
||||||
|
Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE, Tasks.PARENT_ID,
|
||||||
|
Tasks.SORTING,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The columns that say whether a task, or one occurrence of it, is done. */
|
||||||
|
val COMPLETION_COLUMNS: Set<String> = setOf(Tasks.STATUS, Tasks.PERCENT_COMPLETE, Tasks.COMPLETED)
|
||||||
|
|
||||||
|
/** The columns that place a task in time; one occurrence of a series cannot be moved through the provider. */
|
||||||
|
val TIME_COLUMNS: Set<String> = setOf(
|
||||||
|
Tasks.DTSTART, Tasks.DUE, Tasks.DURATION, Tasks.IS_ALLDAY, Tasks.TZ, Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a write to one occurrence of a series does to it. */
|
||||||
|
enum class OccurrenceChange { COMPLETE, CANCEL, REOPEN }
|
||||||
|
|
||||||
|
private val ICAL_DATE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||||
|
|
||||||
|
private val ICAL_DURATION = Regex("""[+-]?P(\d+W|(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?)""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a client's OpenTasks `ContentValues` into [TaskEntity] changes. Pure, so
|
||||||
|
* the rules are testable on the JVM. A bad value throws [IllegalArgumentException],
|
||||||
|
* which reaches the client as is.
|
||||||
|
*
|
||||||
|
* Only [WRITABLE_TASK_COLUMNS] change anything. The rest of what the provider
|
||||||
|
* publishes is accepted and ignored, so a client can write back a row it read;
|
||||||
|
* the fields Agendula keeps for CalDAV (`href`, `etag`, `sequence`, unknown
|
||||||
|
* properties) are not published and so cannot be written at all.
|
||||||
|
*/
|
||||||
|
object ProviderTaskWriter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [values] narrowed to the columns in [writable]. A column [table] does not
|
||||||
|
* have at all is a client bug and throws.
|
||||||
|
*/
|
||||||
|
fun writable(table: ProviderTable, values: ProviderValues, writable: Set<String>): ProviderValues {
|
||||||
|
val known = table.columns.mapTo(HashSet()) { it.first }
|
||||||
|
val unknown = values.keys - known
|
||||||
|
require(unknown.isEmpty()) { "Unknown column(s) for ${table.path}: ${unknown.sorted().joinToString()}" }
|
||||||
|
return values.filterKeys { it in writable }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new task from [values], which must name its list. */
|
||||||
|
fun newTask(values: ProviderValues, uid: String, now: Instant, tzId: String): TaskEntity {
|
||||||
|
val v = Values(values)
|
||||||
|
val listId = requireNotNull(v.long(Tasks.LIST_ID)) { "A new task needs a ${Tasks.LIST_ID}" }
|
||||||
|
val base = TaskEntity(listId = listId, uid = v.string(Tasks.UID)?.trim()?.ifEmpty { null } ?: uid, createdAt = now)
|
||||||
|
// As in OpenTasks, a new task inserted without a zone is floating.
|
||||||
|
val zoned = if (Tasks.TZ in v) values else values + (Tasks.TZ to null)
|
||||||
|
return applied(base, zoned - Tasks.LIST_ID - Tasks.UID, now, tzId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [current] with [values] applied. Identity and sync columns are left alone,
|
||||||
|
* and [current] itself comes back when nothing changed, so a write that
|
||||||
|
* changes nothing owes the server nothing.
|
||||||
|
*/
|
||||||
|
fun patch(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
|
||||||
|
val edited = applied(current, values, now, tzId)
|
||||||
|
return if (edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) == current) current else edited
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The columns in [values] that would change [current] on their own. Writing
|
||||||
|
* back what a row published changes nothing, so a client that read a row can
|
||||||
|
* send all of it with the one value it meant to change.
|
||||||
|
*/
|
||||||
|
fun changedColumns(current: TaskEntity, values: ProviderValues, tzId: String): Set<String> {
|
||||||
|
val stamp = current.lastModified ?: Instant.DISTANT_PAST
|
||||||
|
return values.keys.filterTo(HashSet()) { key ->
|
||||||
|
val edited = runCatching { applied(current, mapOf(key to values[key]), stamp, tzId) }.getOrNull()
|
||||||
|
edited == null || edited.copy(lastModified = current.lastModified, isDirty = current.isDirty) != current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applied(current: TaskEntity, values: ProviderValues, now: Instant, tzId: String): TaskEntity {
|
||||||
|
val v = Values(values)
|
||||||
|
val isOverride = current.masterId != null
|
||||||
|
if (Tasks.UID in v) require(v.string(Tasks.UID)?.trim() == current.uid) { "A task's ${Tasks.UID} cannot change" }
|
||||||
|
if (isOverride) {
|
||||||
|
// NULL is what the row publishes, so writing it back is fine.
|
||||||
|
require(listOf(Tasks.RRULE, Tasks.RDATE, Tasks.EXDATE).none { it in v && v.text(it) != null }) {
|
||||||
|
"A modified occurrence carries no recurrence rule of its own"
|
||||||
|
}
|
||||||
|
if (Tasks.LIST_ID in v) require(v.long(Tasks.LIST_ID) == current.listId) { "A modified occurrence stays in its series' list" }
|
||||||
|
if (Tasks.PARENT_ID in v) require(v.long(Tasks.PARENT_ID) == current.parentId) { "A modified occurrence keeps its series' parent" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val listId = if (Tasks.LIST_ID in v) requireNotNull(v.long(Tasks.LIST_ID)) { "${Tasks.LIST_ID} cannot be null" } else current.listId
|
||||||
|
val parentId = if (Tasks.PARENT_ID in v) v.long(Tasks.PARENT_ID)?.takeIf { it > 0 } else current.parentId
|
||||||
|
require(parentId == null || parentId != current.id || current.id == 0L) { "A task cannot be its own parent" }
|
||||||
|
|
||||||
|
val allDay = if (Tasks.IS_ALLDAY in v) v.boolean(Tasks.IS_ALLDAY) else current.isAllDay
|
||||||
|
val dtstart = (if (Tasks.DTSTART in v) v.instant(Tasks.DTSTART) else current.dtstart)?.forAllDay(allDay)
|
||||||
|
val due = (if (Tasks.DUE in v) v.instant(Tasks.DUE) else current.due)?.forAllDay(allDay)
|
||||||
|
val duration = when {
|
||||||
|
Tasks.DURATION in v -> v.string(Tasks.DURATION)?.trim()?.ifEmpty { null }
|
||||||
|
?.also { require(ICAL_DURATION.matches(it)) { "Not an iCalendar duration: $it" } }
|
||||||
|
Tasks.DUE in v && due != null -> null
|
||||||
|
else -> current.duration
|
||||||
|
}
|
||||||
|
require(due == null || duration == null) { "${Tasks.DUE} and ${Tasks.DURATION} are mutually exclusive" }
|
||||||
|
require(dtstart == null || due == null || due >= dtstart) { "${Tasks.DUE} is before ${Tasks.DTSTART}" }
|
||||||
|
val timezone = when {
|
||||||
|
allDay -> null
|
||||||
|
Tasks.TZ in v -> v.string(Tasks.TZ)?.also { zone ->
|
||||||
|
require(runCatching { ZoneId.of(zone) }.isSuccess) { "Unknown time zone: $zone" }
|
||||||
|
}
|
||||||
|
dtstart == null && due == null -> null
|
||||||
|
// A timed task without a zone is floating (RFC 5545 §3.3.5) and stays so.
|
||||||
|
current.dtstart != null || current.due != null -> if (current.isAllDay) tzId else current.timezone
|
||||||
|
else -> current.timezone ?: tzId
|
||||||
|
}
|
||||||
|
|
||||||
|
val rrule = if (Tasks.RRULE in v) normalizedRule(v.string(Tasks.RRULE)) else current.rrule
|
||||||
|
val ruleDropped = Tasks.RRULE in v && rrule == null && current.rrule != null
|
||||||
|
val rdate = if (Tasks.RDATE in v) dateList(Tasks.RDATE, v, current.rdate) else current.rdate.takeUnless { ruleDropped }
|
||||||
|
val exdate = if (Tasks.EXDATE in v) dateList(Tasks.EXDATE, v, current.exdate) else current.exdate.takeUnless { ruleDropped }
|
||||||
|
require(rrule == null || dtstart != null || due != null) { "A recurring task needs a ${Tasks.DTSTART} or ${Tasks.DUE}" }
|
||||||
|
|
||||||
|
val (status, percent, completedAt) = completion(current, v, now)
|
||||||
|
|
||||||
|
return current.copy(
|
||||||
|
listId = listId,
|
||||||
|
title = if (Tasks.TITLE in v) v.string(Tasks.TITLE)?.trim() else current.title,
|
||||||
|
description = if (Tasks.DESCRIPTION in v) v.text(Tasks.DESCRIPTION) else current.description,
|
||||||
|
location = if (Tasks.LOCATION in v) v.text(Tasks.LOCATION) else current.location,
|
||||||
|
url = if (Tasks.URL in v) v.text(Tasks.URL) else current.url,
|
||||||
|
priority = if (Tasks.PRIORITY in v) v.int(Tasks.PRIORITY, 0..9) ?: TasksContract.PRIORITY_NONE else current.priority,
|
||||||
|
classification = if (Tasks.CLASSIFICATION in v) v.int(Tasks.CLASSIFICATION, 0..2) else current.classification,
|
||||||
|
color = if (Tasks.TASK_COLOR in v) v.int(Tasks.TASK_COLOR) else current.color,
|
||||||
|
sortOrder = if (Tasks.SORTING in v) v.int(Tasks.SORTING) ?: 0 else current.sortOrder,
|
||||||
|
status = status,
|
||||||
|
percentComplete = percent,
|
||||||
|
completedAt = completedAt,
|
||||||
|
dtstart = dtstart,
|
||||||
|
due = due,
|
||||||
|
duration = duration,
|
||||||
|
isAllDay = allDay,
|
||||||
|
timezone = timezone,
|
||||||
|
rrule = rrule,
|
||||||
|
rdate = rdate,
|
||||||
|
exdate = exdate,
|
||||||
|
parentId = parentId,
|
||||||
|
lastModified = now,
|
||||||
|
isDirty = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What [values] do to one occurrence of a series, or `null` when they say
|
||||||
|
* nothing about its completion. An occurrence is done, cancelled or open;
|
||||||
|
* partial progress belongs to a task, not to one of its occurrences.
|
||||||
|
*/
|
||||||
|
fun occurrenceChange(values: ProviderValues): OccurrenceChange? {
|
||||||
|
val v = Values(values.filterKeys { it in COMPLETION_COLUMNS })
|
||||||
|
return when {
|
||||||
|
Tasks.STATUS in v -> when (v.int(Tasks.STATUS, 0..3)) {
|
||||||
|
TasksContract.STATUS_COMPLETED -> OccurrenceChange.COMPLETE
|
||||||
|
TasksContract.STATUS_CANCELLED -> OccurrenceChange.CANCEL
|
||||||
|
TasksContract.STATUS_NEEDS_ACTION, null -> OccurrenceChange.REOPEN
|
||||||
|
else -> throw IllegalArgumentException("An occurrence is completed, cancelled or open, not in process")
|
||||||
|
}
|
||||||
|
Tasks.PERCENT_COMPLETE in v -> when (v.int(Tasks.PERCENT_COMPLETE, 0..100)) {
|
||||||
|
100 -> OccurrenceChange.COMPLETE
|
||||||
|
0, null -> OccurrenceChange.REOPEN
|
||||||
|
else -> throw IllegalArgumentException("An occurrence is completed or open; partial progress belongs to its task")
|
||||||
|
}
|
||||||
|
Tasks.COMPLETED in v -> if (v.instant(Tasks.COMPLETED) != null) OccurrenceChange.COMPLETE else OccurrenceChange.REOPEN
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status, progress and completion time after a write. They move together the
|
||||||
|
* way OpenTasks keeps them: a status implies its progress and time, progress
|
||||||
|
* implies a status, and a completion time alone completes the task. When a
|
||||||
|
* row is written back, the column that changed decides, not the ones it
|
||||||
|
* carries unchanged.
|
||||||
|
*/
|
||||||
|
private fun completion(current: TaskEntity, values: Values, now: Instant): Triple<TaskStatus, Int?, Instant?> {
|
||||||
|
val v = Values(values.map.filterKeys { it !in COMPLETION_COLUMNS || it in completionChanged(current, values) })
|
||||||
|
val percent = if (Tasks.PERCENT_COMPLETE in v) v.int(Tasks.PERCENT_COMPLETE, 0..100) else current.percentComplete
|
||||||
|
val completed = if (Tasks.COMPLETED in v) v.instant(Tasks.COMPLETED) else current.completedAt
|
||||||
|
return when {
|
||||||
|
Tasks.STATUS in v -> when (val status = statusFromInt(v.int(Tasks.STATUS, 0..3))) {
|
||||||
|
TaskStatus.COMPLETED -> Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent else 100, completed ?: now)
|
||||||
|
TaskStatus.NEEDS_ACTION ->
|
||||||
|
Triple(status, if (Tasks.PERCENT_COMPLETE in v) percent?.takeIf { it < 100 } else null, null)
|
||||||
|
else -> Triple(status, percent, null)
|
||||||
|
}
|
||||||
|
Tasks.PERCENT_COMPLETE in v -> when {
|
||||||
|
percent == null -> Triple(current.status, null, current.completedAt)
|
||||||
|
percent >= 100 -> Triple(TaskStatus.COMPLETED, percent, completed ?: now)
|
||||||
|
percent > 0 -> Triple(TaskStatus.IN_PROCESS, percent, null)
|
||||||
|
else -> Triple(TaskStatus.NEEDS_ACTION, percent, null)
|
||||||
|
}
|
||||||
|
Tasks.COMPLETED in v -> when {
|
||||||
|
completed != null -> Triple(TaskStatus.COMPLETED, 100, completed)
|
||||||
|
// Clearing the time reopens a completed task; an open one has none to clear.
|
||||||
|
current.status == TaskStatus.COMPLETED -> Triple(TaskStatus.NEEDS_ACTION, null, null)
|
||||||
|
else -> Triple(current.status, current.percentComplete, null)
|
||||||
|
}
|
||||||
|
else -> Triple(current.status, current.percentComplete, current.completedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The completion columns in [v] that differ from [current]. All of them when
|
||||||
|
* none does, so a write that only repeats the row still settles the rest.
|
||||||
|
*/
|
||||||
|
private fun completionChanged(current: TaskEntity, v: Values): Set<String> {
|
||||||
|
val present = COMPLETION_COLUMNS.filterTo(HashSet()) { it in v }
|
||||||
|
val changed = present.filterTo(HashSet()) { column ->
|
||||||
|
when (column) {
|
||||||
|
Tasks.STATUS -> statusFromInt(v.int(Tasks.STATUS, 0..3)) != current.status
|
||||||
|
Tasks.PERCENT_COMPLETE -> v.int(Tasks.PERCENT_COMPLETE, 0..100) != current.percentComplete
|
||||||
|
else -> v.instant(Tasks.COMPLETED) != current.completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed.ifEmpty { present }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `RDATE`/`EXDATE` value: DATE or DATE-TIME values, comma-separated, of
|
||||||
|
* one kind. [stored] written back as is stays, whatever shape it synced in.
|
||||||
|
*/
|
||||||
|
private fun dateList(column: String, v: Values, stored: String?): String? {
|
||||||
|
val raw = v.string(column)?.trim()?.ifEmpty { null } ?: return null
|
||||||
|
if (raw == stored?.trim()) return stored
|
||||||
|
val values = raw.split(',').map(String::trim)
|
||||||
|
require(values.all { ICAL_DATE.matches(it) }) { "$column takes iCalendar dates or date-times: $raw" }
|
||||||
|
// lib-recur reads the whole list or none of it, so it has to be one kind.
|
||||||
|
val kinds = values.map { if (it.length == 8) "date" else if (it.endsWith('Z')) "utc" else "local" }
|
||||||
|
require(kinds.distinct().size == 1) { "$column mixes value kinds: $raw" }
|
||||||
|
return values.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizedRule(raw: String?): String? {
|
||||||
|
val rule = raw?.trim()?.removePrefix("RRULE:")?.ifEmpty { null } ?: return null
|
||||||
|
require(runCatching { RecurrenceRule(rule) }.isSuccess) { "Not a recurrence rule: $rule" }
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lenient reads over [values]: a client may hand a number as a string or a flag as an integer. */
|
||||||
|
internal class Values(private val values: ProviderValues) {
|
||||||
|
|
||||||
|
val keys: Set<String> get() = values.keys
|
||||||
|
|
||||||
|
val map: ProviderValues get() = values
|
||||||
|
|
||||||
|
operator fun contains(key: String) = key in values
|
||||||
|
|
||||||
|
fun string(key: String): String? = values[key]?.toString()
|
||||||
|
|
||||||
|
/** A free-text column: trimmed, blank stored as nothing. */
|
||||||
|
fun text(key: String): String? = string(key)?.trim()?.ifEmpty { null }
|
||||||
|
|
||||||
|
fun long(key: String): Long? = when (val value = values[key]) {
|
||||||
|
null -> null
|
||||||
|
is Number -> value.toLong()
|
||||||
|
is Boolean -> if (value) 1L else 0L
|
||||||
|
is String -> requireNotNull(value.trim().toLongOrNull()) { "$key is not a number: $value" }
|
||||||
|
else -> throw IllegalArgumentException("$key is not a number: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun int(key: String, range: IntRange? = null): Int? = long(key)?.let { value ->
|
||||||
|
require(value in Int.MIN_VALUE..Int.MAX_VALUE) { "$key is out of range: $value" }
|
||||||
|
value.toInt().also { require(range == null || it in range) { "$key must be in $range: $it" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun boolean(key: String): Boolean = when (val value = values[key]) {
|
||||||
|
null -> false
|
||||||
|
is Boolean -> value
|
||||||
|
is String -> when (value.trim().lowercase()) {
|
||||||
|
"1", "true" -> true
|
||||||
|
"0", "false", "" -> false
|
||||||
|
else -> throw IllegalArgumentException("$key is not a flag: $value")
|
||||||
|
}
|
||||||
|
else -> long(key) != 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun instant(key: String): Instant? = long(key)?.let(Instant::fromEpochMilliseconds)
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.seriesOccurrences
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import de.jeanlucmakiola.agendula.domain.normalizeTags
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** One row by id, `<id column> = N` or `<id column> = ?`. */
|
||||||
|
private val SINGLE_ROW = Regex("""\s*(\w+)\s*=\s*(\d+|\?)\s*""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies another app's writes to the own store for [AgendulaTaskProvider].
|
||||||
|
*
|
||||||
|
* A write's selection runs against the published snapshot, so it matches the
|
||||||
|
* rows the client can read, and each matched row is then written through
|
||||||
|
* [RoomTasksDataSource] under the rules the app's own edits follow. `tasklists`
|
||||||
|
* stays read-only. On `instances`, completion columns tick off that one
|
||||||
|
* occurrence of a series; other content columns edit the series, and a changed
|
||||||
|
* time column is refused, since one occurrence cannot be moved this way.
|
||||||
|
*
|
||||||
|
* Each call runs in one transaction, and a batch in one more around them.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ProviderWriter internal constructor(
|
||||||
|
private val database: TasksDatabase,
|
||||||
|
private val store: RoomTasksDataSource,
|
||||||
|
private val source: ProviderSnapshotSource,
|
||||||
|
private val resolver: ProviderResolver,
|
||||||
|
private val afterWrite: () -> Unit,
|
||||||
|
private val clock: Clock = Clock.System,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Inject constructor(
|
||||||
|
database: TasksDatabase,
|
||||||
|
store: RoomTasksDataSource,
|
||||||
|
source: ProviderSnapshotSource,
|
||||||
|
resolver: ProviderResolver,
|
||||||
|
scheduler: ReminderScheduler,
|
||||||
|
@ApplicationScope scope: CoroutineScope,
|
||||||
|
) : this(database, store, source, resolver, { scope.launch { runCatching { scheduler.sync() } } })
|
||||||
|
|
||||||
|
private var batchDepth = 0
|
||||||
|
private var batchWrote = false
|
||||||
|
|
||||||
|
/** Inserts one row and returns its published id. */
|
||||||
|
fun insert(table: ProviderTable, values: ProviderValues): Long = write {
|
||||||
|
when (table) {
|
||||||
|
ProviderTable.TASKS -> insertTask(values)
|
||||||
|
ProviderTable.PROPERTIES -> insertProperty(values)
|
||||||
|
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates every row [selection] matches and returns how many changed. */
|
||||||
|
fun update(table: ProviderTable, values: ProviderValues, selection: String?, args: Array<out String>?): Int = write {
|
||||||
|
when (table) {
|
||||||
|
ProviderTable.TASKS -> {
|
||||||
|
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||||
|
val matched = ids(table, selection, args)
|
||||||
|
(if (Tasks.LIST_ID in writable) parentsFirst(matched) else matched).count { updateTask(it, writable) }
|
||||||
|
}
|
||||||
|
ProviderTable.INSTANCES -> {
|
||||||
|
val writable = ProviderTaskWriter.writable(table, values, WRITABLE_TASK_COLUMNS)
|
||||||
|
instances(selection, args).count { updateInstance(it, writable) }
|
||||||
|
}
|
||||||
|
ProviderTable.PROPERTIES -> ids(table, selection, args).count { updateProperty(it, values) }
|
||||||
|
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes every row [selection] matches and returns how many went. */
|
||||||
|
fun delete(table: ProviderTable, selection: String?, args: Array<out String>?): Int = write {
|
||||||
|
when (table) {
|
||||||
|
ProviderTable.TASKS -> ids(table, selection, args).count { deleteTask(it) }
|
||||||
|
ProviderTable.INSTANCES -> instances(selection, args).count { deleteInstance(it) }
|
||||||
|
ProviderTable.PROPERTIES -> ids(table, selection, args).count { deleteProperty(it) }
|
||||||
|
else -> throw UnsupportedOperationException("${table.path} is read-only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a client's batch in one transaction, re-arming reminders once at the end. */
|
||||||
|
fun <T> batch(block: () -> T): T = source.locked {
|
||||||
|
batchDepth++
|
||||||
|
try {
|
||||||
|
database.runInTransaction<T> { block() }
|
||||||
|
} finally {
|
||||||
|
// A snapshot built mid-batch may hold rows a failure rolled back.
|
||||||
|
source.invalidate()
|
||||||
|
batchDepth--
|
||||||
|
if (batchDepth == 0 && batchWrote) {
|
||||||
|
batchWrote = false
|
||||||
|
afterWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> write(block: () -> T): T = source.locked {
|
||||||
|
check(resolver.mode() == StorageMode.OWN) {
|
||||||
|
"Agendula is working on top of an external tasks provider; write to that one instead"
|
||||||
|
}
|
||||||
|
val result = try {
|
||||||
|
database.runInTransaction<T> { block() }
|
||||||
|
} catch (e: TaskWriteFailedException) {
|
||||||
|
throw IllegalArgumentException(e.message, e)
|
||||||
|
} catch (e: SQLiteConstraintException) {
|
||||||
|
throw IllegalArgumentException(e.message, e)
|
||||||
|
} finally {
|
||||||
|
source.invalidate()
|
||||||
|
}
|
||||||
|
if (batchDepth == 0) afterWrite() else batchWrote = true
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tasks ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun insertTask(values: ProviderValues): Long {
|
||||||
|
require(values[Tasks.ORIGINAL_INSTANCE_ID] == null && values[Tasks.ORIGINAL_INSTANCE_TIME] == null) {
|
||||||
|
"Modify an occurrence through ${Instances.PATH}, not by inserting one"
|
||||||
|
}
|
||||||
|
val writable = ProviderTaskWriter.writable(ProviderTable.TASKS, values, WRITABLE_TASK_COLUMNS)
|
||||||
|
val task = ProviderTaskWriter.newTask(writable, UUID.randomUUID().toString(), clock.now(), zone())
|
||||||
|
requireWritableList(task.listId)
|
||||||
|
// The unique index cannot catch this: SQLite treats the masters' NULL recurrence_id as distinct.
|
||||||
|
require(database.tasks().byUid(task.listId, task.uid) == null) { "List ${task.listId} already has a task ${task.uid}" }
|
||||||
|
requireParent(task)
|
||||||
|
return store.insertEntity(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateTask(taskId: Long, values: ProviderValues): Boolean {
|
||||||
|
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||||
|
requireWritableList(current.listId)
|
||||||
|
val edited = ProviderTaskWriter.patch(current, values, clock.now(), zone())
|
||||||
|
if (edited == current) return true
|
||||||
|
val moved = edited.listId != current.listId
|
||||||
|
if (moved) {
|
||||||
|
requireWritableList(edited.listId)
|
||||||
|
requireFreeUid(edited.listId, current.uid)
|
||||||
|
}
|
||||||
|
if (edited.parentId != current.parentId || moved) requireParent(edited)
|
||||||
|
store.replaceEntity(current, edited)
|
||||||
|
// A subtree moves as one: a subtask has to be in its parent's list.
|
||||||
|
if (moved) store.subtaskIds(taskId).forEach { moveSubtree(it, edited.listId, hashSetOf(taskId)) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveSubtree(taskId: Long, listId: Long, seen: MutableSet<Long> = HashSet()) {
|
||||||
|
if (!seen.add(taskId)) return
|
||||||
|
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return
|
||||||
|
if (current.listId != listId) {
|
||||||
|
requireFreeUid(listId, current.uid)
|
||||||
|
store.replaceEntity(current, current.copy(listId = listId, lastModified = clock.now(), isDirty = true))
|
||||||
|
}
|
||||||
|
store.subtaskIds(taskId).forEach { moveSubtree(it, listId, seen) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [ids] with every parent before its subtasks, so a bulk move carries a
|
||||||
|
* subtree along before reaching its subtasks, whatever order they came in.
|
||||||
|
*/
|
||||||
|
private fun parentsFirst(ids: List<Long>): List<Long> {
|
||||||
|
if (ids.size < 2) return ids
|
||||||
|
fun depth(id: Long): Int {
|
||||||
|
val seen = HashSet<Long>()
|
||||||
|
var parent = database.tasks().entity(id)?.parentId
|
||||||
|
while (parent != null && seen.add(parent)) parent = database.tasks().entity(parent)?.parentId
|
||||||
|
return seen.size
|
||||||
|
}
|
||||||
|
val depths = ids.associateWith(::depth)
|
||||||
|
return ids.sortedBy(depths::getValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteTask(taskId: Long): Boolean {
|
||||||
|
val current = database.tasks().entity(taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||||
|
requireWritableList(current.listId)
|
||||||
|
// An override deletes itself into an EXDATE on its master.
|
||||||
|
if (current.masterId != null) store.deleteTask(taskId) else store.deleteWithSubtasks(taskId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- instances --------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One occurrence: the row it reads from, its series, and when it was due to happen. */
|
||||||
|
private class InstanceRow(val taskId: Long, val seriesId: Long, val originalTime: Instant?, val recurring: Boolean)
|
||||||
|
|
||||||
|
private fun instances(selection: String?, args: Array<out String>?): List<InstanceRow> {
|
||||||
|
singleId(ProviderTable.INSTANCES, selection, args)?.let { return listOfNotNull(instanceRow(it)) }
|
||||||
|
return source.rows(
|
||||||
|
ProviderTable.INSTANCES,
|
||||||
|
listOf(Tasks.ID, Instances.TASK_ID, Instances.INSTANCE_ORIGINAL_TIME, Instances.IS_RECURRING),
|
||||||
|
selection,
|
||||||
|
args,
|
||||||
|
).map { (id, taskId, original, recurring) ->
|
||||||
|
InstanceRow(
|
||||||
|
taskId = taskId as Long,
|
||||||
|
seriesId = ProviderIds.instanceSeries(id as Long),
|
||||||
|
originalTime = (original as Long?)?.let(Instant::fromEpochMilliseconds),
|
||||||
|
recurring = recurring == 1L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [instanceId] read straight from the store, without rebuilding the snapshot. */
|
||||||
|
private fun instanceRow(instanceId: Long): InstanceRow? {
|
||||||
|
val master = database.tasks().entity(ProviderIds.instanceSeries(instanceId))
|
||||||
|
?.takeIf { !it.isDeleted && it.masterId == null } ?: return null
|
||||||
|
val occurrences = seriesOccurrences(master, database.tasks().overrides(master.id), clock.now())
|
||||||
|
?: return InstanceRow(master.id, master.id, null, recurring = false)
|
||||||
|
.takeIf { ProviderIds.instanceSeconds(instanceId) == 0L }
|
||||||
|
val occurrence = occurrences.firstOrNull { ProviderIds.instance(master.id, it.anchor) == instanceId } ?: return null
|
||||||
|
return InstanceRow(occurrence.override?.id ?: master.id, master.id, occurrence.anchor, recurring = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateInstance(row: InstanceRow, values: ProviderValues): Boolean {
|
||||||
|
val occurrence = row.originalTime
|
||||||
|
if (!row.recurring || occurrence == null) return updateTask(row.taskId, values)
|
||||||
|
val shown = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||||
|
requireWritableList(shown.listId)
|
||||||
|
// A row read back carries every column; only what differs from it is a write.
|
||||||
|
val changed = ProviderTaskWriter.changedColumns(shown, values, zone())
|
||||||
|
val moved = changed intersect TIME_COLUMNS
|
||||||
|
if (moved.isNotEmpty()) {
|
||||||
|
// A bad value reports itself rather than as a move.
|
||||||
|
ProviderTaskWriter.patch(shown, values.filterKeys { it in moved }, clock.now(), zone())
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"One occurrence of a recurring task cannot be moved (${moved.sorted().joinToString()}); write the series through ${Tasks.PATH}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val content = values.filterKeys { it in changed && it !in COMPLETION_COLUMNS }
|
||||||
|
if (content.isNotEmpty()) {
|
||||||
|
updateTask(row.seriesId, content)
|
||||||
|
// A modified occurrence shows its own copy of the content, so it takes the write too.
|
||||||
|
if (row.taskId != row.seriesId) updateTask(row.taskId, content - Tasks.LIST_ID - Tasks.PARENT_ID)
|
||||||
|
}
|
||||||
|
if (changed.none { it in COMPLETION_COLUMNS }) return true
|
||||||
|
// The column the client changed decides, not the unchanged ones it carries along.
|
||||||
|
val change = ProviderTaskWriter.occurrenceChange(values.filterKeys { it in changed && it in COMPLETION_COLUMNS })
|
||||||
|
?: return true
|
||||||
|
val status = database.tasks().entity(row.taskId)?.status
|
||||||
|
when (change) {
|
||||||
|
OccurrenceChange.COMPLETE ->
|
||||||
|
if (status != TaskStatus.COMPLETED) store.setCompletedInstance(row.taskId, occurrence, true)
|
||||||
|
OccurrenceChange.CANCEL ->
|
||||||
|
if (status != TaskStatus.CANCELLED) store.setCancelledInstance(row.taskId, occurrence, true)
|
||||||
|
OccurrenceChange.REOPEN ->
|
||||||
|
if (status != TaskStatus.NEEDS_ACTION) store.setCompletedInstance(row.taskId, occurrence, false)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteInstance(row: InstanceRow): Boolean {
|
||||||
|
val occurrence = row.originalTime
|
||||||
|
if (!row.recurring || occurrence == null) return deleteTask(row.taskId)
|
||||||
|
val task = database.tasks().entity(row.taskId)?.takeUnless { it.isDeleted } ?: return false
|
||||||
|
requireWritableList(task.listId)
|
||||||
|
if (task.masterId != null) store.deleteTask(task.id) else store.deleteOccurrence(task.id, occurrence)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- properties -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What a published property id stands for, read from the store. */
|
||||||
|
private sealed interface PropertyRef {
|
||||||
|
val task: TaskEntity
|
||||||
|
|
||||||
|
class AlarmRow(val alarm: TaskAlarmEntity, override val task: TaskEntity) : PropertyRef
|
||||||
|
class CategoryRow(val name: String, override val task: TaskEntity) : PropertyRef
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun propertyRef(propertyId: Long): PropertyRef? {
|
||||||
|
if (ProviderIds.isAlarm(propertyId)) {
|
||||||
|
val alarm = database.alarms().entity(ProviderIds.alarmRow(propertyId)) ?: return null
|
||||||
|
val task = database.tasks().entity(alarm.taskId)?.takeUnless { it.isDeleted } ?: return null
|
||||||
|
return PropertyRef.AlarmRow(alarm, task)
|
||||||
|
}
|
||||||
|
val task = database.tasks().entity(ProviderIds.categoryTask(propertyId))?.takeUnless { it.isDeleted } ?: return null
|
||||||
|
val name = ProviderIds.categories(task.id, task.categories).entries.firstOrNull { it.value == propertyId }?.key
|
||||||
|
?: return null
|
||||||
|
return PropertyRef.CategoryRow(name, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertProperty(values: ProviderValues): Long {
|
||||||
|
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys))
|
||||||
|
val taskId = requireNotNull(v.long(Properties.TASK_ID)) { "A property needs a ${Properties.TASK_ID}" }
|
||||||
|
val task = liveTask(taskId)
|
||||||
|
return when (v.string(Properties.MIMETYPE)) {
|
||||||
|
Alarm.MIMETYPE -> {
|
||||||
|
val alarm = alarm(TaskAlarmEntity(taskId = taskId, minutesBefore = 0), v, requireMinutes = true)
|
||||||
|
var id = 0L
|
||||||
|
store.writeAlarm(taskId) { id = database.alarms().insert(alarm) }
|
||||||
|
ProviderIds.alarm(id)
|
||||||
|
}
|
||||||
|
Category.MIMETYPE -> {
|
||||||
|
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
|
||||||
|
setCategories(task, task.categories + name)
|
||||||
|
val categories = database.tasks().entity(taskId)!!.categories
|
||||||
|
ProviderIds.categories(taskId, categories).getValue(name)
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("Only alarm and category properties can be written")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateProperty(propertyId: Long, values: ProviderValues): Boolean {
|
||||||
|
val v = Values(ProviderTaskWriter.writable(ProviderTable.PROPERTIES, values, values.keys - Properties.PROPERTY_ID))
|
||||||
|
val ref = propertyRef(propertyId) ?: return false
|
||||||
|
requireWritableList(ref.task.listId)
|
||||||
|
requireSame(v, Properties.TASK_ID, ref.task.id)
|
||||||
|
val mimetype = if (ref is PropertyRef.AlarmRow) Alarm.MIMETYPE else Category.MIMETYPE
|
||||||
|
if (Properties.MIMETYPE in v) require(v.string(Properties.MIMETYPE) == mimetype) { "A property's mimetype cannot change" }
|
||||||
|
when (ref) {
|
||||||
|
is PropertyRef.AlarmRow -> {
|
||||||
|
val edited = alarm(ref.alarm, v, requireMinutes = false)
|
||||||
|
if (edited != ref.alarm) store.writeAlarm(ref.task.id) { database.alarms().update(edited) }
|
||||||
|
}
|
||||||
|
is PropertyRef.CategoryRow -> if (Category.NAME in v) {
|
||||||
|
val name = requireNotNull(v.text(Category.NAME)) { "A category needs a ${Category.NAME}" }
|
||||||
|
setCategories(ref.task, ref.task.categories.map { if (it == ref.name) name else it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteProperty(propertyId: Long): Boolean {
|
||||||
|
val ref = propertyRef(propertyId) ?: return false
|
||||||
|
requireWritableList(ref.task.listId)
|
||||||
|
when (ref) {
|
||||||
|
is PropertyRef.AlarmRow -> store.writeAlarm(ref.task.id) { database.alarms().delete(ref.alarm.id) }
|
||||||
|
is PropertyRef.CategoryRow -> setCategories(ref.task, ref.task.categories - ref.name)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alarm(base: TaskAlarmEntity, v: Values, requireMinutes: Boolean): TaskAlarmEntity {
|
||||||
|
if (requireMinutes) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) { "An alarm needs ${Alarm.MINUTES_BEFORE}" }
|
||||||
|
val reference = when (if (Alarm.REFERENCE in v) v.int(Alarm.REFERENCE) else null) {
|
||||||
|
null -> base.reference
|
||||||
|
Alarm.REFERENCE_DUE -> AlarmReference.DUE
|
||||||
|
Alarm.REFERENCE_START -> AlarmReference.START
|
||||||
|
else -> throw IllegalArgumentException("${Alarm.REFERENCE} must be ${Alarm.REFERENCE_DUE} (due) or ${Alarm.REFERENCE_START} (start)")
|
||||||
|
}
|
||||||
|
return base.copy(
|
||||||
|
minutesBefore = if (Alarm.MINUTES_BEFORE in v) requireNotNull(v.int(Alarm.MINUTES_BEFORE)) {
|
||||||
|
"${Alarm.MINUTES_BEFORE} cannot be null"
|
||||||
|
} else base.minutesBefore,
|
||||||
|
reference = reference,
|
||||||
|
message = if (Alarm.MESSAGE in v) v.text(Alarm.MESSAGE) else base.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCategories(task: TaskEntity, categories: List<String>) {
|
||||||
|
val normalized = normalizeTags(categories)
|
||||||
|
if (normalized == task.categories) return
|
||||||
|
store.replaceEntity(task, task.copy(categories = normalized, lastModified = clock.now(), isDirty = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- checks -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids [selection] matches. A row URI's plain `<id column> = N` is checked
|
||||||
|
* against the store directly; anything else runs against the snapshot.
|
||||||
|
*/
|
||||||
|
private fun ids(table: ProviderTable, selection: String?, args: Array<out String>?): List<Long> {
|
||||||
|
singleId(table, selection, args)?.let { id ->
|
||||||
|
val exists = when (table) {
|
||||||
|
ProviderTable.TASKS -> database.tasks().entity(id)?.isDeleted == false
|
||||||
|
ProviderTable.PROPERTIES -> propertyRef(id) != null
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
return listOfNotNull(id.takeIf { exists })
|
||||||
|
}
|
||||||
|
return source.rows(table, listOf(table.idColumn), selection, args).map { it.single() as Long }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun singleId(table: ProviderTable, selection: String?, args: Array<out String>?): Long? {
|
||||||
|
val match = SINGLE_ROW.matchEntire(selection ?: return null) ?: return null
|
||||||
|
if (match.groupValues[1] != table.idColumn) return null
|
||||||
|
val value = match.groupValues[2]
|
||||||
|
return when {
|
||||||
|
value == "?" -> args?.singleOrNull()?.trim()?.toLongOrNull()
|
||||||
|
args.isNullOrEmpty() -> value.toLongOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireFreeUid(listId: Long, uid: String) {
|
||||||
|
require(database.tasks().byUid(listId, uid)?.isDeleted != false) { "List $listId already has a task $uid" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun liveTask(taskId: Long): TaskEntity {
|
||||||
|
val task = requireNotNull(database.tasks().entity(taskId)?.takeUnless { it.isDeleted }) { "No task $taskId" }
|
||||||
|
requireWritableList(task.listId)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireWritableList(listId: Long) {
|
||||||
|
val list = requireNotNull(database.taskLists().entity(listId)) { "No task list $listId" }
|
||||||
|
if (list.isReadOnly) throw UnsupportedOperationException("Task list $listId is read-only")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parent has to be a live, top-level task in the same list, and not one of the task's own subtasks. */
|
||||||
|
private fun requireParent(task: TaskEntity) {
|
||||||
|
var parentId = task.parentId ?: return
|
||||||
|
val parent = database.tasks().entity(parentId)?.takeUnless { it.isDeleted }
|
||||||
|
require(parent != null && parent.masterId == null) { "No task $parentId to be a parent" }
|
||||||
|
require(parent.listId == task.listId) { "A subtask has to be in its parent's list" }
|
||||||
|
val seen = HashSet<Long>()
|
||||||
|
while (true) {
|
||||||
|
require(parentId != task.id) { "Task ${task.id} cannot be its own ancestor" }
|
||||||
|
if (!seen.add(parentId)) break
|
||||||
|
parentId = database.tasks().entity(parentId)?.parentId ?: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireSame(v: Values, column: String, expected: Long) {
|
||||||
|
if (column in v) require(v.long(column) == expected) { "A property's $column cannot change" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun zone(): String = ZoneId.systemDefault().id
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.provider
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteQueryBuilder
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.database.sqlite.transaction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An in-memory SQLite copy of a [ProviderSnapshot], so a client's own
|
||||||
|
* `selection`, `selectionArgs` and `sortOrder` run as SQL exactly as they would
|
||||||
|
* against OpenTasks. Holds only what the caller may already read.
|
||||||
|
*
|
||||||
|
* Not thread-safe; [ProviderSnapshotSource] serialises access.
|
||||||
|
*/
|
||||||
|
internal class SnapshotDatabase {
|
||||||
|
|
||||||
|
private val db: SQLiteDatabase = SQLiteDatabase.create(null).apply {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
execSQL(
|
||||||
|
"CREATE TABLE ${table.path} (" +
|
||||||
|
table.columns.joinToString { (name, type) -> "$name $type" } + ")",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(snapshot: ProviderSnapshot) {
|
||||||
|
db.transaction {
|
||||||
|
ProviderTable.entries.forEach { table ->
|
||||||
|
delete(table.path, null, null)
|
||||||
|
snapshot.rows[table].orEmpty().forEach { insertOrThrow(table.path, null, it.toContentValues()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the query and copies the result out, so the cursor a client holds
|
||||||
|
* survives the next [load].
|
||||||
|
*/
|
||||||
|
fun query(
|
||||||
|
table: ProviderTable,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
sortOrder: String?,
|
||||||
|
): Cursor {
|
||||||
|
val builder = SQLiteQueryBuilder().apply {
|
||||||
|
tables = table.path
|
||||||
|
isStrict = true
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
isStrictColumns = true
|
||||||
|
isStrictGrammar = true
|
||||||
|
}
|
||||||
|
projectionMap = table.columns.associate { (name, _) -> name to name }
|
||||||
|
}
|
||||||
|
builder.query(db, projection, selection, selectionArgs, null, null, sortOrder).use { source ->
|
||||||
|
val copy = MatrixCursor(source.columnNames, source.count)
|
||||||
|
while (source.moveToNext()) {
|
||||||
|
copy.addRow(
|
||||||
|
Array(source.columnCount) { column ->
|
||||||
|
when (source.getType(column)) {
|
||||||
|
Cursor.FIELD_TYPE_NULL -> null
|
||||||
|
Cursor.FIELD_TYPE_INTEGER -> source.getLong(column)
|
||||||
|
Cursor.FIELD_TYPE_FLOAT -> source.getDouble(column)
|
||||||
|
Cursor.FIELD_TYPE_BLOB -> source.getBlob(column)
|
||||||
|
else -> source.getString(column)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProviderRow.toContentValues() = ContentValues(size).also { values ->
|
||||||
|
forEach { (key, value) ->
|
||||||
|
when (value) {
|
||||||
|
null -> values.putNull(key)
|
||||||
|
is Long -> values.put(key, value)
|
||||||
|
is Int -> values.put(key, value)
|
||||||
|
is String -> values.put(key, value)
|
||||||
|
else -> values.put(key, value.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ interface AccountDao {
|
|||||||
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
@Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId")
|
||||||
fun recordSync(accountId: Long, at: Instant?, error: String?)
|
fun recordSync(accountId: Long, at: Instant?, error: String?)
|
||||||
|
|
||||||
|
@Query("UPDATE accounts SET label = :label WHERE id = :accountId")
|
||||||
|
fun setLabel(accountId: Long, label: String?)
|
||||||
|
|
||||||
@Query("DELETE FROM accounts WHERE id = :accountId")
|
@Query("DELETE FROM accounts WHERE id = :accountId")
|
||||||
fun delete(accountId: Long): Int
|
fun delete(accountId: Long): Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||||
|
|
||||||
|
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.ICalValues
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts claimable `VALARM`s out of a residue written before v3 synced them.
|
||||||
|
*
|
||||||
|
* ⚠️ Frozen with [TasksDatabase.MIGRATION_2_3]: a copy of `AlarmCodec`'s claim
|
||||||
|
* rule as it stood then, not a call into it, so a later change to what the
|
||||||
|
* codec claims cannot change what this migration produced for an earlier
|
||||||
|
* upgrader.
|
||||||
|
*/
|
||||||
|
internal object AlarmLift {
|
||||||
|
|
||||||
|
/** The task columns the claim rule reads. */
|
||||||
|
data class Task(
|
||||||
|
val dtstart: Instant?,
|
||||||
|
val due: Instant?,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val recurring: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Alarm(val minutesBefore: Int, val reference: AlarmReference, val uid: String?, val raw: String)
|
||||||
|
|
||||||
|
/** The lifted alarms and the residue without them, or `null` residue when nothing is left. */
|
||||||
|
data class Result(val alarms: List<Alarm>, val residue: String?)
|
||||||
|
|
||||||
|
private const val DAY_MINUTES = 24 * 60
|
||||||
|
private const val MAX_RAW_BYTES = 25 * 1024
|
||||||
|
|
||||||
|
fun lift(residue: String?, task: Task): Result {
|
||||||
|
val component = parse(residue) ?: return Result(emptyList(), residue)
|
||||||
|
val alarms = mutableListOf<Alarm>()
|
||||||
|
val kept = component.components.filter { sub ->
|
||||||
|
if (!sub.name.equals("VALARM", ignoreCase = true)) return@filter true
|
||||||
|
val alarm = claim(sub, task) ?: return@filter true
|
||||||
|
alarms += alarm
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (alarms.isEmpty()) return Result(emptyList(), residue)
|
||||||
|
val rest = ICalSerializer.serializeProperties(component.properties) + ICalSerializer.serializeAll(kept)
|
||||||
|
return Result(alarms, rest.ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun claim(alarm: ICalComponent, task: Task): Alarm? {
|
||||||
|
val action = alarm.property("ACTION")?.value?.trim()?.uppercase()
|
||||||
|
if (action != "DISPLAY" && action != "AUDIO") return null
|
||||||
|
if (alarm.property("X-APPLE-DEFAULT-ALARM")?.value?.trim().equals("TRUE", ignoreCase = true)) return null
|
||||||
|
if (listOf("X-APPLE-PROXIMITY", "PROXIMITY", "REPEAT", "DURATION", "RELATED-TO").any { alarm.property(it) != null }) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (alarm.components.isNotEmpty()) return null
|
||||||
|
val trigger = alarm.properties("TRIGGER").singleOrNull() ?: return null
|
||||||
|
val value = trigger.value.trim()
|
||||||
|
if (value.startsWith("19760401T005545Z")) return null
|
||||||
|
val absolute = trigger.param("VALUE").equals("DATE-TIME", ignoreCase = true) ||
|
||||||
|
(!value.startsWith("P", ignoreCase = true) && !value.startsWith("-") && !value.startsWith("+"))
|
||||||
|
val (minutes, reference) = (if (absolute) absolute(trigger, task) else relative(trigger, task)) ?: return null
|
||||||
|
val raw = ICalSerializer.serialize(alarm)
|
||||||
|
if (raw.toByteArray(Charsets.UTF_8).size > MAX_RAW_BYTES) return null
|
||||||
|
val uid = (alarm.property("X-WR-ALARMUID") ?: alarm.property("UID"))?.value?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
return Alarm(minutes, reference, uid, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relative(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
|
||||||
|
val seconds = ICalValues.parseDuration(trigger.value) ?: return null
|
||||||
|
val reference = when {
|
||||||
|
trigger.param("RELATED").equals("END", ignoreCase = true) ->
|
||||||
|
if (task.due != null) AlarmReference.DUE else return null
|
||||||
|
task.dtstart != null && task.dtstart != task.due -> AlarmReference.START
|
||||||
|
task.due != null -> AlarmReference.DUE
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val before = -seconds
|
||||||
|
val minutes = if (task.isAllDay) days(before) else if (before % 60 != 0L) return null else before / 60
|
||||||
|
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to reference }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun absolute(trigger: ICalProperty, task: Task): Pair<Int, AlarmReference>? {
|
||||||
|
if (task.recurring) return null
|
||||||
|
val due = task.due ?: return null
|
||||||
|
val at = (ICalValues.parseTime(trigger) as? ICalValues.TimeValue.Timed)
|
||||||
|
?.takeIf { it.tzid == null }?.instant ?: return null
|
||||||
|
val before = (due - at).inWholeSeconds
|
||||||
|
val minutes = if (task.isAllDay) days(before) else Math.floorDiv(before, 60L)
|
||||||
|
return minutes.takeIf { it in 0..Int.MAX_VALUE }?.let { it.toInt() to AlarmReference.DUE }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun days(seconds: Long): Long = (seconds / 86_400.0).roundToLong() * DAY_MINUTES
|
||||||
|
|
||||||
|
private fun parse(text: String?): ICalComponent? {
|
||||||
|
if (text.isNullOrEmpty()) return null
|
||||||
|
return runCatching { ICalParser.parse("BEGIN:VTODO\r\n$text\r\nEND:VTODO\r\n") }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import androidx.room.PrimaryKey
|
|||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
|
import java.util.UUID
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +26,8 @@ data class AccountEntity(
|
|||||||
@ColumnInfo(name = "username") val username: String? = null,
|
@ColumnInfo(name = "username") val username: String? = null,
|
||||||
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
|
@ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null,
|
||||||
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
|
@ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null,
|
||||||
|
/** A name the user gave the account (#105); [displayName] stays the stable key. */
|
||||||
|
@ColumnInfo(name = "label") val label: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,12 +202,31 @@ data class TaskEntity(
|
|||||||
* verbatim on write so a round-trip cannot silently lose a field.
|
* verbatim on write so a round-trip cannot silently lose a field.
|
||||||
*/
|
*/
|
||||||
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
|
@ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null,
|
||||||
|
|
||||||
|
// reminders
|
||||||
|
/**
|
||||||
|
* Another client removed this task's last alarm, so it has none — not "use
|
||||||
|
* the default". A task without reminder rows normally takes the list's or
|
||||||
|
* the app's default, which is right for one that never had any (a client
|
||||||
|
* that does not write alarms), and wrong for one whose alarms were turned
|
||||||
|
* off elsewhere: it would keep ringing here. Set by a download that takes
|
||||||
|
* the last row away; cleared as soon as the task has a reminder again.
|
||||||
|
*/
|
||||||
|
@ColumnInfo(name = "reminders_off", defaultValue = "0") val remindersOff: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
|
/** What [TaskAlarmEntity.minutesBefore] counts back from. */
|
||||||
enum class AlarmReference { DUE, START }
|
enum class AlarmReference { DUE, START }
|
||||||
|
|
||||||
/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */
|
/**
|
||||||
|
* A reminder lead on a task. Positive [minutesBefore] is *before* [reference].
|
||||||
|
*
|
||||||
|
* Synced as a `VALARM` (see `AlarmCodec`). [uid] is the alarm's identity on the
|
||||||
|
* wire — `X-WR-ALARMUID` / RFC 9074 `UID` — minted when the row is created, so
|
||||||
|
* every upload of the same alarm names it the same way. [raw] is the `VALARM`
|
||||||
|
* this row was read from, re-emitted verbatim while the row still means what
|
||||||
|
* it did, so another client's `ACKNOWLEDGED`, `X-*` and trigger form survive.
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "task_alarms",
|
tableName = "task_alarms",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
@@ -224,4 +246,6 @@ data class TaskAlarmEntity(
|
|||||||
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
|
@ColumnInfo(name = "minutes_before") val minutesBefore: Int,
|
||||||
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
|
@ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE,
|
||||||
@ColumnInfo(name = "message") val message: String? = null,
|
@ColumnInfo(name = "message") val message: String? = null,
|
||||||
|
@ColumnInfo(name = "uid", defaultValue = "") val uid: String = UUID.randomUUID().toString(),
|
||||||
|
@ColumnInfo(name = "raw") val raw: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.room.Embedded
|
|||||||
data class TaskListRow(
|
data class TaskListRow(
|
||||||
@Embedded val list: TaskListEntity,
|
@Embedded val list: TaskListEntity,
|
||||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||||
|
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,5 +24,6 @@ data class TaskRow(
|
|||||||
@ColumnInfo(name = "list_name") val listName: String,
|
@ColumnInfo(name = "list_name") val listName: String,
|
||||||
@ColumnInfo(name = "list_color") val listColor: Int,
|
@ColumnInfo(name = "list_color") val listColor: Int,
|
||||||
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
@ColumnInfo(name = "account_display_name") val accountDisplayName: String?,
|
||||||
|
@ColumnInfo(name = "account_label") val accountLabel: String? = null,
|
||||||
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
|
@ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ object RoomTaskMapper {
|
|||||||
color = row.list.color,
|
color = row.list.color,
|
||||||
// TaskList.accountName is non-null and the lists screen groups by it, so a
|
// 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.
|
// list with no account still has to report something to group under.
|
||||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||||
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||||
isSynced = row.list.isSynced,
|
isSynced = row.list.isSynced,
|
||||||
isVisible = row.list.isVisible,
|
isVisible = row.list.isVisible,
|
||||||
@@ -61,7 +61,7 @@ object RoomTaskMapper {
|
|||||||
listColor = row.listColor,
|
listColor = row.listColor,
|
||||||
taskColor = row.task.color,
|
taskColor = row.task.color,
|
||||||
listName = row.listName,
|
listName = row.listName,
|
||||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME,
|
||||||
parentId = row.task.parentId,
|
parentId = row.task.parentId,
|
||||||
isRecurring = row.task.isRecurring,
|
isRecurring = row.task.isRecurring,
|
||||||
occurrenceStart = occurrenceStart,
|
occurrenceStart = occurrenceStart,
|
||||||
|
|||||||
+187
-79
@@ -2,8 +2,8 @@ package de.jeanlucmakiola.agendula.data.tasks.room
|
|||||||
|
|
||||||
import androidx.room.InvalidationTracker
|
import androidx.room.InvalidationTracker
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.editable
|
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
@@ -31,7 +31,54 @@ private val SERIES_LINES = listOf("EXDATE", "RDATE", "RRULE", "RECURRENCE-ID", "
|
|||||||
/** Enough to count every occurrence before a split point without bounding a real series. */
|
/** Enough to count every occurrence before a split point without bounding a real series. */
|
||||||
internal const val SPLIT_COUNT_CEILING = 100_000
|
internal const val SPLIT_COUNT_CEILING = 100_000
|
||||||
|
|
||||||
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
/** The window every read of the store expands a series over. */
|
||||||
|
internal fun expansionWindow(now: Instant) = ExpansionWindow(
|
||||||
|
from = now - WINDOW_BACK,
|
||||||
|
until = now + WINDOW_FORWARD,
|
||||||
|
pivot = now,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
internal 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One occurrence of a series: its anchor, the override replacing it if any, and its resolved times. */
|
||||||
|
internal class SeriesOccurrence(
|
||||||
|
val anchor: Instant,
|
||||||
|
val override: TaskEntity?,
|
||||||
|
val start: Instant?,
|
||||||
|
val due: Instant?,
|
||||||
|
val distance: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every occurrence of [master] inside the expansion window, with any
|
||||||
|
* `RECURRENCE-ID` override substituted for the occurrence it replaces, or
|
||||||
|
* `null` when [master] does not recur.
|
||||||
|
*/
|
||||||
|
internal fun seriesOccurrences(master: TaskEntity, overrides: List<TaskEntity>, now: Instant): List<SeriesOccurrence>? {
|
||||||
|
val spec = master.recurrenceSpec() ?: return null
|
||||||
|
val anchors = RecurrenceExpander.expand(spec, expansionWindow(now))
|
||||||
|
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
||||||
|
val byAnchor = overrides.associateBy { it.recurrenceId }
|
||||||
|
return anchors.mapIndexed { index, anchor ->
|
||||||
|
val override = byAnchor[anchor]
|
||||||
|
val (start, due) = if (override != null) override.dtstart to override.due else occurrenceTimes(master, anchor)
|
||||||
|
SeriesOccurrence(anchor, override, start, due, distances.getOrNull(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||||
|
|
||||||
|
/** Distinct alarm texts whose `ACKNOWLEDGED` is remembered between scheduler passes. */
|
||||||
|
private const val ACK_CACHE_SIZE = 512
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [TasksDataSource] over Agendula's own Room store.
|
* [TasksDataSource] over Agendula's own Room store.
|
||||||
@@ -101,19 +148,32 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One transaction, so a failure part-way leaves the subtree whole. */
|
||||||
|
override fun deleteWithSubtasks(taskId: Long) = tx { super.deleteWithSubtasks(taskId) }
|
||||||
|
|
||||||
|
override fun subtaskIds(parentTaskId: Long): List<Long> =
|
||||||
|
tasks.subtasks(parentTaskId).map { it.task.id }.filter { it != parentTaskId }
|
||||||
|
|
||||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||||
|
|
||||||
override fun reminders(): Map<Long, List<TaskReminder>> =
|
override fun reminders(): Map<Long, List<TaskReminder>> {
|
||||||
alarms.all().groupBy({ it.taskId }) {
|
val stored = alarms.all().groupBy({ it.taskId }) { it.asReminder().copy(acknowledgedAt = acknowledged(it)) }
|
||||||
TaskReminder(
|
// Turned off elsewhere: present and empty, so nothing falls back to a default.
|
||||||
minutesBefore = it.minutesBefore,
|
return stored + alarms.remindersOff().filterNot(stored::containsKey).associateWith { emptyList() }
|
||||||
fromStart = it.reference == AlarmReference.START,
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun alarms(): Map<Long, TaskReminder> =
|
/** `raw` → its `ACKNOWLEDGED`, so a scheduler pass does not re-parse every alarm. */
|
||||||
reminders().mapNotNull { (id, list) -> list.editable()?.let { id to it } }.toMap()
|
private val acknowledgements = object : LinkedHashMap<String, Instant?>(64, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Instant?>) = size > ACK_CACHE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acknowledged(row: TaskAlarmEntity): Instant? {
|
||||||
|
val raw = row.raw ?: return null
|
||||||
|
return synchronized(acknowledgements) {
|
||||||
|
if (raw in acknowledgements) acknowledgements[raw] else row.acknowledgedAt().also { acknowledgements[raw] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every occurrence of [row] inside the expansion window, with any
|
* Every occurrence of [row] inside the expansion window, with any
|
||||||
@@ -123,70 +183,69 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
|
* [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> {
|
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
|
||||||
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
|
val occurrences = seriesOccurrences(row.task, overrides, now) ?: return listOf(RoomTaskMapper.task(row))
|
||||||
val window = ExpansionWindow(
|
return occurrences.map { occurrence ->
|
||||||
from = now - WINDOW_BACK,
|
val override = occurrence.override
|
||||||
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) {
|
if (override != null) {
|
||||||
RoomTaskMapper.task(
|
RoomTaskMapper.task(
|
||||||
row = row.copy(task = override),
|
row = row.copy(task = override),
|
||||||
occurrenceStart = anchor,
|
occurrenceStart = occurrence.anchor,
|
||||||
start = override.dtstart,
|
start = occurrence.start,
|
||||||
due = override.due,
|
due = occurrence.due,
|
||||||
distanceFromCurrent = distances[index],
|
distanceFromCurrent = occurrence.distance,
|
||||||
seriesId = row.task.id,
|
seriesId = row.task.id,
|
||||||
recurrenceRule = row.task.rrule,
|
recurrenceRule = row.task.rrule,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val (start, due) = occurrenceTimes(row.task, anchor)
|
|
||||||
RoomTaskMapper.task(
|
RoomTaskMapper.task(
|
||||||
row = row,
|
row = row,
|
||||||
occurrenceStart = anchor,
|
occurrenceStart = occurrence.anchor,
|
||||||
start = start,
|
start = occurrence.start,
|
||||||
due = due,
|
due = occurrence.due,
|
||||||
distanceFromCurrent = distances[index],
|
distanceFromCurrent = occurrence.distance,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 ---------------------------------------------------------------
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
override fun insertTask(form: TaskForm): Long {
|
override fun insertTask(form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||||
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
|
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())
|
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)) }
|
var id = 0L
|
||||||
|
tx {
|
||||||
|
id = tasks.insert(TaskFormWriter.withRule(entity, form.rrule))
|
||||||
|
reminders?.let { alarms.syncForTask(id, it) }
|
||||||
|
writes.onWritten(setOf(form.listId))
|
||||||
|
}
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
/**
|
||||||
|
* [reminders] as [taskId]'s own, inside the caller's transaction.
|
||||||
|
*
|
||||||
|
* ⚠️ Part of the task write rather than a second call after it. Once
|
||||||
|
* reminders sync, a sync landing between the two would upload the task
|
||||||
|
* without them, or with the ones it is about to lose.
|
||||||
|
*/
|
||||||
|
private fun writeReminders(taskId: Long, reminders: List<TaskReminder>?) {
|
||||||
|
if (reminders == null) return
|
||||||
|
alarms.syncForTask(taskId, reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedReminders(taskId: Long): List<TaskReminder> =
|
||||||
|
alarms.forTask(taskId).map { it.asReminder() }.sortedWith(TaskReminderOrder)
|
||||||
|
|
||||||
|
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||||
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
val edited = TaskFormWriter.apply(current, form, clock.now(), zone())
|
||||||
|
writeReminders(taskId, reminders)
|
||||||
if (current.masterId == null && edited.listId != current.listId) {
|
if (current.masterId == null && edited.listId != current.listId) {
|
||||||
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||||
database.runInTransaction { move(current, TaskFormWriter.withRule(edited, form.rrule)) }
|
move(current, TaskFormWriter.withRule(edited, form.rrule))
|
||||||
writes.onWritten(setOf(current.listId, edited.listId))
|
writes.onWritten(setOf(current.listId, edited.listId))
|
||||||
return
|
return@tx
|
||||||
}
|
}
|
||||||
// ⚠️ An override's list and parent are the master's, the rule
|
// ⚠️ An override's list and parent are the master's, the rule
|
||||||
// [updateInstance] states and this path has to keep. An overridden
|
// [updateInstance] states and this path has to keep. An overridden
|
||||||
@@ -220,8 +279,23 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
plan.tombstone?.let { tasks.insert(it) }
|
plan.tombstone?.let { tasks.insert(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = tx {
|
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||||
val master = seriesMaster(seriesId)
|
val master = seriesMaster(seriesId)
|
||||||
|
// ⚠️ The overrides too, where they still carry the series' reminders.
|
||||||
|
// A fork copies them as a snapshot — completing one occurrence is
|
||||||
|
// enough — and each override uploads its own VALARMs, so leaving them
|
||||||
|
// would keep ringing those occurrences at the old lead everywhere. One
|
||||||
|
// whose reminders were set for that occurrence alone keeps them.
|
||||||
|
if (reminders != null) {
|
||||||
|
val before = storedReminders(master.id)
|
||||||
|
tasks.overrides(master.id)
|
||||||
|
.filter { storedReminders(it.id) == before }
|
||||||
|
.forEach {
|
||||||
|
writeReminders(it.id, reminders)
|
||||||
|
if (form.remindersChosen && reminders.isEmpty()) alarms.setRemindersOff(it.id, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeReminders(master.id, reminders)
|
||||||
val (start, due) = resolvedTimes(master, occurrenceStart)
|
val (start, due) = resolvedTimes(master, occurrenceStart)
|
||||||
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
|
val edited = TaskFormWriter.seriesEdit(master, start, due, form, clock.now(), zone())
|
||||||
// A moved or re-ruled series no longer generates the anchors its
|
// A moved or re-ruled series no longer generates the anchors its
|
||||||
@@ -237,14 +311,14 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
writes.onWritten(setOf(master.listId, edited.listId))
|
writes.onWritten(setOf(master.listId, edited.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long {
|
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long {
|
||||||
var created = seriesId
|
var created = seriesId
|
||||||
tx {
|
tx {
|
||||||
val master = seriesMaster(seriesId)
|
val master = seriesMaster(seriesId)
|
||||||
val spec = master.recurrenceSpec()
|
val spec = master.recurrenceSpec()
|
||||||
val anchor = master.recurrenceAnchor
|
val anchor = master.recurrenceAnchor
|
||||||
if (spec == null || anchor == null || occurrenceStart <= anchor) {
|
if (spec == null || anchor == null || occurrenceStart <= anchor) {
|
||||||
updateSeries(seriesId, occurrenceStart, form)
|
updateSeries(seriesId, occurrenceStart, form, reminders)
|
||||||
return@tx
|
return@tx
|
||||||
}
|
}
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
@@ -287,7 +361,9 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
else TaskFormWriter.shiftValues(laterExceptions, delta, master.timezone ?: zone())
|
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 fresh = TaskFormWriter.withRule(applied, rule).let { if (it.rrule != null) it.copy(exdate = exceptions) else it }
|
||||||
val newId = tasks.insert(fresh)
|
val newId = tasks.insert(fresh)
|
||||||
alarms.forTask(master.id).forEach { alarms.insert(it.copy(id = 0, taskId = newId)) }
|
// A new UID, so a new resource: the alarms are copies, not the same ones.
|
||||||
|
alarms.copyToTask(master.id, newId, sameResource = false)
|
||||||
|
writeReminders(newId, reminders)
|
||||||
|
|
||||||
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
|
val unmoved = fresh.dtstart == start && fresh.due == due && sameRule
|
||||||
tasks.overrides(master.id)
|
tasks.overrides(master.id)
|
||||||
@@ -322,8 +398,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
val master = tasks.entity(seriesId) ?: return@tx
|
val master = tasks.entity(seriesId) ?: return@tx
|
||||||
val anchor = master.recurrenceAnchor
|
val anchor = master.recurrenceAnchor
|
||||||
if (anchor == null || occurrenceStart <= anchor) {
|
if (anchor == null || occurrenceStart <= anchor) {
|
||||||
tasks.subtasks(seriesId).forEach { deleteTask(it.task.id) }
|
deleteWithSubtasks(seriesId)
|
||||||
deleteTask(seriesId)
|
|
||||||
return@tx
|
return@tx
|
||||||
}
|
}
|
||||||
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
|
tasks.update(TaskFormWriter.endedBefore(master, occurrenceStart, clock.now(), zone()))
|
||||||
@@ -352,7 +427,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
* which is the model least compatible with sync; the override shares its
|
* which is the model least compatible with sync; the override shares its
|
||||||
* master's UID, which is exactly what makes it an override.
|
* master's UID, which is exactly what makes it an override.
|
||||||
*/
|
*/
|
||||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) = tx {
|
||||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
||||||
val now = clock.now()
|
val now = clock.now()
|
||||||
val existing = tasks.override(taskId, occurrenceStart)
|
val existing = tasks.override(taskId, occurrenceStart)
|
||||||
@@ -361,8 +436,9 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
// master's, whatever the form was carrying.
|
// master's, whatever the form was carrying.
|
||||||
val edited = TaskFormWriter.apply(existing, form, now, zone())
|
val edited = TaskFormWriter.apply(existing, form, now, zone())
|
||||||
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
|
tasks.update(edited.copy(listId = master.listId, parentId = master.parentId))
|
||||||
|
writeReminders(existing.id, reminders)
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
return
|
return@tx
|
||||||
}
|
}
|
||||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
val fork = TaskFormWriter.apply(
|
val fork = TaskFormWriter.apply(
|
||||||
@@ -374,28 +450,19 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
// The list and parent come from the master: moving one occurrence between
|
// The list and parent come from the master: moving one occurrence between
|
||||||
// lists or parents is not something the override model expresses.
|
// lists or parents is not something the override model expresses.
|
||||||
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
||||||
alarms.replaceAllForTask(id, alarms.forTask(taskId))
|
// The occurrence starts out with the series' reminders and then takes
|
||||||
|
// the form's — the master's own are never touched.
|
||||||
|
alarms.copyToTask(taskId, id, sameResource = true)
|
||||||
|
writeReminders(id, reminders)
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = tx {
|
||||||
alarms.replaceEditable(
|
if (!alarms.syncForTask(taskId, reminders)) return@tx
|
||||||
taskId,
|
// The reminders are part of the VTODO now, so changing them is an edit.
|
||||||
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
|
val current = tasks.entity(taskId) ?: return@tx
|
||||||
)
|
tasks.update(current.copy(isDirty = true, lastModified = clock.now()))
|
||||||
}
|
writes.onWritten(setOf(current.listId))
|
||||||
|
|
||||||
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) {
|
override fun setCancelled(taskId: Long, cancelled: Boolean) {
|
||||||
@@ -447,7 +514,7 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
val (start, due) = occurrenceTimes(master, occurrenceStart)
|
||||||
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
val id = tasks.insert(write(newOverride(master, taskId, occurrenceStart, start, due)))
|
||||||
alarms.replaceAllForTask(id, alarms.forTask(taskId))
|
alarms.copyToTask(taskId, id, sameResource = true)
|
||||||
writes.onWritten(setOf(master.listId))
|
writes.onWritten(setOf(master.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +582,47 @@ class RoomTasksDataSource @Inject constructor(
|
|||||||
writes.onWritten(setOf(current.listId))
|
writes.onWritten(setOf(current.listId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- whole-row writes, for the published provider --------------------------
|
||||||
|
|
||||||
|
/** Inserts [entity] as it stands, for a client that wrote the row itself. */
|
||||||
|
internal fun insertEntity(entity: TaskEntity): Long {
|
||||||
|
if (lists.exists(entity.listId) == 0) throw TaskWriteFailedException("insert task: no list ${entity.listId}")
|
||||||
|
return tasks.insert(entity).also { writes.onWritten(setOf(entity.listId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes [edited] over [current] under the rules [updateTask] and
|
||||||
|
* [updateSeries] keep: a task moves lists through [TaskMove], an override
|
||||||
|
* keeps its master's list and parent, and a series whose anchor or rule
|
||||||
|
* changed drops the overrides it no longer generates.
|
||||||
|
*/
|
||||||
|
internal fun replaceEntity(current: TaskEntity, edited: TaskEntity) {
|
||||||
|
tx {
|
||||||
|
val reshaped = current.masterId == null && current.recurrenceSpec() != null &&
|
||||||
|
(edited.dtstart != current.dtstart || edited.due != current.due || edited.rrule != current.rrule)
|
||||||
|
if (reshaped) tasks.overrides(current.id).forEach { tasks.delete(it.id) }
|
||||||
|
when {
|
||||||
|
current.masterId != null -> tasks.update(edited.copy(listId = current.listId, parentId = current.parentId))
|
||||||
|
edited.listId != current.listId -> {
|
||||||
|
if (lists.exists(edited.listId) == 0) throw TaskWriteFailedException("move task: no list ${edited.listId}")
|
||||||
|
move(current, edited)
|
||||||
|
}
|
||||||
|
else -> tasks.update(edited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writes.onWritten(setOf(current.listId, edited.listId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds, changes or removes one reminder, and marks its task changed so the reminder syncs. */
|
||||||
|
internal fun writeAlarm(taskId: Long, write: () -> Unit) {
|
||||||
|
val task = tasks.entity(taskId) ?: throw TaskWriteFailedException("alarm on task $taskId")
|
||||||
|
tx {
|
||||||
|
write()
|
||||||
|
tasks.update(task.copy(lastModified = clock.now(), isDirty = true))
|
||||||
|
}
|
||||||
|
writes.onWritten(setOf(task.listId))
|
||||||
|
}
|
||||||
|
|
||||||
override fun createLocalList(name: String, color: Int): Long =
|
override fun createLocalList(name: String, color: Int): Long =
|
||||||
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
|
import androidx.room.Update
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
|
||||||
|
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Reads and writes over `task_alarms`. */
|
/** Reads and writes over `task_alarms`. */
|
||||||
@Dao
|
@Dao
|
||||||
@@ -16,37 +21,137 @@ interface TaskAlarmDao {
|
|||||||
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
|
@Query("SELECT * FROM task_alarms WHERE task_id = :taskId ORDER BY id")
|
||||||
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
fun forTask(taskId: Long): List<TaskAlarmEntity>
|
||||||
|
|
||||||
|
/** The reminders of several tasks in one query — one resource's rows, for an upload. */
|
||||||
|
@Query("SELECT * FROM task_alarms WHERE task_id IN (:taskIds) ORDER BY id")
|
||||||
|
fun forTasks(taskIds: List<Long>): List<TaskAlarmEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM task_alarms WHERE id = :id")
|
||||||
|
fun entity(id: Long): TaskAlarmEntity?
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
fun insert(alarm: TaskAlarmEntity): Long
|
fun insert(alarm: TaskAlarmEntity): Long
|
||||||
|
|
||||||
|
@Update
|
||||||
|
fun update(alarm: TaskAlarmEntity): Int
|
||||||
|
|
||||||
@Query("DELETE FROM task_alarms WHERE id = :id")
|
@Query("DELETE FROM task_alarms WHERE id = :id")
|
||||||
fun delete(id: Long): Int
|
fun delete(id: Long): Int
|
||||||
|
|
||||||
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
@Query("DELETE FROM task_alarms WHERE task_id = :taskId")
|
||||||
fun deleteForTask(taskId: Long): Int
|
fun deleteForTask(taskId: Long): Int
|
||||||
|
|
||||||
|
/** See [TaskEntity.remindersOff]. */
|
||||||
|
@Query("UPDATE tasks SET reminders_off = :off WHERE id = :taskId")
|
||||||
|
fun setRemindersOff(taskId: Long, off: Boolean): Int
|
||||||
|
|
||||||
|
/** The tasks whose alarms were turned off elsewhere — "none", not "the default". */
|
||||||
|
@Query("SELECT id FROM tasks WHERE reminders_off = 1")
|
||||||
|
fun remindersOff(): List<Long>
|
||||||
|
|
||||||
/** Set the task's only reminder, or clear every one with `null`. */
|
/** Set the task's only reminder, or clear every one with `null`. */
|
||||||
@Transaction
|
@Transaction
|
||||||
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) = replaceAllForTask(taskId, listOfNotNull(alarm))
|
fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) {
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace every reminder on the task. The rows that land are always new ones —
|
|
||||||
* ids are cleared so alarms lifted off another task (forking an occurrence
|
|
||||||
* copies the master's) insert instead of colliding.
|
|
||||||
*/
|
|
||||||
@Transaction
|
|
||||||
fun replaceAllForTask(taskId: Long, alarms: List<TaskAlarmEntity>) {
|
|
||||||
deleteForTask(taskId)
|
deleteForTask(taskId)
|
||||||
alarms.forEach { insert(it.copy(id = 0, taskId = taskId)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the task's editable reminder — its last before-due one — or clear it
|
|
||||||
* with `null`. Start-relative and additional reminders stay as they are.
|
|
||||||
*/
|
|
||||||
@Transaction
|
|
||||||
fun replaceEditable(taskId: Long, alarm: TaskAlarmEntity?) {
|
|
||||||
forTask(taskId).lastOrNull { it.reference == AlarmReference.DUE }?.let { delete(it.id) }
|
|
||||||
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
|
alarm?.let { insert(it.copy(id = 0, taskId = taskId)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the task's reminders [wanted], touching only what differs.
|
||||||
|
*
|
||||||
|
* ⚠️ A diff, not a delete-and-reinsert. A row that is still wanted keeps its
|
||||||
|
* [TaskAlarmEntity.uid] and its [TaskAlarmEntity.raw] — which [TaskReminder]
|
||||||
|
* carries neither of, so a rewrite from it would give every save a new
|
||||||
|
* alarm identity on the server and throw away another client's `VALARM` the
|
||||||
|
* user never touched.
|
||||||
|
*
|
||||||
|
* Duplicates count: two identical leads are two rows.
|
||||||
|
*
|
||||||
|
* @return whether anything changed.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
fun syncForTask(taskId: Long, wanted: List<TaskReminder>): Boolean {
|
||||||
|
val unmatched = wanted.map { it.lead }.toMutableList()
|
||||||
|
var changed = false
|
||||||
|
forTask(taskId).forEach { row ->
|
||||||
|
if (!unmatched.remove(row.asReminder())) {
|
||||||
|
delete(row.id)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unmatched.forEach {
|
||||||
|
insert(it.toAlarmEntity(taskId))
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
// A reminder of its own again, so the "turned off elsewhere" no longer holds.
|
||||||
|
if (wanted.isNotEmpty()) setRemindersOff(taskId, false)
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the task's reminders with what a download carried.
|
||||||
|
*
|
||||||
|
* Incoming alarms are matched to the rows already here by uid first, then by
|
||||||
|
* lead, so a reminder the server merely re-described keeps its row rather
|
||||||
|
* than being churned into a new one.
|
||||||
|
*
|
||||||
|
* A download that takes away every alarm the task had marks it
|
||||||
|
* [TaskEntity.remindersOff]; one that carries alarms clears that.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
fun replaceFromServer(taskId: Long, incoming: List<TaskAlarmEntity>) {
|
||||||
|
val existing = forTask(taskId).toMutableList()
|
||||||
|
val hadAny = existing.isNotEmpty()
|
||||||
|
val placed = incoming.map { alarm ->
|
||||||
|
val match = existing.firstOrNull { it.uid == alarm.uid }
|
||||||
|
?: existing.firstOrNull { it.asReminder() == alarm.asReminder() }
|
||||||
|
match?.let(existing::remove)
|
||||||
|
alarm.copy(id = match?.id ?: 0, taskId = taskId)
|
||||||
|
}
|
||||||
|
existing.forEach { delete(it.id) }
|
||||||
|
placed.forEach { if (it.id == 0L) insert(it) else update(it) }
|
||||||
|
when {
|
||||||
|
incoming.isNotEmpty() -> setRemindersOff(taskId, false)
|
||||||
|
hadAny -> setRemindersOff(taskId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy [fromTaskId]'s reminders onto [toTaskId].
|
||||||
|
*
|
||||||
|
* [sameResource] is for an override forked inside the same series — the
|
||||||
|
* same `VTODO` UID, so the alarms keep their identity. Anything else is a
|
||||||
|
* different task on the server and gets fresh uids: two tasks naming one
|
||||||
|
* alarm is an identity no other client expects.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
fun copyToTask(fromTaskId: Long, toTaskId: Long, sameResource: Boolean) {
|
||||||
|
forTask(fromTaskId).forEach { insert(if (sameResource) it.copy(id = 0, taskId = toTaskId) else it.freshCopy(toTaskId)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the row means to the scheduler and the editor, without its identity. */
|
||||||
|
fun TaskAlarmEntity.asReminder() = TaskReminder(minutesBefore, reference == AlarmReference.START)
|
||||||
|
|
||||||
|
/** The inverse of [asReminder]: a new row, with an identity of its own. */
|
||||||
|
fun TaskReminder.toAlarmEntity(taskId: Long) = TaskAlarmEntity(
|
||||||
|
taskId = taskId,
|
||||||
|
minutesBefore = minutesBefore,
|
||||||
|
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The same reminder on another task: what it means, never who it is on the server. */
|
||||||
|
fun TaskAlarmEntity.freshCopy(taskId: Long) =
|
||||||
|
TaskAlarmEntity(taskId = taskId, minutesBefore = minutesBefore, reference = reference, message = message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `ACKNOWLEDGED` stamp of the `VALARM` the row was read from, if any.
|
||||||
|
*
|
||||||
|
* Read off [TaskAlarmEntity.raw] rather than kept in a column: it is the other
|
||||||
|
* client's bookkeeping, and it changes only when a download replaces the row.
|
||||||
|
*/
|
||||||
|
fun TaskAlarmEntity.acknowledgedAt(): Instant? {
|
||||||
|
val text = raw ?: return null
|
||||||
|
if (!text.contains("ACKNOWLEDGED", ignoreCase = true)) return null
|
||||||
|
val property = runCatching { ICalParser.parse(text) }.getOrNull()?.property("ACKNOWLEDGED") ?: return null
|
||||||
|
return (ICalValues.parseTime(property) as? ICalValues.TimeValue.Timed)?.instant
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface TaskDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
||||||
a.display_name AS account_display_name
|
a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN task_lists l ON l.id = t.list_id
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
@@ -44,7 +44,7 @@ interface TaskDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
||||||
a.display_name AS account_display_name
|
a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN task_lists l ON l.id = t.list_id
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
@@ -56,7 +56,7 @@ interface TaskDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only,
|
||||||
a.display_name AS account_display_name
|
a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN task_lists l ON l.id = t.list_id
|
JOIN task_lists l ON l.id = t.list_id
|
||||||
LEFT JOIN accounts a ON a.id = l.account_id
|
LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
@@ -105,6 +105,10 @@ interface TaskDao {
|
|||||||
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
|
@Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL")
|
||||||
fun exportTasks(listId: Long): List<TaskEntity>
|
fun exportTasks(listId: Long): List<TaskEntity>
|
||||||
|
|
||||||
|
/** Every row but the tombstones, overrides included — what the published provider lays out. */
|
||||||
|
@Query("SELECT * FROM tasks WHERE is_deleted = 0")
|
||||||
|
fun live(): List<TaskEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
|
@Query("SELECT * FROM tasks WHERE is_dirty = 1")
|
||||||
fun dirty(): List<TaskEntity>
|
fun dirty(): List<TaskEntity>
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ object TaskFormWriter {
|
|||||||
isAllDay = form.isAllDay,
|
isAllDay = form.isAllDay,
|
||||||
timezone = if (timed) tzId else null,
|
timezone = if (timed) tzId else null,
|
||||||
parentId = form.parentId?.takeIf { it > 0 },
|
parentId = form.parentId?.takeIf { it > 0 },
|
||||||
|
remindersOff = if (form.remindersChosen) form.reminders.isEmpty() else current.remindersOff,
|
||||||
lastModified = now,
|
lastModified = now,
|
||||||
isDirty = true,
|
isDirty = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface TaskListDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT l.*, a.display_name AS account_display_name
|
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
ORDER BY a.display_name, l.sort_order, l.name
|
ORDER BY a.display_name, l.sort_order, l.name
|
||||||
"""
|
"""
|
||||||
@@ -25,7 +25,7 @@ interface TaskListDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT l.*, a.display_name AS account_display_name
|
SELECT l.*, a.display_name AS account_display_name, a.label AS account_label
|
||||||
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id
|
||||||
WHERE l.id = :listId
|
WHERE l.id = :listId
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.room.RoomDatabase
|
|||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agendula's own task store. Four tables, designed from
|
* Agendula's own task store. Four tables, designed from
|
||||||
@@ -20,7 +22,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
TaskEntity::class,
|
TaskEntity::class,
|
||||||
TaskAlarmEntity::class,
|
TaskAlarmEntity::class,
|
||||||
],
|
],
|
||||||
version = 2,
|
version = 4,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -63,5 +65,120 @@ abstract class TasksDatabase : RoomDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3 syncs reminders as `VALARM`s, which takes two things the rows did not
|
||||||
|
* have: an identity on the wire (`uid`) and the original text (`raw`).
|
||||||
|
*
|
||||||
|
* Claimable `VALARM`s are lifted out of the residue for every row, synced
|
||||||
|
* or not — an unchanged task is never downloaded again, and an imported
|
||||||
|
* one never was. Not marked dirty: the server already has them. Where a
|
||||||
|
* task held the same lead in both stores (an override forked before v3
|
||||||
|
* copies the master's alarms *and* carries its `VALARM`s), the lifted
|
||||||
|
* one wins: it is the one with an identity.
|
||||||
|
*
|
||||||
|
* Reminders that so far lived only on this device are pushed once, by
|
||||||
|
* marking their tasks dirty — bounded to open tasks in synced lists whose
|
||||||
|
* reminder can still fire, so the upgrade is not a burst of PUTs landing
|
||||||
|
* on other clients as long-missed alarms.
|
||||||
|
*
|
||||||
|
* `tasks.reminders_off` starts at 0 everywhere: until now nothing could
|
||||||
|
* have turned a synced task's alarms off.
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE task_alarms ADD COLUMN uid TEXT NOT NULL DEFAULT ''")
|
||||||
|
db.execSQL("ALTER TABLE task_alarms ADD COLUMN raw TEXT")
|
||||||
|
db.execSQL("ALTER TABLE tasks ADD COLUMN reminders_off INTEGER NOT NULL DEFAULT 0")
|
||||||
|
// A version-4 UUID per row, in one statement: `randomblob` is
|
||||||
|
// evaluated per row, and the upgrade runs on app start.
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
UPDATE task_alarms SET uid =
|
||||||
|
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
||||||
|
substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||||
|
substr('89ab', 1 + (abs(random()) % 4), 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||||
|
lower(hex(randomblob(6)))
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Lifted(val taskId: Long, val result: AlarmLift.Result)
|
||||||
|
val lifted = mutableListOf<Lifted>()
|
||||||
|
db.query(
|
||||||
|
"SELECT id, dtstart, due, is_all_day, rrule, rdate, unknown_properties FROM tasks " +
|
||||||
|
"WHERE unknown_properties LIKE '%VALARM%'",
|
||||||
|
).use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val task = AlarmLift.Task(
|
||||||
|
dtstart = if (c.isNull(1)) null else Instant.fromEpochMilliseconds(c.getLong(1)),
|
||||||
|
due = if (c.isNull(2)) null else Instant.fromEpochMilliseconds(c.getLong(2)),
|
||||||
|
isAllDay = c.getInt(3) != 0,
|
||||||
|
recurring = !c.isNull(4) || !c.isNull(5),
|
||||||
|
)
|
||||||
|
val result = AlarmLift.lift(c.getString(6), task)
|
||||||
|
if (result.alarms.isNotEmpty()) lifted += Lifted(c.getLong(0), result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifted.forEach { (taskId, result) ->
|
||||||
|
val local = mutableListOf<Triple<Long, Int, String>>()
|
||||||
|
db.query(
|
||||||
|
"SELECT id, minutes_before, reference FROM task_alarms WHERE task_id = ?",
|
||||||
|
arrayOf<Any?>(taskId),
|
||||||
|
).use { c -> while (c.moveToNext()) local += Triple(c.getLong(0), c.getInt(1), c.getString(2)) }
|
||||||
|
result.alarms.forEach { alarm ->
|
||||||
|
local.firstOrNull { it.second == alarm.minutesBefore && it.third == alarm.reference.name }
|
||||||
|
?.let { duplicate ->
|
||||||
|
local.remove(duplicate)
|
||||||
|
db.execSQL("DELETE FROM task_alarms WHERE id = ?", arrayOf<Any?>(duplicate.first))
|
||||||
|
}
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO task_alarms (task_id, minutes_before, reference, uid, raw) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
arrayOf<Any?>(
|
||||||
|
taskId,
|
||||||
|
alarm.minutesBefore,
|
||||||
|
alarm.reference.name,
|
||||||
|
alarm.uid ?: UUID.randomUUID().toString(),
|
||||||
|
alarm.raw,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
db.execSQL("UPDATE tasks SET unknown_properties = ? WHERE id = ?", arrayOf<Any?>(result.residue, taskId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ `raw IS NULL` is "never on the server": everything lifted
|
||||||
|
// above carries its original. Status 0/1 are NEEDS-ACTION and
|
||||||
|
// IN-PROCESS (`ICalStatus`).
|
||||||
|
//
|
||||||
|
// ⚠️ Never a list the upload cannot write to. A dirty row is
|
||||||
|
// also one the download will not overwrite, so marking a task in
|
||||||
|
// a read-only share — or a confidential one, which the upload
|
||||||
|
// skips in a shared collection — would freeze it for good: never
|
||||||
|
// sent, and the owner's edits never taken. Classification 2 is
|
||||||
|
// CONFIDENTIAL.
|
||||||
|
//
|
||||||
|
// LAST-MODIFIED moves with it: the body changes, and a client
|
||||||
|
// reconciling by that stamp would otherwise keep its stale copy.
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
UPDATE tasks SET is_dirty = 1, last_modified = ?
|
||||||
|
WHERE is_deleted = 0
|
||||||
|
AND status IN (0, 1)
|
||||||
|
AND (classification IS NULL OR classification != 2)
|
||||||
|
AND list_id IN (SELECT id FROM task_lists WHERE account_id IS NOT NULL AND is_read_only = 0)
|
||||||
|
AND (rrule IS NOT NULL OR rdate IS NOT NULL OR due > ?)
|
||||||
|
AND id IN (SELECT task_id FROM task_alarms WHERE raw IS NULL)
|
||||||
|
""".trimIndent(),
|
||||||
|
arrayOf<Any?>(now, now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v4 lets the user name an account (#105). */
|
||||||
|
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import de.jeanlucmakiola.agendula.data.di.ExternalStore
|
import de.jeanlucmakiola.agendula.data.di.ExternalStore
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
import de.jeanlucmakiola.agendula.data.tasks.room.toAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
@@ -200,7 +199,7 @@ class ExternalImport @Inject constructor(
|
|||||||
var alarmCount = 0
|
var alarmCount = 0
|
||||||
for ((sourceTaskId, reminders) in snapshot.alarms) {
|
for ((sourceTaskId, reminders) in snapshot.alarms) {
|
||||||
val taskId = taskIds[sourceTaskId] ?: continue
|
val taskId = taskIds[sourceTaskId] ?: continue
|
||||||
reminders.forEach { alarmDao.insert(it.toEntity(taskId)) }
|
reminders.forEach { alarmDao.insert(it.toAlarmEntity(taskId)) }
|
||||||
alarmCount += reminders.size
|
alarmCount += reminders.size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +227,6 @@ class ExternalImport @Inject constructor(
|
|||||||
lastModified = lastModified,
|
lastModified = lastModified,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
|
|
||||||
taskId = taskId,
|
|
||||||
minutesBefore = minutesBefore,
|
|
||||||
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun verify(written: TransferCounts, before: TransferCounts) {
|
private fun verify(written: TransferCounts, before: TransferCounts) {
|
||||||
val after = tableCounts()
|
val after = tableCounts()
|
||||||
check(after.lists - before.lists == written.lists) {
|
check(after.lists - before.lists == written.lists) {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
import de.jeanlucmakiola.agendula.data.tasks.ical.CalendarResource
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
import de.jeanlucmakiola.agendula.data.tasks.ical.VTodoMapper
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.room.freshCopy
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -65,7 +67,11 @@ interface IcsImportStore {
|
|||||||
fun <T> transaction(block: () -> T): T
|
fun <T> transaction(block: () -> T): T
|
||||||
fun createList(name: String, color: Int): Long
|
fun createList(name: String, color: Int): Long
|
||||||
fun hasUid(listId: Long, uid: String): Boolean
|
fun hasUid(listId: Long, uid: String): Boolean
|
||||||
|
|
||||||
|
/** Whether any list holds [uid] — the same file imported somewhere else before. */
|
||||||
|
fun hasUidAnywhere(uid: String): Boolean
|
||||||
fun insert(row: TaskEntity): Long
|
fun insert(row: TaskEntity): Long
|
||||||
|
fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>)
|
||||||
fun masterByUid(listId: Long, uid: String): TaskEntity?
|
fun masterByUid(listId: Long, uid: String): TaskEntity?
|
||||||
fun setParent(taskId: Long, parentId: Long?)
|
fun setParent(taskId: Long, parentId: Long?)
|
||||||
}
|
}
|
||||||
@@ -76,9 +82,9 @@ interface IcsImportStore {
|
|||||||
*
|
*
|
||||||
* Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync
|
* Goes through the same [CalendarResource] / [VTodoMapper] path CalDAV sync
|
||||||
* uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every
|
* uses, so recurrence, `RECURRENCE-ID` overrides, `RELATED-TO` parents and every
|
||||||
* property the mapper does not claim (`VALARM`s included) survive as they would
|
* property the mapper does not claim survive as they would on a download. A
|
||||||
* on a download. `VALARM`s stay in the residue: like sync, the import does not
|
* `VALARM` the mapper claims becomes a reminder on the task, exactly as a
|
||||||
* turn them into local reminders.
|
* download does; the rest stay in the residue.
|
||||||
*
|
*
|
||||||
* Tasks whose UID the target list already holds are skipped, so importing the
|
* Tasks whose UID the target list already holds are skipped, so importing the
|
||||||
* same file twice is harmless.
|
* same file twice is harmless.
|
||||||
@@ -200,8 +206,17 @@ class IcsImport @Inject constructor(
|
|||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// ⚠️ The same file in a second list is a second task carrying
|
||||||
|
// the first one's alarm identities. Those are what clients
|
||||||
|
// track a dismissal by, so the copy's alarms are new ones.
|
||||||
|
val copied = store.hasUidAnywhere(task.uid)
|
||||||
|
fun alarmsOf(mapped: VTodoMapper.Mapped) =
|
||||||
|
if (copied) mapped.alarms.map { it.freshCopy(0) } else mapped.alarms
|
||||||
val masterId = store.insert(task.master.entity.forImport(listId, masterId = null))
|
val masterId = store.insert(task.master.entity.forImport(listId, masterId = null))
|
||||||
task.overrides.forEach { store.insert(it.entity.forImport(listId, masterId)) }
|
store.insertAlarms(masterId, alarmsOf(task.master))
|
||||||
|
task.overrides.forEach {
|
||||||
|
store.insertAlarms(store.insert(it.entity.forImport(listId, masterId)), alarmsOf(it))
|
||||||
|
}
|
||||||
task.master.parentUid?.let { parents += masterId to it }
|
task.master.parentUid?.let { parents += masterId to it }
|
||||||
imported++
|
imported++
|
||||||
}
|
}
|
||||||
@@ -270,8 +285,15 @@ class RoomIcsImportStore(private val database: TasksDatabase) : IcsImportStore {
|
|||||||
arrayOf<Any?>(listId, uid),
|
arrayOf<Any?>(listId, uid),
|
||||||
).use { it.moveToFirst() }
|
).use { it.moveToFirst() }
|
||||||
|
|
||||||
|
override fun hasUidAnywhere(uid: String): Boolean =
|
||||||
|
database.query("SELECT 1 FROM tasks WHERE uid = ? LIMIT 1", arrayOf<Any?>(uid)).use { it.moveToFirst() }
|
||||||
|
|
||||||
override fun insert(row: TaskEntity): Long = database.tasks().insert(row)
|
override fun insert(row: TaskEntity): Long = database.tasks().insert(row)
|
||||||
|
|
||||||
|
override fun insertAlarms(taskId: Long, alarms: List<TaskAlarmEntity>) {
|
||||||
|
alarms.forEach { database.alarms().insert(it.copy(id = 0, taskId = taskId)) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid)
|
override fun masterByUid(listId: Long, uid: String): TaskEntity? = database.tasks().byUid(listId, uid)
|
||||||
|
|
||||||
override fun setParent(taskId: Long, parentId: Long?) {
|
override fun setParent(taskId: Long, parentId: Long?) {
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ data class TaskForm(
|
|||||||
val parentId: Long? = null,
|
val parentId: Long? = null,
|
||||||
/** 0..100 progress; `null` leaves it unset (cleared). */
|
/** 0..100 progress; `null` leaves it unset (cleared). */
|
||||||
val percentComplete: Int? = null,
|
val percentComplete: Int? = null,
|
||||||
/** The task's own reminders, in minutes before [due]; empty = the list's or app default applies. */
|
/** The task's own reminders; empty = the list's or app default applies, unless [remindersChosen]. */
|
||||||
val reminders: List<Int> = emptyList(),
|
val reminders: List<TaskReminder> = emptyList(),
|
||||||
|
/** Whether [reminders] are exactly the task's, as the editor showed them: empty is then none. */
|
||||||
|
val remindersChosen: Boolean = false,
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
val url: String? = null,
|
val url: String? = null,
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
@@ -30,14 +32,14 @@ data class TaskForm(
|
|||||||
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
|
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
|
||||||
if (listId <= 0L) add(TaskFormError.NO_LIST)
|
if (listId <= 0L) add(TaskFormError.NO_LIST)
|
||||||
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
|
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
|
||||||
if (reminders.isNotEmpty() && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
|
if (reminders.isNotEmpty() && start == null && due == null) add(TaskFormError.REMINDER_WITHOUT_DATE)
|
||||||
if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE)
|
if (rrule != null && start == null && due == null) add(TaskFormError.RECURRENCE_WITHOUT_DATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isValid: Boolean get() = validate().isEmpty()
|
val isValid: Boolean get() = validate().isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE, RECURRENCE_WITHOUT_DATE }
|
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DATE, RECURRENCE_WITHOUT_DATE }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional edit-form sections that can be shown or tucked behind "More fields"
|
* Optional edit-form sections that can be shown or tucked behind "More fields"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
const val DAY_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The earliest lead an all-day reminder can have: 00:01 on the day. On an
|
||||||
|
* all-day task a lead that is not whole days counts back from the date's
|
||||||
|
* midnight, so `-540` is 09:00 on the day — see [TaskReminder.allDay].
|
||||||
|
*/
|
||||||
|
const val MIN_ALL_DAY_LEAD = -(DAY_MINUTES - 1)
|
||||||
|
|
||||||
|
/** A stored reminder: how long before the start ([fromStart]) or the due date. */
|
||||||
|
data class TaskReminder(
|
||||||
|
val minutesBefore: Int,
|
||||||
|
val fromStart: Boolean = false,
|
||||||
|
/**
|
||||||
|
* When another client last dismissed this alarm (RFC 9074 `ACKNOWLEDGED`):
|
||||||
|
* a trigger at or before it has been dealt with and is not fired here.
|
||||||
|
* Never part of what a reminder *is* — see [lead].
|
||||||
|
*/
|
||||||
|
val acknowledgedAt: Instant? = null,
|
||||||
|
) {
|
||||||
|
/** The reminder without its bookkeeping, for comparing two of them. */
|
||||||
|
val lead: TaskReminder get() = if (acknowledgedAt == null) this else copy(acknowledgedAt = null)
|
||||||
|
|
||||||
|
/** This reminder read as an all-day one. */
|
||||||
|
val allDay: AllDayReminder get() = AllDayReminder.of(minutesBefore)
|
||||||
|
|
||||||
|
/** This reminder's lead rounded to the nearest whole day, as the earlier all-day leads were. */
|
||||||
|
fun inWholeDays(): TaskReminder =
|
||||||
|
copy(minutesBefore = (minutesBefore.coerceAtLeast(0) + DAY_MINUTES / 2) / DAY_MINUTES * DAY_MINUTES)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The all-day reminder [daysBefore] the date at [minuteOfDay], or at the
|
||||||
|
* device's all-day time when that is null. Midnight would read back as
|
||||||
|
* the latter, so it is 00:01 — see [allDayMinuteOfDay].
|
||||||
|
*/
|
||||||
|
fun allDay(daysBefore: Int, minuteOfDay: Int?, fromStart: Boolean = false) =
|
||||||
|
TaskReminder(daysBefore * DAY_MINUTES - (minuteOfDay?.let(::allDayMinuteOfDay) ?: 0), fromStart)
|
||||||
|
|
||||||
|
/** The time an all-day reminder can name: any but midnight, which is a whole-day lead. */
|
||||||
|
fun allDayMinuteOfDay(minuteOfDay: Int): Int = minuteOfDay.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An all-day reminder as the day it rings on and, when it names one, its time. */
|
||||||
|
data class AllDayReminder(val daysBefore: Int, val minuteOfDay: Int?) {
|
||||||
|
companion object {
|
||||||
|
/** [minutesBefore] on an all-day task; anything after the day is clamped to its last minute. */
|
||||||
|
fun of(minutesBefore: Int): AllDayReminder {
|
||||||
|
val minutes = minutesBefore.coerceAtLeast(MIN_ALL_DAY_LEAD)
|
||||||
|
if (minutes % DAY_MINUTES == 0) return AllDayReminder(minutes / DAY_MINUTES, minuteOfDay = null)
|
||||||
|
val days = Math.floorDiv(minutes, DAY_MINUTES) + 1
|
||||||
|
return AllDayReminder(days, minuteOfDay = days * DAY_MINUTES - minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A canonical order, for comparing two sets of reminders. */
|
||||||
|
val TaskReminderOrder: Comparator<TaskReminder> =
|
||||||
|
compareBy<TaskReminder>({ !it.fromStart }, { -it.minutesBefore })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order the editor and the detail screen list reminders in: start's first,
|
||||||
|
* then earliest first. An all-day reminder without a time rings at [allDayMinuteOfDay].
|
||||||
|
*/
|
||||||
|
fun taskReminderDisplayOrder(allDay: Boolean, allDayMinuteOfDay: Int): Comparator<TaskReminder> =
|
||||||
|
compareBy<TaskReminder>({ !it.fromStart }, { reminder ->
|
||||||
|
if (!allDay) {
|
||||||
|
-reminder.minutesBefore
|
||||||
|
} else {
|
||||||
|
val (days, minuteOfDay) = reminder.allDay
|
||||||
|
(minuteOfDay ?: allDayMinuteOfDay) - days * DAY_MINUTES
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A default reminder's lead as a reminder of the task's own. */
|
||||||
|
fun ReminderLead.asTaskReminder(): TaskReminder = TaskReminder(minutes, fromStart)
|
||||||
|
|
||||||
|
/** This reminder's lead, as default reminders are kept. */
|
||||||
|
fun TaskReminder.asReminderLead(): ReminderLead = ReminderLead(minutesBefore, fromStart)
|
||||||
@@ -161,6 +161,57 @@ object ICalValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val DURATION = Regex(
|
||||||
|
"([+-])?P(?:(\\d+)W|(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?)?)",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 5545 §3.3.6 `dur-value` in signed seconds: `-PT15M` → -900, `P1W` →
|
||||||
|
* 604800, `-P1DT2H` → -93600. Null when it does not parse.
|
||||||
|
*
|
||||||
|
* A little more lenient than the grammar — `PT1H5S` skips the minutes the
|
||||||
|
* grammar would require — because a stricter reader would leave another
|
||||||
|
* client's alarm unread over a form nobody misreads.
|
||||||
|
*/
|
||||||
|
fun parseDuration(value: String): Long? {
|
||||||
|
val match = DURATION.matchEntire(value.trim()) ?: return null
|
||||||
|
val (sign, weeks, days, hours, minutes, seconds) = match.destructured
|
||||||
|
// "P" and "PT" alone match the pattern and mean nothing.
|
||||||
|
if (listOf(weeks, days, hours, minutes, seconds).all(String::isEmpty)) return null
|
||||||
|
if (value.trim().endsWith("T", ignoreCase = true)) return null
|
||||||
|
fun part(text: String, scale: Long): Long =
|
||||||
|
if (text.isEmpty()) 0L else Math.multiplyExact(text.toLong(), scale)
|
||||||
|
val total = runCatching {
|
||||||
|
listOf(
|
||||||
|
part(weeks, 7 * 86_400L), part(days, 86_400L),
|
||||||
|
part(hours, 3_600L), part(minutes, 60L), part(seconds, 1L),
|
||||||
|
).reduce(Math::addExact)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
return if (sign == "-") -total else total
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inverse of [parseDuration], in days and time, never weeks: `-PT15M`, `-P1D`, `PT0S`. */
|
||||||
|
fun formatDuration(seconds: Long): String {
|
||||||
|
if (seconds == 0L) return "PT0S"
|
||||||
|
val magnitude = kotlin.math.abs(seconds)
|
||||||
|
val days = magnitude / 86_400
|
||||||
|
val hours = magnitude % 86_400 / 3_600
|
||||||
|
val minutes = magnitude % 3_600 / 60
|
||||||
|
val rest = magnitude % 60
|
||||||
|
return buildString {
|
||||||
|
if (seconds < 0) append('-')
|
||||||
|
append('P')
|
||||||
|
if (days > 0) append(days).append('D')
|
||||||
|
if (hours > 0 || minutes > 0 || rest > 0) {
|
||||||
|
append('T')
|
||||||
|
if (hours > 0) append(hours).append('H')
|
||||||
|
if (minutes > 0) append(minutes).append('M')
|
||||||
|
if (rest > 0) append(rest).append('S')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun java.time.Instant.toKotlin(): Instant =
|
private fun java.time.Instant.toKotlin(): Instant =
|
||||||
Instant.fromEpochMilliseconds(toEpochMilli())
|
Instant.fromEpochMilliseconds(toEpochMilli())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ fun RootScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navRequest: NavRequest? = null,
|
navRequest: NavRequest? = null,
|
||||||
onNavRequestConsumed: () -> Unit = {},
|
onNavRequestConsumed: () -> Unit = {},
|
||||||
|
onReturnHome: () -> Unit = {},
|
||||||
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
permissionViewModel: PermissionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -86,6 +87,7 @@ fun RootScreen(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
navRequest = navRequest,
|
navRequest = navRequest,
|
||||||
onNavRequestConsumed = onNavRequestConsumed,
|
onNavRequestConsumed = onNavRequestConsumed,
|
||||||
|
onReturnHome = onReturnHome,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +103,7 @@ private fun ReadyGate(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navRequest: NavRequest? = null,
|
navRequest: NavRequest? = null,
|
||||||
onNavRequestConsumed: () -> Unit = {},
|
onNavRequestConsumed: () -> Unit = {},
|
||||||
|
onReturnHome: () -> Unit = {},
|
||||||
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
|
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
|
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
|
||||||
@@ -113,6 +116,7 @@ private fun ReadyGate(
|
|||||||
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
|
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
|
||||||
navRequest = navRequest,
|
navRequest = navRequest,
|
||||||
onNavRequestConsumed = onNavRequestConsumed,
|
onNavRequestConsumed = onNavRequestConsumed,
|
||||||
|
onReturnHome = onReturnHome,
|
||||||
)
|
)
|
||||||
false -> ReadableWidth(modifier) {
|
false -> ReadableWidth(modifier) {
|
||||||
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
|
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.systemGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** How far past the system back-gesture zone a horizontal swipe is still ignored. */
|
||||||
|
private val EDGE_MARGIN = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swallows horizontal swipes that start near the screen's left or right edge,
|
||||||
|
* before a swipe box below it sees them (#90). A back gesture started just
|
||||||
|
* inside the system's gesture zone otherwise reaches the app as a row swipe.
|
||||||
|
* Taps and vertical scrolls are left alone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.ignoringEdgeSwipes(): Modifier {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
val windowWidth = LocalWindowInfo.current.containerSize.width
|
||||||
|
val insets = WindowInsets.systemGestures
|
||||||
|
val margin = with(density) { EDGE_MARGIN.roundToPx() }
|
||||||
|
val leftEdge = insets.getLeft(density, layoutDirection) + margin
|
||||||
|
val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
return this
|
||||||
|
.onGloballyPositioned { offsetX = it.positionInWindow().x }
|
||||||
|
.pointerInput(leftEdge, rightEdge) {
|
||||||
|
val slop = viewConfiguration.touchSlop
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
val x = offsetX + down.position.x
|
||||||
|
if (x > leftEdge && x < rightEdge) return@awaitEachGesture
|
||||||
|
var horizontal = false
|
||||||
|
while (true) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!horizontal) {
|
||||||
|
val delta = change.position - down.position
|
||||||
|
when {
|
||||||
|
abs(delta.x) > slop -> horizontal = true
|
||||||
|
abs(delta.y) > slop -> break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (horizontal) change.consume()
|
||||||
|
if (!change.pressed) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||||
@@ -33,9 +35,57 @@ fun reminderLeadTimeLabel(minutes: Int, allDay: Boolean = false): String = when
|
|||||||
minutes <= 0 -> stringResource(R.string.reminder_at_due)
|
minutes <= 0 -> stringResource(R.string.reminder_at_due)
|
||||||
minutes % 10_080 == 0 ->
|
minutes % 10_080 == 0 ->
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
minutes % 1_440 == 0 ->
|
minutes % DAY_MINUTES == 0 ->
|
||||||
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
|
pluralStringResource(R.plurals.reminder_days, minutes / DAY_MINUTES, minutes / DAY_MINUTES)
|
||||||
minutes % 60 == 0 ->
|
minutes % 60 == 0 ->
|
||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [reminderLeadTimeLabel] naming the date the lead counts from, for a task
|
||||||
|
* that has both: "15 minutes before the start", "On the due date".
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun anchoredLeadTimeLabel(minutes: Int, allDay: Boolean, fromStart: Boolean): String {
|
||||||
|
@Composable
|
||||||
|
fun plural(start: Int, due: Int, count: Int) =
|
||||||
|
pluralStringResource(if (fromStart) start else due, count, count)
|
||||||
|
return when {
|
||||||
|
minutes <= 0 && allDay ->
|
||||||
|
stringResource(if (fromStart) R.string.reminder_on_start_date else R.string.reminder_on_due_date)
|
||||||
|
minutes <= 0 -> stringResource(if (fromStart) R.string.reminder_at_start else R.string.reminder_at_due_time)
|
||||||
|
minutes % 10_080 == 0 ->
|
||||||
|
plural(R.plurals.reminder_weeks_before_start, R.plurals.reminder_weeks_before_due, minutes / 10_080)
|
||||||
|
minutes % DAY_MINUTES == 0 ->
|
||||||
|
plural(R.plurals.reminder_days_before_start, R.plurals.reminder_days_before_due, minutes / DAY_MINUTES)
|
||||||
|
minutes % 60 == 0 ->
|
||||||
|
plural(R.plurals.reminder_hours_before_start, R.plurals.reminder_hours_before_due, minutes / 60)
|
||||||
|
else -> plural(R.plurals.reminder_minutes_before_start, R.plurals.reminder_minutes_before_due, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lead's label, naming its date when [anchored]. */
|
||||||
|
@Composable
|
||||||
|
fun reminderLabel(minutes: Int, allDay: Boolean, fromStart: Boolean, anchored: Boolean): String =
|
||||||
|
if (anchored) anchoredLeadTimeLabel(minutes, allDay, fromStart) else reminderLeadTimeLabel(minutes, allDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of a task's own reminders in one line, naming its date when [anchored].
|
||||||
|
* An all-day one names its day and the time it rings at — its own, or
|
||||||
|
* [allDayMinuteOfDay] when it has none.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun taskReminderLabel(reminder: TaskReminder, allDay: Boolean, allDayMinuteOfDay: Int, anchored: Boolean): String {
|
||||||
|
if (!allDay) return reminderLabel(reminder.minutesBefore, false, reminder.fromStart, anchored)
|
||||||
|
val (days, minuteOfDay) = reminder.allDay
|
||||||
|
return stringResource(
|
||||||
|
R.string.reminder_at_time,
|
||||||
|
reminderLabel(days * DAY_MINUTES, true, reminder.fromStart, anchored),
|
||||||
|
formatMinuteOfDay(minuteOfDay ?: allDayMinuteOfDay, LocalUse24HourFormat.current),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun reminderAnchorLabel(fromStart: Boolean): Int =
|
||||||
|
if (fromStart) R.string.reminder_anchor_start else R.string.reminder_anchor_due
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
import de.jeanlucmakiola.floret.reminders.ReminderLead
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForLeads
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -16,27 +17,19 @@ import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -46,8 +39,10 @@ import de.jeanlucmakiola.agendula.R
|
|||||||
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
||||||
* plus a "Custom" row that expands an inline amount field + unit selector.
|
* plus a "Custom" row that expands an inline amount field + unit selector.
|
||||||
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
||||||
* a "None" row; [allDay] switches to day-scale presets and wording. Returns the
|
* a "None" row; [allDay] switches to day-scale presets and wording, and its
|
||||||
* choice as a [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
|
* custom amounts to days and weeks. [header] sits above the rows, for whatever
|
||||||
|
* else the caller's reminder carries. Returns the choice as a
|
||||||
|
* [ReminderOverride]. Ported from Calendula's ReminderDefaultPicker.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderLeadPicker(
|
fun ReminderLeadPicker(
|
||||||
@@ -59,6 +54,7 @@ fun ReminderLeadPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
allDay: Boolean = false,
|
allDay: Boolean = false,
|
||||||
presets: List<Int> = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
presets: List<Int> = if (allDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
// Agendula is single-reminder: an override carries a one-element list, so
|
// Agendula is single-reminder: an override carries a one-element list, so
|
||||||
// take the single value for this single-select picker.
|
// take the single value for this single-select picker.
|
||||||
@@ -68,7 +64,7 @@ fun ReminderLeadPicker(
|
|||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
var unit by rememberSaveable { mutableStateOf(seed.unit.takeIf { it in reminderUnits(allDay) } ?: reminderUnits(allDay).first()) }
|
||||||
|
|
||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
@@ -78,6 +74,7 @@ fun ReminderLeadPicker(
|
|||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
header?.invoke(this)
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -112,6 +109,8 @@ fun ReminderLeadPicker(
|
|||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
|
units = reminderUnits(allDay),
|
||||||
|
allDay = allDay,
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
onSelect(ReminderOverride.Minutes(listOf(minutes)))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -129,7 +128,9 @@ fun ReminderLeadPicker(
|
|||||||
* Clearing the last time falls back to "Use default" on a per-list picker and to
|
* Clearing the last time falls back to "Use default" on a per-list picker and to
|
||||||
* "No reminder" on the global one. Changes apply live; the user leaves via back.
|
* "No reminder" on the global one. Changes apply live; the user leaves via back.
|
||||||
* [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second
|
* [allDay] switches to day-scale presets, and [leadTimeSummary] adds a second
|
||||||
* line to each lead-time row. Ported from Calendula's ReminderDefaultPicker.
|
* line to each lead-time row. A start / due toggle picks which date the rows
|
||||||
|
* count from, and the choice carries it ([ReminderOverride.Minutes.fromStart]).
|
||||||
|
* Ported from Calendula's ReminderDefaultPicker.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -149,24 +150,27 @@ fun ReminderDefaultPicker(
|
|||||||
LaunchedEffect(selected) { if (!userEdited) current = selected }
|
LaunchedEffect(selected) { if (!userEdited) current = selected }
|
||||||
val inherits = current is ReminderOverride.Inherit
|
val inherits = current is ReminderOverride.Inherit
|
||||||
val isNone = current is ReminderOverride.None
|
val isNone = current is ReminderOverride.None
|
||||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
val selectedLeads = (current as? ReminderOverride.Minutes)?.leads.orEmpty()
|
||||||
|
var fromStart by rememberSaveable { mutableStateOf(true) }
|
||||||
|
val selectedMinutes = selectedLeads.filter { it.fromStart == fromStart }.map { it.minutes }
|
||||||
// Custom values seen this session keep their row once unchecked.
|
// Custom values seen this session keep their row once unchecked.
|
||||||
val seenCustom = remember { mutableSetOf<Int>() }
|
val seenCustom = remember { mutableSetOf<ReminderLead>() }
|
||||||
seenCustom += selectedMinutes.filter { it !in presets }
|
seenCustom += selectedLeads.filter { it.minutes !in presets }
|
||||||
val rows = presets + seenCustom.sorted()
|
val rows = presets + seenCustom.filter { it.fromStart == fromStart }.map { it.minutes }.sorted()
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf("") }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
var unit by rememberSaveable { mutableStateOf(reminderUnits(allDay).first()) }
|
||||||
|
|
||||||
fun apply(override: ReminderOverride) {
|
fun apply(override: ReminderOverride) {
|
||||||
userEdited = true
|
userEdited = true
|
||||||
current = override
|
current = override
|
||||||
onSelect(override)
|
onSelect(override)
|
||||||
}
|
}
|
||||||
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
fun emit(leads: List<ReminderLead>) = apply(reminderOverrideForLeads(leads, allowInherit))
|
||||||
|
fun lead(minute: Int) = ReminderLead(minute, fromStart)
|
||||||
fun toggle(minute: Int) =
|
fun toggle(minute: Int) =
|
||||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
emit(if (minute in selectedMinutes) selectedLeads - lead(minute) else selectedLeads + lead(minute))
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
if (allowInherit) {
|
if (allowInherit) {
|
||||||
@@ -186,6 +190,8 @@ fun ReminderDefaultPicker(
|
|||||||
onClick = { apply(ReminderOverride.None) },
|
onClick = { apply(ReminderOverride.None) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
val rowCount = rows.size + 1 // + the custom row
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
rows.forEachIndexed { index, minute ->
|
rows.forEachIndexed { index, minute ->
|
||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
@@ -213,8 +219,10 @@ fun ReminderDefaultPicker(
|
|||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
|
units = reminderUnits(allDay),
|
||||||
|
allDay = allDay,
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
emit(selectedMinutes + minutes)
|
emit(selectedLeads + lead(minutes))
|
||||||
amountText = ""
|
amountText = ""
|
||||||
customExpanded = false
|
customExpanded = false
|
||||||
},
|
},
|
||||||
@@ -223,57 +231,54 @@ fun ReminderDefaultPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which date a reminder counts back from. */
|
||||||
|
@Composable
|
||||||
|
fun ReminderAnchorToggle(fromStart: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||||
|
listOf(true, false).forEachIndexed { index, start ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = fromStart == start,
|
||||||
|
onClick = { onChange(start) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, 2),
|
||||||
|
label = { Text(stringResource(reminderAnchorLabel(start))) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomReminderEditor(
|
private fun CustomReminderEditor(
|
||||||
amountText: String,
|
amountText: String,
|
||||||
onAmountChange: (String) -> Unit,
|
onAmountChange: (String) -> Unit,
|
||||||
unit: ReminderUnit,
|
unit: ReminderUnit,
|
||||||
onUnitChange: (ReminderUnit) -> Unit,
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
units: List<ReminderUnit>,
|
||||||
|
allDay: Boolean,
|
||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
onAmountChange = onAmountChange,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor, allDay) }
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
) {
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
Column(
|
confirmEnabled = amount != null,
|
||||||
modifier = Modifier.padding(16.dp),
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
) {
|
selectedUnit = units.indexOf(unit),
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
)
|
||||||
SegmentedButton(
|
|
||||||
selected = unit == entry,
|
|
||||||
onClick = { onUnitChange(entry) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
|
||||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
|
||||||
enabled = amount != null,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The units a custom lead is entered in. An all-day reminder's time of day is
|
||||||
|
* set on its own, so its lead is whole days: an hour count would read back as a
|
||||||
|
* time of day.
|
||||||
|
*/
|
||||||
|
private fun reminderUnits(allDay: Boolean): List<ReminderUnit> =
|
||||||
|
if (allDay) listOf(ReminderUnit.Days, ReminderUnit.Weeks) else ReminderUnit.entries
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride, allDay: Boolean): String = when (override) {
|
||||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull down to sync every account (#73). Without an account there is nothing
|
||||||
|
* to sync, so the gesture is off. [topPadding] keeps the indicator below a top
|
||||||
|
* bar the content scrolls under.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SyncPullBox(
|
||||||
|
available: Boolean,
|
||||||
|
syncing: Boolean,
|
||||||
|
onSync: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
topPadding: Dp = 0.dp,
|
||||||
|
content: @Composable BoxScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
if (!available) {
|
||||||
|
Box(modifier, content = content)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val state = rememberPullToRefreshState()
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = syncing,
|
||||||
|
onRefresh = onSync,
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
indicator = {
|
||||||
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = state,
|
||||||
|
isRefreshing = syncing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -66,7 +66,7 @@ import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
import de.jeanlucmakiola.agendula.ui.common.TagChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.linkified
|
import de.jeanlucmakiola.agendula.ui.common.linkified
|
||||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -198,6 +198,7 @@ fun TaskDetailScreen(
|
|||||||
detail = s.detail,
|
detail = s.detail,
|
||||||
inner = inner,
|
inner = inner,
|
||||||
reminders = s.reminders,
|
reminders = s.reminders,
|
||||||
|
allDayMinuteOfDay = s.allDayMinuteOfDay,
|
||||||
onToggle = { viewModel.toggleComplete(s.detail.task) },
|
onToggle = { viewModel.toggleComplete(s.detail.task) },
|
||||||
onToggleSubtask = { viewModel.toggleComplete(it) },
|
onToggleSubtask = { viewModel.toggleComplete(it) },
|
||||||
onAddSubtask = { viewModel.addSubtask(it) },
|
onAddSubtask = { viewModel.addSubtask(it) },
|
||||||
@@ -251,6 +252,7 @@ private fun DetailBody(
|
|||||||
detail: TaskDetail,
|
detail: TaskDetail,
|
||||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||||
reminders: List<TaskReminder>,
|
reminders: List<TaskReminder>,
|
||||||
|
allDayMinuteOfDay: Int,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onToggleSubtask: (Task) -> Unit,
|
onToggleSubtask: (Task) -> Unit,
|
||||||
onAddSubtask: (String) -> Unit,
|
onAddSubtask: (String) -> Unit,
|
||||||
@@ -352,8 +354,12 @@ private fun DetailBody(
|
|||||||
DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) {
|
DetailCard(icon = Icons.Rounded.Notifications, iconContentDescription = stringResource(R.string.edit_reminder_label)) {
|
||||||
reminders.forEach { reminder ->
|
reminders.forEach { reminder ->
|
||||||
Text(
|
Text(
|
||||||
if (reminder.fromStart) stringResource(R.string.detail_reminder_before_start, reminderLeadTimeLabel(reminder.minutesBefore))
|
taskReminderLabel(
|
||||||
else reminderLeadTimeLabel(reminder.minutesBefore, task.isAllDay),
|
reminder,
|
||||||
|
task.isAllDay,
|
||||||
|
allDayMinuteOfDay,
|
||||||
|
anchored = task.start != null && task.due != null,
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
|
||||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
|
||||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -30,7 +35,9 @@ sealed interface TaskDetailUiState {
|
|||||||
data class Content(
|
data class Content(
|
||||||
val detail: TaskDetail,
|
val detail: TaskDetail,
|
||||||
/** The task's own reminders; empty when the list's or app default applies. */
|
/** The task's own reminders; empty when the list's or app default applies. */
|
||||||
val reminders: List<de.jeanlucmakiola.agendula.data.tasks.TaskReminder> = emptyList(),
|
val reminders: List<de.jeanlucmakiola.agendula.domain.TaskReminder> = emptyList(),
|
||||||
|
/** The time an all-day reminder without one of its own rings at. */
|
||||||
|
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||||
) : TaskDetailUiState
|
) : TaskDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +45,7 @@ sealed interface TaskDetailUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TaskDetailViewModel @Inject constructor(
|
class TaskDetailViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@ApplicationScope private val appScope: CoroutineScope,
|
@ApplicationScope private val appScope: CoroutineScope,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -46,14 +54,18 @@ class TaskDetailViewModel @Inject constructor(
|
|||||||
val state: StateFlow<TaskDetailUiState> =
|
val state: StateFlow<TaskDetailUiState> =
|
||||||
target.filterNotNull()
|
target.filterNotNull()
|
||||||
.flatMapLatest { (id, occurrence) ->
|
.flatMapLatest { (id, occurrence) ->
|
||||||
|
val allDayMinute = settingsPrefs.settings.map { it.allDayReminderMinuteOfDay }.distinctUntilChanged()
|
||||||
repository.taskDetail(id, occurrence)
|
repository.taskDetail(id, occurrence)
|
||||||
.map<TaskDetail?, TaskDetailUiState> { detail ->
|
.map { detail -> detail?.let { it to repository.remindersFor(it.task.taskId) } }
|
||||||
if (detail == null) {
|
.combine(allDayMinute) { loaded, minute ->
|
||||||
|
if (loaded == null) {
|
||||||
TaskDetailUiState.NotFound
|
TaskDetailUiState.NotFound
|
||||||
} else {
|
} else {
|
||||||
|
val (detail, reminders) = loaded
|
||||||
TaskDetailUiState.Content(
|
TaskDetailUiState.Content(
|
||||||
detail = detail,
|
detail = detail,
|
||||||
reminders = repository.remindersFor(detail.task.taskId),
|
reminders = reminders.sortedWith(taskReminderDisplayOrder(detail.task.isAllDay, minute)),
|
||||||
|
allDayMinuteOfDay = minute,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,14 @@ import androidx.compose.ui.text.AnnotatedString
|
|||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
import de.jeanlucmakiola.agendula.ui.common.RecurringScopeDialog
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ReminderAnchorToggle
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||||
import de.jeanlucmakiola.agendula.ui.common.icon
|
import de.jeanlucmakiola.agendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
import de.jeanlucmakiola.agendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.agendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.taskReminderLabel
|
||||||
import de.jeanlucmakiola.floret.components.revealOnFocus
|
import de.jeanlucmakiola.floret.components.revealOnFocus
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
@@ -48,6 +51,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -123,6 +127,8 @@ import de.jeanlucmakiola.agendula.domain.Task
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.DAY_MINUTES
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
@@ -263,6 +269,8 @@ private fun EditContent(
|
|||||||
// the title takes the target list's colour.
|
// the title takes the target list's colour.
|
||||||
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
// A reminder's anchor only says something when the task has both to choose from.
|
||||||
|
val bothDates = state.start != null && state.due != null
|
||||||
|
|
||||||
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
var pickerTarget by rememberSaveable { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
var showListPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -559,23 +567,25 @@ private fun EditContent(
|
|||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
// One card per reminder, connected, the last row adding another.
|
// One card per reminder, connected, the last row adding another.
|
||||||
val rows = state.reminders.size + 1
|
val rows = state.reminders.size + 1
|
||||||
state.reminders.forEachIndexed { index, minutes ->
|
state.reminders.forEachIndexed { index, reminder ->
|
||||||
ReminderItemCard(
|
ReminderItemCard(
|
||||||
position = positionOf(index, rows),
|
position = positionOf(index, rows),
|
||||||
title = reminderLeadTimeLabel(minutes, state.isAllDay),
|
title = taskReminderLabel(reminder, state.isAllDay, state.allDayMinuteOfDay, anchored = bothDates),
|
||||||
onRemove = { viewModel.removeReminder(minutes) },
|
onRemove = { viewModel.removeReminder(reminder) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AddReminderCard(
|
AddReminderCard(
|
||||||
position = positionOf(state.reminders.size, rows),
|
position = positionOf(state.reminders.size, rows),
|
||||||
label = stringResource(
|
label = stringResource(
|
||||||
if (state.reminders.isEmpty()) R.string.edit_reminder_add_first else R.string.edit_reminder_add,
|
// Only a set the user has not chosen still falls back to the default.
|
||||||
|
if (state.reminders.isEmpty() && !state.remindersChosen) R.string.edit_reminder_add_first
|
||||||
|
else R.string.edit_reminder_add,
|
||||||
),
|
),
|
||||||
onClick = { showReminderPicker = true },
|
onClick = { showReminderPicker = true },
|
||||||
)
|
)
|
||||||
if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
|
if (TaskFormError.REMINDER_WITHOUT_DATE in state.errors) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
FieldError(R.string.error_reminder_without_due)
|
FieldError(R.string.error_reminder_without_date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,18 +698,73 @@ private fun EditContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showReminderPicker) {
|
if (showReminderPicker) {
|
||||||
|
var fromStart by rememberSaveable { mutableStateOf(state.start != null) }
|
||||||
|
var minuteOfDay by rememberSaveable { mutableIntStateOf(state.allDayMinuteOfDay) }
|
||||||
|
var pickingTime by rememberSaveable { mutableStateOf(false) }
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.edit_reminder_add),
|
title = stringResource(R.string.edit_reminder_add),
|
||||||
selected = ReminderOverride.Inherit,
|
selected = ReminderOverride.Inherit,
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
allDay = state.isAllDay,
|
allDay = state.isAllDay,
|
||||||
|
header = if (bothDates || state.isAllDay) {
|
||||||
|
{
|
||||||
|
if (bothDates) {
|
||||||
|
ReminderAnchorToggle(fromStart = fromStart, onChange = { fromStart = it })
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
if (state.isAllDay) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.reminder_time),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Text(
|
||||||
|
formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { pickingTime = true },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onSelect = { choice ->
|
onSelect = { choice ->
|
||||||
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let(viewModel::addReminder)
|
(choice as? ReminderOverride.Minutes)?.minutes?.firstOrNull()?.let { minutes ->
|
||||||
|
// A task with one date counts from that one, whatever the toggle said.
|
||||||
|
val anchorStart = if (bothDates) fromStart else state.due == null
|
||||||
|
viewModel.addReminder(
|
||||||
|
if (state.isAllDay) {
|
||||||
|
// The default time is left unnamed, so the reminder follows the setting.
|
||||||
|
TaskReminder.allDay(
|
||||||
|
daysBefore = minutes / DAY_MINUTES,
|
||||||
|
minuteOfDay = minuteOfDay.takeIf { it != state.allDayMinuteOfDay },
|
||||||
|
fromStart = anchorStart,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TaskReminder(minutes, fromStart = anchorStart)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
showReminderPicker = false
|
showReminderPicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showReminderPicker = false },
|
onDismiss = { showReminderPicker = false },
|
||||||
)
|
)
|
||||||
|
if (pickingTime) {
|
||||||
|
TimePickerAlert(
|
||||||
|
initial = LocalTime.of(minuteOfDay / 60, minuteOfDay % 60),
|
||||||
|
onConfirm = {
|
||||||
|
val picked = it.hour * 60 + it.minute
|
||||||
|
// The default time stays unnamed; any other is one an all-day reminder can name.
|
||||||
|
minuteOfDay = if (picked == state.allDayMinuteOfDay) picked else TaskReminder.allDayMinuteOfDay(picked)
|
||||||
|
pickingTime = false
|
||||||
|
},
|
||||||
|
onDismiss = { pickingTime = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
@@ -819,6 +884,7 @@ private fun InlineField(
|
|||||||
* affordance appears once a value is set. Tappable values read as links
|
* affordance appears once a value is set. Tappable values read as links
|
||||||
* (primary); an error flips them to the error colour.
|
* (primary); an error flips them to the error colour.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScheduleRow(
|
private fun ScheduleRow(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -829,43 +895,76 @@ private fun ScheduleRow(
|
|||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp)
|
||||||
Text(
|
LabelledValueLayout(
|
||||||
text = label,
|
label = {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (value == null) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.edit_set),
|
text = label,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
},
|
||||||
Text(
|
value = {
|
||||||
text = value.formatDate(allDay),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
if (value == null) {
|
||||||
color = valueColor,
|
Text(
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
text = stringResource(R.string.edit_set),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
if (!allDay) {
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Text(
|
modifier = valueModifier,
|
||||||
text = value.formatTime(LocalUse24HourFormat.current),
|
)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
} else {
|
||||||
color = valueColor,
|
FlowRow(
|
||||||
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
|
horizontalArrangement = Arrangement.End,
|
||||||
)
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
) {
|
||||||
Icon(
|
Text(
|
||||||
Icons.Rounded.Clear,
|
text = value.formatDate(allDay),
|
||||||
contentDescription = stringResource(R.string.edit_clear),
|
style = MaterialTheme.typography.titleMedium,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = valueColor,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = valueModifier,
|
||||||
)
|
)
|
||||||
|
if (!allDay) {
|
||||||
|
Text(
|
||||||
|
text = value.formatTime(LocalUse24HourFormat.current),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = valueColor,
|
||||||
|
modifier = valueModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Clear,
|
||||||
|
contentDescription = stringResource(R.string.edit_clear),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [label] at the start and [value] at the end of one line when both fit, else
|
||||||
|
* [value] on its own line below, still end-aligned — so large font scales wrap
|
||||||
|
* rather than clip either side (#76).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun LabelledValueLayout(label: @Composable () -> Unit, value: @Composable () -> Unit) {
|
||||||
|
FlowRow(
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
label()
|
||||||
|
// Its whole width as its minimum, so the value wraps as one piece.
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
Box(Modifier.width(IntrinsicSize.Max)) { value() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.edit
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_ALL_DAY_REMINDER_MINUTE
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||||
@@ -16,6 +18,10 @@ import de.jeanlucmakiola.agendula.domain.TaskFormError
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskReminder
|
||||||
|
import de.jeanlucmakiola.agendula.domain.taskReminderDisplayOrder
|
||||||
|
import de.jeanlucmakiola.agendula.data.tasks.StoredReminders
|
||||||
|
import java.time.ZoneId
|
||||||
import de.jeanlucmakiola.agendula.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
import de.jeanlucmakiola.agendula.domain.htmlToPlainText
|
||||||
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
import de.jeanlucmakiola.agendula.domain.looksLikeHtml
|
||||||
@@ -54,7 +60,13 @@ data class TaskEditUiState(
|
|||||||
val priority: Priority = Priority.NONE,
|
val priority: Priority = Priority.NONE,
|
||||||
val parentId: Long? = null,
|
val parentId: Long? = null,
|
||||||
val percentComplete: Int? = null,
|
val percentComplete: Int? = null,
|
||||||
val reminders: List<Int> = emptyList(),
|
val reminders: List<TaskReminder> = emptyList(),
|
||||||
|
/** Whether the user added or removed a reminder; until then the defaults follow the form. */
|
||||||
|
val remindersTouched: Boolean = false,
|
||||||
|
/** Whether [reminders] are stored as they are — see [TaskForm.remindersChosen]. */
|
||||||
|
val remindersChosen: Boolean = false,
|
||||||
|
/** The time an all-day reminder without one of its own rings at. */
|
||||||
|
val allDayMinuteOfDay: Int = DEFAULT_ALL_DAY_REMINDER_MINUTE,
|
||||||
val location: String = "",
|
val location: String = "",
|
||||||
val url: String = "",
|
val url: String = "",
|
||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
@@ -124,6 +136,20 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
|
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
|
||||||
private var defaultFields: Set<TaskFormField> = emptySet()
|
private var defaultFields: Set<TaskFormField> = emptySet()
|
||||||
|
|
||||||
|
/** The settings as the form was bound, for the defaults an all-day switch swaps in. */
|
||||||
|
private var settings: Settings = Settings()
|
||||||
|
|
||||||
|
/** The reminders before the last all-day switch, and what it made of them. */
|
||||||
|
private var lastSwitch: Pair<List<TaskReminder>, List<TaskReminder>>? = null
|
||||||
|
|
||||||
|
/** The reminders the task was loaded with, and the dates it had then. */
|
||||||
|
private var storedReminders: Set<TaskReminder> = emptySet()
|
||||||
|
|
||||||
|
/** [storedReminders] as the task's current kind, timed or all-day. */
|
||||||
|
private var loadedReminders: List<TaskReminder> = emptyList()
|
||||||
|
private var loadedStart: Instant? = null
|
||||||
|
private var loadedDue: Instant? = null
|
||||||
|
|
||||||
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
/** Start a fresh task, optionally pre-selecting a list / parent. */
|
||||||
fun bindNew(
|
fun bindNew(
|
||||||
presetListId: Long? = null,
|
presetListId: Long? = null,
|
||||||
@@ -138,6 +164,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
baselineLastModified = null
|
baselineLastModified = null
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val settings = settingsPrefs.settings.first()
|
val settings = settingsPrefs.settings.first()
|
||||||
|
this@TaskEditViewModel.settings = settings
|
||||||
defaultFields = settings.defaultEditFields
|
defaultFields = settings.defaultEditFields
|
||||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||||
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
val preset = presetFilterId?.let { settingsPrefs.savedFilter(it).first() }
|
||||||
@@ -157,6 +184,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
loading = false,
|
loading = false,
|
||||||
isNew = true,
|
isNew = true,
|
||||||
autofocusTitle = settings.autofocusTitle,
|
autofocusTitle = settings.autofocusTitle,
|
||||||
|
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
|
||||||
listId = defaultList,
|
listId = defaultList,
|
||||||
// A preset due is all-day, so a default start has to be too, or
|
// A preset due is all-day, so a default start has to be too, or
|
||||||
// today's start would land after a due of today.
|
// today's start would land after a due of today.
|
||||||
@@ -174,7 +202,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
parentCandidates = loadParents(defaultList, selfId = null),
|
parentCandidates = loadParents(defaultList, selfId = null),
|
||||||
title = initialTitle.orEmpty(),
|
title = initialTitle.orEmpty(),
|
||||||
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
|
tags = (listOfNotNull(presetTag) + preset?.tags.orEmpty()).distinct(),
|
||||||
),
|
).withDefaultReminders(),
|
||||||
).also { baseline = it.toForm().copy(title = "") }
|
).also { baseline = it.toForm().copy(title = "") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +214,9 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
editingTaskId = taskId
|
editingTaskId = taskId
|
||||||
editingOccurrence = occurrenceStart
|
editingOccurrence = occurrenceStart
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
defaultFields = settingsPrefs.settings.first().defaultEditFields
|
val settings = settingsPrefs.settings.first()
|
||||||
|
this@TaskEditViewModel.settings = settings
|
||||||
|
defaultFields = settings.defaultEditFields
|
||||||
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
|
||||||
val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull()
|
val task = runCatching { repository.taskDetail(taskId, occurrenceStart).first()?.task }.getOrNull()
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
@@ -194,6 +224,10 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
baselineLastModified = task.lastModified
|
baselineLastModified = task.lastModified
|
||||||
|
loadedReminders = repository.remindersFor(taskId).map { it.lead }.distinct()
|
||||||
|
storedReminders = loadedReminders.toSet()
|
||||||
|
loadedStart = task.start
|
||||||
|
loadedDue = task.due
|
||||||
_state.value = withFields(
|
_state.value = withFields(
|
||||||
TaskEditUiState(
|
TaskEditUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -208,7 +242,9 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
priority = task.priority,
|
priority = task.priority,
|
||||||
parentId = task.parentId,
|
parentId = task.parentId,
|
||||||
percentComplete = task.percentComplete,
|
percentComplete = task.percentComplete,
|
||||||
reminders = repository.remindersFor(taskId).filter { !it.fromStart }.map { it.minutesBefore },
|
reminders = loadedReminders
|
||||||
|
.sortedWith(taskReminderDisplayOrder(task.isAllDay, settings.allDayReminderMinuteOfDay)),
|
||||||
|
allDayMinuteOfDay = settings.allDayReminderMinuteOfDay,
|
||||||
location = task.location.orEmpty(),
|
location = task.location.orEmpty(),
|
||||||
url = task.url.orEmpty(),
|
url = task.url.orEmpty(),
|
||||||
tags = task.tags,
|
tags = task.tags,
|
||||||
@@ -271,35 +307,90 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
|
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
|
||||||
fun onListChange(listId: Long) {
|
fun onListChange(listId: Long) {
|
||||||
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
|
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
|
||||||
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
|
update { it.copy(listId = listId, parentId = null, errors = emptySet()).withDefaultReminders() }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val candidates = loadParents(listId, selfId = editingTaskId)
|
val candidates = loadParents(listId, selfId = editingTaskId)
|
||||||
update { it.copy(parentCandidates = candidates) }
|
update { it.copy(parentCandidates = candidates) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
|
fun onStartChange(value: Instant?) = update { it.copy(start = value).withDefaultReminders() }
|
||||||
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
|
fun onDueChange(value: Instant?) = update { it.copy(due = value).withDefaultReminders() }
|
||||||
/**
|
/**
|
||||||
* All-day and timed values use different conventions (UTC midnight vs. a real
|
* All-day and timed values use different conventions (UTC midnight vs. a real
|
||||||
* instant in the device zone), so the switch has to move the timestamps too —
|
* instant in the device zone), so the switch has to move the timestamps too —
|
||||||
* flipping the flag alone makes an all-day task read back as "02:00", which
|
* flipping the flag alone makes an all-day task read back as "02:00", which
|
||||||
* looks to the user like the time reset itself.
|
* looks to the user like the time reset itself. The reminders are made
|
||||||
|
* the new kind's here, so the editor shows what will be stored; switching
|
||||||
|
* straight back gives the old ones back.
|
||||||
*/
|
*/
|
||||||
fun onAllDayChange(value: Boolean) = update {
|
fun onAllDayChange(value: Boolean) = update {
|
||||||
it.copy(
|
if (it.isAllDay == value) return@update it
|
||||||
|
val switched = it.copy(
|
||||||
isAllDay = value,
|
isAllDay = value,
|
||||||
start = it.start?.rebasedForAllDay(value),
|
start = it.start?.rebasedForAllDay(value),
|
||||||
due = it.due?.rebasedForAllDay(value),
|
due = it.due?.rebasedForAllDay(value),
|
||||||
)
|
)
|
||||||
|
if (!it.remindersTouched) {
|
||||||
|
loadedReminders = carried(loadedReminders, switched)
|
||||||
|
return@update switched.withDefaultReminders()
|
||||||
|
}
|
||||||
|
switched.copy(reminders = carried(it.reminders, switched).sortedForDisplay(switched))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [reminders] as [switched]'s kind; straight back after a switch, the ones before it. */
|
||||||
|
private fun carried(reminders: List<TaskReminder>, switched: TaskEditUiState): List<TaskReminder> {
|
||||||
|
val previous = lastSwitch
|
||||||
|
val carried = if (previous != null && previous.second == reminders) {
|
||||||
|
previous.first
|
||||||
|
} else {
|
||||||
|
StoredReminders.switched(reminders, switched.toForm(), settings, nowMillis(), ZoneId.systemDefault())
|
||||||
|
}
|
||||||
|
lastSwitch = reminders to carried
|
||||||
|
return carried
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminders the task will be saved with while the user has not
|
||||||
|
* touched them: those it was loaded with, plus the defaults of the dates
|
||||||
|
* it has gained — all of them, for a new task. Shown so they are noted
|
||||||
|
* before saving rather than added unseen.
|
||||||
|
*/
|
||||||
|
private fun TaskEditUiState.withDefaultReminders(): TaskEditUiState {
|
||||||
|
if (remindersTouched) return this
|
||||||
|
val reminders = StoredReminders.withDefaults(
|
||||||
|
toForm().copy(reminders = loadedReminders),
|
||||||
|
loadedStart,
|
||||||
|
loadedDue,
|
||||||
|
settings,
|
||||||
|
nowMillis(),
|
||||||
|
ZoneId.systemDefault(),
|
||||||
|
).sortedForDisplay(this)
|
||||||
|
val visible = if (reminders.isEmpty()) visibleFields else visibleFields + TaskFormField.Reminder
|
||||||
|
return copy(
|
||||||
|
reminders = reminders,
|
||||||
|
// A new task's, or defaults added to the ones it had: stored as shown.
|
||||||
|
remindersChosen = isNew || reminders.toSet() != storedReminders,
|
||||||
|
visibleFields = visible,
|
||||||
|
hiddenFields = hiddenOf(visible),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
|
||||||
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
|
||||||
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
|
||||||
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
|
||||||
fun addReminder(minutesBeforeDue: Int) = update {
|
fun addReminder(reminder: TaskReminder) = update {
|
||||||
it.copy(reminders = (it.reminders + minutesBeforeDue).distinct().sorted(), errors = emptySet())
|
it.copy(
|
||||||
|
reminders = (it.reminders + reminder).distinct().sortedForDisplay(it),
|
||||||
|
remindersTouched = true,
|
||||||
|
remindersChosen = true,
|
||||||
|
errors = emptySet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun removeReminder(reminder: TaskReminder) = update {
|
||||||
|
it.copy(reminders = it.reminders - reminder, remindersTouched = true, remindersChosen = true)
|
||||||
}
|
}
|
||||||
fun removeReminder(minutesBeforeDue: Int) = update { it.copy(reminders = it.reminders - minutesBeforeDue) }
|
|
||||||
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
fun onLocationChange(value: String) = update { it.copy(location = value) }
|
||||||
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
fun onUrlChange(value: String) = update { it.copy(url = value) }
|
||||||
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
|
fun addTag(name: String) = update { it.copy(tags = normalizeTags(it.tags + name), tagDraft = "") }
|
||||||
@@ -382,6 +473,10 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The order the editor lists [state]'s reminders in. */
|
||||||
|
private fun List<TaskReminder>.sortedForDisplay(state: TaskEditUiState): List<TaskReminder> =
|
||||||
|
sortedWith(taskReminderDisplayOrder(state.isAllDay, state.allDayMinuteOfDay))
|
||||||
|
|
||||||
/** Snapshot the editable state as a validated [TaskForm]. */
|
/** Snapshot the editable state as a validated [TaskForm]. */
|
||||||
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
||||||
title = title,
|
title = title,
|
||||||
@@ -394,6 +489,7 @@ private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
|
|||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
percentComplete = percentComplete,
|
percentComplete = percentComplete,
|
||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
|
remindersChosen = remindersChosen,
|
||||||
location = location.ifBlank { null },
|
location = location.ifBlank { null },
|
||||||
url = url.ifBlank { null },
|
url = url.ifBlank { null },
|
||||||
tags = withDraft(tagDraft),
|
tags = withDraft(tagDraft),
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ private const val EXTERNAL_STORAGE = "com.android.externalstorage.documents"
|
|||||||
@Composable
|
@Composable
|
||||||
fun ExportScreen(
|
fun ExportScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ExportViewModel = hiltViewModel(),
|
viewModel: ExportViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -89,6 +90,8 @@ fun ExportScreen(
|
|||||||
title = stringResource(R.string.settings_export),
|
title = stringResource(R.string.settings_export),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.export_hint),
|
text = stringResource(R.string.export_hint),
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ private val PICK_TYPES = arrayOf("text/calendar", "text/x-vcalendar", "applicati
|
|||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
predictiveBack: Boolean = false,
|
||||||
|
visible: Boolean? = null,
|
||||||
initialUri: Uri? = null,
|
initialUri: Uri? = null,
|
||||||
onOpenStorage: (() -> Unit)? = null,
|
onOpenStorage: (() -> Unit)? = null,
|
||||||
viewModel: ImportViewModel = hiltViewModel(),
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
@@ -105,6 +107,8 @@ fun ImportScreen(
|
|||||||
title = stringResource(R.string.import_title),
|
title = stringResource(R.string.import_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
predictiveBack = predictiveBack,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.import_hint),
|
text = stringResource(R.string.import_hint),
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor(
|
|||||||
val result = importer.import(ready.parsed, target)
|
val result = importer.import(ready.parsed, target)
|
||||||
(chosen as? ImportChoice.Existing)?.list
|
(chosen as? ImportChoice.Existing)?.list
|
||||||
?.takeIf { it.accountId != null && result.imported > 0 }
|
?.takeIf { it.accountId != null && result.imported > 0 }
|
||||||
?.let { runCatching { syncRequests.syncNow(it.accountName) } }
|
?.let { runCatching { syncRequests.syncNow(it.accountId!!) } }
|
||||||
ImportStep.Done(
|
ImportStep.Done(
|
||||||
listName = listName,
|
listName = listName,
|
||||||
imported = result.imported,
|
imported = result.imported,
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ import de.jeanlucmakiola.floret.components.positionOf
|
|||||||
* source, which is the specific thing §3.2(a) requires us to point at.
|
* source, which is the specific thing §3.2(a) requires us to point at.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LicencesScreen(onBack: () -> Unit) {
|
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.licences_title),
|
title = stringResource(R.string.licences_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.licences_intro),
|
stringResource(R.string.licences_intro),
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import kotlinx.coroutines.delay
|
|||||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
@@ -122,6 +123,8 @@ fun ListsScreen(
|
|||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
|
||||||
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
var newList by rememberSaveable { mutableStateOf(false) }
|
var newList by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -173,7 +176,12 @@ fun ListsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
SyncPullBox(
|
||||||
|
available = syncAvailable && !searchActive,
|
||||||
|
syncing = syncing,
|
||||||
|
onSync = viewModel::syncAll,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
|
||||||
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
ListsUiState.Failure ->
|
ListsUiState.Failure ->
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.agendula.data.sync.ManualSync
|
||||||
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||||
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
|
|||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
private val remoteLists: RemoteLists,
|
private val remoteLists: RemoteLists,
|
||||||
|
private val manualSync: ManualSync,
|
||||||
settingsPrefs: SettingsPrefs,
|
settingsPrefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Pull to sync (#73); off without an account. */
|
||||||
|
val syncAvailable: StateFlow<Boolean> =
|
||||||
|
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
val syncing: StateFlow<Boolean> = manualSync.syncing
|
||||||
|
|
||||||
|
fun syncAll() = manualSync.syncAll()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a new list may go: the device, then every account whose server said
|
* Where a new list may go: the device, then every account whose server said
|
||||||
* it would take one.
|
* it would take one.
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ package de.jeanlucmakiola.agendula.ui.navigation
|
|||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -27,6 +34,7 @@ import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
|||||||
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
|
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single in-app back stack: lists → task list → detail / edit.
|
* The single in-app back stack: lists → task list → detail / edit.
|
||||||
@@ -37,13 +45,25 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
|||||||
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
|
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
|
||||||
* unaware of the nav library.
|
* unaware of the nav library.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendulaNavHost(
|
fun AgendulaNavHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
navRequest: NavRequest? = null,
|
navRequest: NavRequest? = null,
|
||||||
onNavRequestConsumed: () -> Unit = {},
|
onNavRequestConsumed: () -> Unit = {},
|
||||||
|
onReturnHome: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
|
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
|
||||||
|
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
val backStack by nav.currentBackStack.collectAsState()
|
||||||
|
LaunchedEffect(backStack, returnHomeEntry) {
|
||||||
|
val id = returnHomeEntry ?: return@LaunchedEffect
|
||||||
|
if (backStack.none { it.id == id }) {
|
||||||
|
returnHomeEntry = null
|
||||||
|
onReturnHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(navRequest) {
|
LaunchedEffect(navRequest) {
|
||||||
when (navRequest) {
|
when (navRequest) {
|
||||||
@@ -67,22 +87,25 @@ fun AgendulaNavHost(
|
|||||||
}
|
}
|
||||||
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
|
||||||
}
|
}
|
||||||
|
val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true ||
|
||||||
|
(navRequest as? NavRequest.OpenTask)?.returnHome == true
|
||||||
|
returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null
|
||||||
onNavRequestConsumed()
|
onNavRequestConsumed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val slideSpec = MaterialTheme.motionScheme.defaultSpatialSpec<IntOffset>()
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = nav,
|
navController = nav,
|
||||||
startDestination = Dest.LISTS,
|
startDestination = Dest.LISTS,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
// Only the screen on top moves; the background is always held static.
|
// Calendula's overlay motion. NavHost's own back handler seeks the exit
|
||||||
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
// over the previous screen, so destinations don't add a preview of their own.
|
||||||
// Back is the predictive-back peek: navigation-compose composes the
|
enterTransition = { slideInHorizontally(slideSpec) { it } + fadeIn() },
|
||||||
// screen being revealed underneath and seeks popExit with the gesture, so
|
|
||||||
// the leaving screen scales down to reveal the (unmoved) previous screen.
|
|
||||||
enterTransition = { fadeIn() },
|
|
||||||
exitTransition = { ExitTransition.None },
|
exitTransition = { ExitTransition.None },
|
||||||
popEnterTransition = { EnterTransition.None },
|
popEnterTransition = { EnterTransition.None },
|
||||||
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
|
popExitTransition = { overlayExit },
|
||||||
) {
|
) {
|
||||||
composable(Dest.LISTS) {
|
composable(Dest.LISTS) {
|
||||||
ReadableWidth {
|
ReadableWidth {
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
|
|||||||
* [AgendulaNavHost], which pushes it over whatever is showing.
|
* [AgendulaNavHost], which pushes it over whatever is showing.
|
||||||
*/
|
*/
|
||||||
sealed interface NavRequest {
|
sealed interface NavRequest {
|
||||||
/** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
|
/**
|
||||||
data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
|
* One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series.
|
||||||
|
* [returnHome] leaves the app once the detail is closed (a widget row, #93).
|
||||||
|
*/
|
||||||
|
data class OpenTask(
|
||||||
|
val taskId: Long,
|
||||||
|
val occurrenceStart: Long? = null,
|
||||||
|
val returnHome: Boolean = false,
|
||||||
|
) : NavRequest
|
||||||
|
|
||||||
/** Settings → Accounts, where a sync report's detail lives. */
|
/** Settings → Accounts, where a sync report's detail lives. */
|
||||||
data object OpenAccounts : NavRequest
|
data object OpenAccounts : NavRequest
|
||||||
@@ -21,8 +28,13 @@ sealed interface NavRequest {
|
|||||||
/**
|
/**
|
||||||
* A new task, optionally titled with text shared from another app, or
|
* A new task, optionally titled with text shared from another app, or
|
||||||
* preset to a list — a widget's own add button, when it shows one list.
|
* preset to a list — a widget's own add button, when it shows one list.
|
||||||
|
* [returnHome] leaves the app once the form is closed (#93).
|
||||||
*/
|
*/
|
||||||
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
|
data class NewTask(
|
||||||
|
val title: String? = null,
|
||||||
|
val listId: Long? = null,
|
||||||
|
val returnHome: Boolean = false,
|
||||||
|
) : NavRequest
|
||||||
|
|
||||||
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
|
||||||
data class OpenSmart(val list: SmartList) : NavRequest
|
data class OpenSmart(val list: SmartList) : NavRequest
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user