diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml index e99df16a..2b6ba5db 100644 --- a/.github/workflows/flutter-android.yml +++ b/.github/workflows/flutter-android.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Validate release PR source branch if: github.event_name == 'pull_request' @@ -25,6 +27,9 @@ jobs: RELEASE_PR_TITLE: ${{ github.event.pull_request.title }} run: python3 tool/check_release_pull_request.py + - name: Materialize pinned shared dependencies + run: python3 tool/checkout_shared_dependencies.py + - name: Set up JDK 17 uses: actions/setup-java@v5 with: diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index acfafedc..87628cb2 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -30,6 +30,8 @@ jobs: - name: Check out repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Validate release PR source branch if: github.event_name == 'pull_request' @@ -37,6 +39,9 @@ jobs: RELEASE_PR_TITLE: ${{ github.event.pull_request.title }} run: python3 tool/check_release_pull_request.py + - name: Materialize pinned shared dependencies + run: python3 tool/checkout_shared_dependencies.py + - name: Install Linux build dependencies run: | printf 'https://archive.ubuntu.com/ubuntu/\n' | \ @@ -258,6 +263,18 @@ jobs: id: snapcraft uses: snapcore/action-build@v1 + - name: Build deterministic sibling-layout source archive + run: python3 tool/package_source.py + + - name: Upload pinned source artifact + if: github.event_name == 'push' + uses: actions/upload-artifact@v7 + with: + name: busymax-source + path: dist/source/ + if-no-files-found: error + retention-days: 7 + - name: Install strict Snap run: >- sudo snap install --dangerous diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 2561bce6..cc2302aa 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -18,6 +18,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Validate release PR source branch if: github.event_name == 'pull_request' @@ -26,6 +28,10 @@ jobs: RELEASE_PR_TITLE: ${{ github.event.pull_request.title }} run: python tool/check_release_pull_request.py + - name: Materialize pinned shared dependencies + shell: pwsh + run: python tool/checkout_shared_dependencies.py + - name: Set up Flutter uses: subosito/flutter-action@v2 with: diff --git a/.gitignore b/.gitignore index b0f6c4ea..9f124303 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ migrate_working_dir/ .pub/ /build/ /coverage/ +/dist/ # Per-run evidence belongs in build/ or a temporary directory. /docs/validation/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2cf49892..6e6e0303 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -59,12 +59,13 @@ android { minSdk = 24 targetSdk = 37 // Keep Android's required monotonic package code separate from the - // public 0.2.4 version declared in pubspec.yaml. When using split APKs, + // public version declared in pubspec.yaml. The owner confirmed 4 is + // the highest distributed base code; v0.3.0 advances to 5. For split APKs, // 1000 * ABI_VERSION is added automatically by Flutter. // (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` // flag during build. - versionCode = 4 + versionCode = 5 versionName = flutter.versionName manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!! manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash diff --git a/android/app/src/main/res/raw/busymax_notification_keep.xml b/android/app/src/main/res/raw/busymax_notification_keep.xml new file mode 100644 index 00000000..f7e80f1a --- /dev/null +++ b/android/app/src/main/res/raw/busymax_notification_keep.xml @@ -0,0 +1,4 @@ + + + diff --git a/docs/android_release.md b/docs/android_release.md index b732f5cf..323625fd 100644 --- a/docs/android_release.md +++ b/docs/android_release.md @@ -5,6 +5,12 @@ Production key material is never tracked. Copy `storeFile` at the owner-controlled keystore, and fill its passwords and alias outside logs and shell history. Do not replace an established upload identity. +For v0.3.0 the public version is `0.3.0` and the package code is `5`, advancing +the v0.2.4 source and retained test-signed artifact code `4`. During preparation +on 2026-10-11, the owner confirmed `4` is the highest distributed base code. +For subsequent releases, verify the distribution ledger and account for ABI +offsets if split APKs were distributed before choosing the next code. + With real public provider configuration and owner signing present, build both formats with: diff --git a/docs/android_setup.md b/docs/android_setup.md index 7668c33e..77a52f32 100644 --- a/docs/android_setup.md +++ b/docs/android_setup.md @@ -1,7 +1,7 @@ # Android setup and provider registration BusyMax Android is a beta application with package ID -`io.busystack.busymax`, version `0.2.4`, minimum API 24, and compile/target +`io.busystack.busymax`, version `0.3.0`, minimum API 24, and compile/target API 37. The supported toolchain is Flutter 3.47.5, its bundled Dart 3.13.4, JDK 17, Android platform 37, and Build Tools 37.x. diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 269b35b1..21322beb 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -20,6 +20,8 @@ grep -nE '^version:|/head` and target branch; +the synthetic PR merge checkout does not prove release-branch ancestry. +The owner reviews and manually merges the open release PR. +Historically, v0.2.4 feature/fix PRs were squashed into `Release/v0.2.4`, +followed by a squash of that release PR into `main`. For release artifacts, follow [Snap beta release](beta_snap_release.md) or [Windows packaging](windows_packaging.md) and the diff --git a/docs/development/contacts-read-only.md b/docs/development/contacts-read-only.md new file mode 100644 index 00000000..6bf97cbe --- /dev/null +++ b/docs/development/contacts-read-only.md @@ -0,0 +1,66 @@ +# BusyMax contacts are read-only attendee lookup + +Implemented the owner's approved product policy. BusyMax synchronizes and caches contacts for attendee suggestions and offline lookup. It does not create, update or delete remote contacts, photos, Google groups, Microsoft folders or DAV address books. Contact account enrollment, consent, refresh, local disconnection/removal, source preferences and cache/cursor writes remain supported. Calendar/task writes and invitations retain their existing authorization and providers. + +The live starting point was `fix/contacts-read-only` at `42dd355454cfc5e86bd10f0c58ea786267f20b5f`, with no staged, unstaged or untracked work. No owner instruction files were found in BusyMax or its ancestor directories. [Baseline evidence](evidence/contacts-read-only/baseline.json) records sibling heads and pre-existing BusyCards IDE/cache work. The supplied archive identifies `788e8a8e4be563eecddc45f8f783e02e315ae0e2`, SHA-256 `e4830832d85d210df33fa459a8ec9623fd9eed1cbbb1c5ab6d06fc7da9b4dd86`; its static report was not treated as an executed test run of this correction. + +## Integration boundary + +`ReadOnlyContactsProvider` implements the existing public `ContactsProvider` contract. It keeps its concrete delegate private, delegates discovery, paging, retrieval, photo reads, create reconciliation reads, action decoding and disposal, and rejects `create`, `update`, `delete`, `setPhoto` and every provider action before calling the delegate. It preserves native contact records, identities, revisions, cancellation and pagination. Effective source capabilities disable create/update/delete/photo-write, rename and remove; read/photo-read and all other metadata retain their original values, including server-unreadable sources. + +`BusyMaxContactsController._bindProvider` applies this boundary to every `_bind` route, including the injectable factory, startup, enrollment, reconnect and removal recovery. The factory is private after construction. `_davProvider` also constructs CardDAV with `readOnly: true`. Stored OAuth grants remain actual provider grants rather than fabricated read-only sets. Old DAV configuration/passwords and `carddav:write` markers remain readable; only new DAV enrollment records the constant read-only configuration. Existing mode selection arguments and the Google contacts client's writable field were removed. + +`BusyMaxContactsStore` uses the existing Drift transaction to normalize cached source capabilities at startup before directory restoration. Reads and future source writes apply the same effective policy, including before online discovery and for disabled sources. IDs, records, source selection, cursors, preferences, account generations, credentials and schema 20 remain intact. No Drift regeneration, schema bump or data reset is needed. + +## Requested and granted authorization + +Contacts remain optional. First calendar/task sign-in requests no contacts scope. Explicit Google contacts enrollment requests only `https://www.googleapis.com/auth/contacts.readonly`; desktop Microsoft requests Graph-qualified `Contacts.Read`, and native MSAL uses the short representation. Already granted full Google contacts or Microsoft `Contacts.ReadWrite` permission satisfies read access without write consent or a new browser. Recognition of historical write grants is separate from outgoing scope assembly. + +Desktop reconnect/replacement requests carry previous contact access forward as read-only and retain base Calendar/Tasks, identity/offline access and enabled Microsoft shared-calendar/category scopes. Google keeps the installed-app system-browser PKCE full-required-set flow; it does not use web incremental authorization. Google refresh remains scope-free. Microsoft refresh now omits its optional scope parameter, including claims recovery, while retaining the issuing registration, rotation, returned-grant parsing, cooldown and generation fencing. Omitted returned scopes retain the existing grant under the renewal contract; explicit partial/revoked grants are honored. No local policy change revokes an existing provider grant or clears a working refresh token. + +The real Android broker sends read-only contacts scopes for interactive and silent requests. Existing sufficient native authorization uses silent acquisition, checks the actual result and preserves subject/tenant/registration/generation fencing. First optional enrollment persists accepted actual native grants before validating the contacts link, inside the existing native credential commit. Persistence failure rolls the grant/link back together. Requested scopes are never concatenated into a fictitious token grant. Existing optional native calendar/category access is carried into explicit reconnect/replacement. The Kotlin bridge already accepts supplied scope lists and returns provider-reported scopes; no new allowlist or bridge change was necessary. + +The resolved native artifacts were inspected: Google `play-services-auth:21.5.0` exposes `getGrantedScopes(): List` and `setRequestedScopes(List)`; MSAL `8.4.1` exposes `getScope(): String[]`. Artifact hashes and `javap` output are retained with delivery evidence. No SDK, runtime, dependency or registration versions changed. + +## Journals and lifecycle + +BusyMax no longer calls `directory.resumeQueued` during synchronization. Queued, unknown and conflicting entries retain their IDs, payloads and uncertainty evidence. Existing executing-to-unknown crash/removal handling remains in the shared directory, with unresolved recovery still actionable through the existing reconnect/removal state. Startup, timer, manual sync, reconnect and removal rollback do not dispatch historic contact writes. Explicit contact mutation attempts fail at the integration boundary; the shared directory may record a newly attempted provider action as failed, while existing journals are preserved. + +AuthRepository, AuthorizationPersistence, C1 lock ordering and C2 removal receipts were preserved. The actual LifecycleFixture still includes its production persistence/lock chain. Tests retain original enabled/disabled/absent state, cancellation/drain ordering, conditional restoration, detached-binding recovery, newer-generation fencing, unknown writes and shutdown assertions. + +## Settings and localization + +Linux/Yaru, Windows/Fluent and Android Settings retain account/source controls and platform routes. Editing/upgrade actions, writable badges and DAV mode switches were removed. Every supported locale contains a contacts-specific read-only attendee description, and the three unused editing keys were removed through normal `flutter gen-l10n`. The generic Portuguese alias remains empty; the existing European Portuguese locale carries its translation. Shared calendar/task editing strings remain. Manual attendee input, source-qualified cached suggestions, exclusions, limits, draft selections and stale-result fencing use the existing implementation. The embedded provider setup guides label contacts as optional and make no claim that prior grants were revoked or verification approved. + +## Verification and delivery + +Use the unchanged Flutter 3.47.5 and bundled Dart 3.13.4 pins verified by `tool/verify_flutter_sdk.dart`; `tool/shared_dependencies.json` retains contacts `cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2`, DAV `18f636f3227e3df8c1406bafd85b45e7f12cf8bd` and Graph `6572ee14ae992d4cd4f8c2218babaff5051bc132`. Sibling repositories are read-only dependencies; their checkout helper was inspected and not run over owner working trees. + +Commands and full logs are recorded under `build/contacts-read-only` and included in the delivery evidence archive. Command receipts retain their actual exit status, duration, head/tree and source fingerprint. The delivery manifest records the final commit/tree, archive and per-file checksums. Source uses the existing `python3 tool/package_source.py` sibling layout and contains the unchanged pinned dependency inputs. Linux release packaging preserves its native helper/assets. Extraction receipts verify both manifests, the relative dependency layout and locked offline resolution. The pre-existing source archive was preserved under `build/contacts-read-only/preserved-source-input` before replacing the packaging output; its revision is unverified and it is not presented as this correction. + +Targeted regressions include the existing contacts/store/enrollment/removal, authorization/consent/credential persistence, actual platform Settings routes and attendee fencing tests; additions inspect actual desktop authorization URLs, real Android broker method-channel scopes/native commits, shared-provider transports under broad grants, cached schema-20 state and journal preservation across startup/timer/reconnect/removal rollback. The final evidence also records normal localization generation, touched-file formatting, `git diff --check`, `flutter analyze`, `dart run tool/check_platform_boundaries.dart`, the full BusyMax Flutter suite, Linux debug/release builds, available native workflow checks and an isolated native Linux Settings session with a synthetic cache and screenshot. + +The final application and test inputs are commit `4a4bd5528725fe1eca067099a9671ced9e4a745a`, tree `e0e1c52405d73f26a259ecd59cff8f6ee48863f2`, source fingerprint `8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6`. Subsequent changes contain development evidence only; the delivery manifest identifies that final documentation commit and proves identical tested inputs. [Verification record](evidence/contacts-read-only/verification.json) includes exact commands, exit statuses, durations, log hashes and skip names. + +| Gate | Measured result | +| --- | --- | +| Locked resolution, localization generation, touched-file formatting, diff check, analysis, platform boundaries | Passed | +| Complete BusyMax Flutter suite | 3,619 passed, 10 live-provider skips, 0 failures | +| Affected cases within that final complete run | 435 passed, 0 skips | +| Corrective locale and linked-DAV focused run | 42 passed | +| Native Linux workflow checks and debug/release builds | Passed on the recorded Linux host | +| Real isolated Linux Settings session | 1 passed; synthetic offline cache, source selection, dialog and screenshot | +| Android bridge native unit tests and app lint | 11 tests passed; lint passed; unchanged native inputs checked across the correction | +| Independent Android test-signed APK/AAB builds and inspection | Passed; signatures, notification resources, 16 KB ELF segments and APK zip alignment checked | + +The first complete run caught the generic Portuguese alias regression (3,617 passes, 10 skips, 1 failure). It was corrected without changing supported locales, and the complete suite was rerun. An initial Android release build in the shared build layout failed because the generated registrant referenced the dev-only integration plugin without its release dependency. The independent temporary source/sibling layout resolved that build failure without changing pins or generated source manually. Both diagnostic logs remain in the evidence archive. Newly built Android files are labeled test-signed and were never installed. + +Native Windows builds/device acceptance require a Windows host; Fluent route tests are not native acceptance. Android on-device acceptance and live-provider tests are unverified: no device installation, disposable accounts or registrations were authorized. AAB bundletool alignment metadata remains unverified because no local bundletool jar is available; the other recorded artifact checks passed. Historical Windows/Android/Snap/offline artifacts are not relabeled as builds of this correction. Unchanged Snap/engine/driver qualification was not restarted. + +## References checked 11 October 2026 + +- [Google People connections authorization](https://developers.google.com/people/api/rest/v1/people.connections/list): accepts readonly or full contacts authorization. +- [Microsoft Graph permission meanings](https://learn.microsoft.com/en-us/graph/permissions-reference): `Contacts.Read` reads; `Contacts.ReadWrite` also includes mutations. +- [Google installed-app authorization](https://developers.google.com/identity/protocols/oauth2/native-app): PKCE and full-set reauthorization; installed-app incremental authorization is unsupported. +- [Microsoft refresh parameters](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token) and [RFC 6749 section 6](https://www.rfc-editor.org/rfc/rfc6749.html#section-6): optional refresh scope omission renews existing authorization. +- [Google sensitive scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification): scope access must match actual user-facing functionality. diff --git a/docs/development/evidence/contacts-read-only/baseline.json b/docs/development/evidence/contacts-read-only/baseline.json new file mode 100644 index 00000000..54e20ff5 --- /dev/null +++ b/docs/development/evidence/contacts-read-only/baseline.json @@ -0,0 +1,52 @@ +{ + "busymax": { + "branch": "fix/contacts-read-only", + "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f", + "status": "", + "staged": "", + "unstaged": "", + "schema": 20 + }, + "busystack_contacts": { + "branch": "main", + "head": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2", + "status": "", + "staged": "", + "unstaged": "" + }, + "busystack_dav": { + "branch": "main", + "head": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd", + "status": "", + "staged": "", + "unstaged": "" + }, + "busystack_graph": { + "branch": "main", + "head": "6572ee14ae992d4cd4f8c2218babaff5051bc132", + "status": "", + "staged": "", + "unstaged": "" + }, + "busycards": { + "branch": "main", + "head": "8cfb67e50e2d058228edc16dfcb053baf4abef56", + "status": "M .idea/busycards.iml\n M .idea/libraries/Dart_Packages.xml\n?? .idea/libraries/Flutter_Plugins.xml\n?? native/microsoft_auth_helper/__pycache__/", + "staged": "", + "unstaged": ".idea/busycards.iml | 3574 +++++++++++++++++++++++++++++++++++++\n .idea/libraries/Dart_Packages.xml | 558 +++++-\n 2 files changed, 4130 insertions(+), 2 deletions(-)" + }, + "busybox": { + "branch": "Release/v0.0.1", + "head": "5aeba272bde4f6798fadaa394c037e8b7aaf74e6", + "status": "", + "staged": "", + "unstaged": "" + }, + "busyfiles": { + "branch": "Release/v0.0.1", + "head": "14c2853cc089345d2b1ed053fc0d34b1a7477110", + "status": "", + "staged": "", + "unstaged": "" + } +} diff --git a/docs/development/evidence/contacts-read-only/verification.json b/docs/development/evidence/contacts-read-only/verification.json new file mode 100644 index 00000000..5662281e --- /dev/null +++ b/docs/development/evidence/contacts-read-only/verification.json @@ -0,0 +1,505 @@ +{ + "schema": 1, + "recordedUtc": "2026-10-11T13:30:21.822384+00:00", + "verifiedCommit": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "verifiedTree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "implementationCommits": [ + "1c7a433950ebcd6ce691b7c3ae4deddb572acf2e", + "4a4bd5528725fe1eca067099a9671ced9e4a745a" + ], + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "fingerprintContract": "SHA-256 of json.dumps(sorted path->SHA-256 map, sort_keys=True); application, test, platform, native, tool and embedded provider guides. Only subsequent development evidence is excluded. Full per-file map is in the delivery evidence archive.", + "toolchain": { + "host": "Linux-7.0.0-38-generic-x86_64-with-glibc2.43", + "flutter": { + "frameworkVersion": "3.47.5", + "channel": "stable", + "repositoryUrl": "https://github.com/flutter/flutter.git", + "frameworkRevision": "6a19cca56475dbfba1478ee68d7bd0c2ef891da1", + "frameworkCommitDate": "2026-09-17 14:13:22 -0400", + "engineRevision": "af7e796e161ae0bb1ff0758c71a7105418bd9ded", + "engineCommitDate": "2026-09-16 18:35:09.000Z", + "engineContentHash": "ab598368592da0064197e2bc15c7f5b0a2c6bb1f", + "engineBuildDate": "2026-09-16 12:35:21.428", + "dartSdkVersion": "3.13.4", + "devToolsVersion": "2.60.0", + "flutterVersion": "3.47.5" + }, + "pubspecLockSha256": "d7ad8317a97382b3aa52fa7434fd6d3a3ef2c8cb2a13d431581894f0d8bef849", + "dependencyPins": { + "schema": 1, + "layout": "sibling-working-trees", + "dependencies": [ + { + "name": "busystack_contacts", + "repository": "https://github.com/ChenZhenIO/busystack_contacts.git", + "revision": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2", + "path": "../busystack_contacts" + }, + { + "name": "busystack_dav", + "repository": "https://github.com/ChenZhenIO/busystack_dav.git", + "revision": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd", + "path": "../busystack_dav" + }, + { + "name": "busystack_graph", + "repository": "https://github.com/ChenZhenIO/busystack_graph.git", + "revision": "6572ee14ae992d4cd4f8c2218babaff5051bc132", + "path": "../busystack_graph" + } + ] + }, + "instructions": "No applicable AGENTS.md files found in repository or ancestor directories.", + "java": { + "exit": 0, + "output": "openjdk version \"25.0.1\" 2025-10-21 LTS\nOpenJDK Runtime Environment Temurin-25.0.1+8 (build 25.0.1+8-LTS)\nOpenJDK 64-Bit Server VM Temurin-25.0.1+8 (build 25.0.1+8-LTS, mixed mode, sharing)\n" + }, + "clang": { + "exit": 0, + "output": "Ubuntu clang version 21.1.8 (6ubuntu1)\nTarget: x86_64-pc-linux-gnu\nThread model: posix\nInstalledDir: /usr/lib/llvm-21/bin\n" + }, + "cmake": { + "exit": 0, + "output": "cmake version 4.2.3\n\nCMake suite maintained and supported by Kitware (kitware.com/cmake).\n" + }, + "g++": { + "exit": 0, + "output": "g++ (Ubuntu 15.2.0-16ubuntu1) 15.2.0\nCopyright (C) 2025 Free Software Foundation, Inc.\nThis is free software; see the source for copying conditions. There is NO\nwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\n\n" + } + }, + "fullFlutterSuite": { + "passed": 3619, + "skipped": 10, + "failed": 0 + }, + "affectedCasesInFinalFullSuite": { + "passed": 435, + "skipped": 0, + "failed": 0 + }, + "priorFocusedCommand": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "test", + "test/contacts", + "test/core/auth/contacts_authorization_requests_test.dart", + "test/google_tasks/oauth/auth_url_test.dart", + "test/google_tasks/oauth/token_exchange_test.dart", + "test/microsoft_todo/oauth/microsoft_oauth_service_test.dart", + "test/core/auth/authorization_transition_test.dart", + "test/features/auth/data/auth_repository_test.dart", + "test/features/settings/presentation/settings_screen_test.dart", + "test/platform/contacts_settings_routes_test.dart", + "test/platform/attendee_contacts_context_test.dart", + "test/platform/android/android_authorization_broker_test.dart", + "test/platform/android/android_contacts_enrollment_test.dart", + "test/platform/android/android_registration_transition_test.dart", + "test/platform/android/android_platform_channel_test.dart", + "--machine" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f", + "tree": "bba83dd5c534ab05ed1c28a24d85de22942821a2", + "sourceFingerprint": "50fdbb2ef28c2a32ac592ce18abf4ca5a755de013c45bb6020116ea797e3fd38", + "startedUtc": "2026-10-11T12:52:28.288945+00:00", + "exit": 0, + "elapsedSeconds": 153.117, + "log": "focused.log", + "logSha256": "6232d43a982166bad84df8b7febbf9b0e6d26e8961bf6291008a59c7a89f5170" + }, + "correctiveFocusedCases": 42, + "skips": [ + { + "name": "real browser Login Flow returns and revokes a canonical app password", + "path": "test/dav/nextcloud_login_flow_live_test.dart", + "reason": "Set BUSYMAX_NEXTCLOUD_LOGIN_LIVE=1 and its URL, username, temporary app password, and TLS certificate variables to run this test." + }, + { + "name": "real Login Flow start can be cancelled while browser authorization opens", + "path": "test/dav/nextcloud_login_flow_live_test.dart", + "reason": "Live Nextcloud Login Flow is not enabled." + }, + { + "name": "real pending polling remains 404 until the operation expires", + "path": "test/dav/nextcloud_login_flow_live_test.dart", + "reason": "Live Nextcloud Login Flow is not enabled." + }, + { + "name": "real Apple iCloud Calendar preserves discovery and event semantics", + "path": "test/dav/apple_icloud_live_integration_test.dart", + "reason": "Set BUSYMAX_ICLOUD_LIVE=1 and the BUSYMAX_ICLOUD_LIVE_* credential variables to run the Apple iCloud integration test." + }, + { + "name": "real iCloud onboarding, credential replacement, and local removal are atomic", + "path": "test/dav/apple_icloud_live_integration_test.dart", + "reason": "Set BUSYMAX_ICLOUD_LIVE=1 and the BUSYMAX_ICLOUD_LIVE_* credential variables to run the Apple iCloud integration test." + }, + { + "name": "exact Nextcloud server preserves DAV calendar and task semantics", + "path": "test/dav/nextcloud_live_integration_test.dart", + "reason": "Set BUSYMAX_NEXTCLOUD_LIVE=1 and the BUSYMAX_NEXTCLOUD_LIVE_* credential variables to run the isolated live-server tests." + }, + { + "name": "large Nextcloud collection synchronizes and deletes without truncation", + "path": "test/dav/nextcloud_live_integration_test.dart", + "reason": "Set BUSYMAX_NEXTCLOUD_LIVE_LARGE=1 with the live-server variables to run the 128-member collection test." + }, + { + "name": "prepare a durable Nextcloud object for server restart", + "path": "test/dav/nextcloud_live_integration_test.dart", + "reason": "Set the live restart stage to prepare to create the durable probe." + }, + { + "name": "rediscover and mutate the durable object after server restart", + "path": "test/dav/nextcloud_live_integration_test.dart", + "reason": "Set the live restart stage to verify after restarting the server." + }, + { + "name": "Nextcloud user and group shares enforce live ACL changes", + "path": "test/dav/nextcloud_sharing_live_test.dart", + "reason": "Set BUSYMAX_NEXTCLOUD_SHARING_LIVE=1 and disposable user/group environment variables to run the live Nextcloud sharing test." + } + ], + "androidNativeUnitTests": { + "tests": 11, + "skipped": 0, + "failures": 0, + "errors": 0 + }, + "androidNativeInputReuse": "Unit/lint receipt at 1c7a433; git diff --exit-code confirms android/, packages/busymax_android_platform/ and lib/src/android/ unchanged through verifiedCommit. The independent release build uses verifiedCommit.", + "commands": { + "locked-pub-final": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "pub", + "get", + "--enforce-lockfile" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:12:06.693602+00:00", + "exit": 0, + "elapsedSeconds": 5.444, + "log": "locked-pub-final.log", + "logSha256": "81571a671d2c0bb8c6f76bb7b2225fe07ad8bfd2abfa126840513cf856701ae6" + }, + "localization-verified": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "gen-l10n" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:17:38.991692+00:00", + "exit": 0, + "elapsedSeconds": 8.163, + "log": "localization-verified.log", + "logSha256": "97362974d3ec503ef18e205dbcc7d8e1ca57e5426c80a4966c6608571fa88609" + }, + "format-final": { + "command": [ + "python3", + "-c", + "import subprocess; p=subprocess.check_output([\"git\",\"diff\",\"--name-only\",\"42dd355454cfc5e86bd10f0c58ea786267f20b5f\",\"HEAD\",\"--\",\"*.dart\"],text=True).splitlines(); subprocess.run([\"/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart\",\"format\",\"--output=none\",\"--set-exit-if-changed\",*p],check=True)" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:12:47.625274+00:00", + "exit": 0, + "elapsedSeconds": 2.032, + "log": "format-final.log", + "logSha256": "d7b931aeddd13dbfaf708346f8224047501416a8e8e4592df36bd16897905d6c" + }, + "diff-check-final": { + "command": [ + "git", + "diff", + "--check", + "42dd355454cfc5e86bd10f0c58ea786267f20b5f", + "HEAD" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:17:36.740548+00:00", + "exit": 0, + "elapsedSeconds": 0.477, + "log": "diff-check-final.log", + "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "analyze-final": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "analyze" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:12:12.372728+00:00", + "exit": 0, + "elapsedSeconds": 32.2, + "log": "analyze-final.log", + "logSha256": "7fd056ddcba04d39591b14b27d7cb3f3a91ba0f18d01c23a4748fdb519566169" + }, + "boundaries-final": { + "command": [ + "/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart", + "run", + "tool/check_platform_boundaries.dart" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:12:45.429983+00:00", + "exit": 0, + "elapsedSeconds": 1.996, + "log": "boundaries-final.log", + "logSha256": "3f00b10245d2e2189cf2f1a007fb552555a01bc024a4a7618cce5a011ca7680e" + }, + "locale-and-linked-verified": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "test", + "test/app/localization_audit_test.dart", + "test/contacts/contacts_read_only_state_test.dart", + "--machine" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "1c7a433950ebcd6ce691b7c3ae4deddb572acf2e", + "tree": "7ac2af1e93321a972376ed23e67c5e6d697abf0d", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:11:09.973830+00:00", + "exit": 0, + "elapsedSeconds": 7.584, + "log": "locale-and-linked-verified.log", + "logSha256": "83ea98596ed15665b2efce7f585c26e73dd751165d972c90984aee38dfe722d6" + }, + "full-suite-final": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "test", + "--machine" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:11:48.529349+00:00", + "exit": 0, + "elapsedSeconds": 870.015, + "log": "full-suite-final.log", + "logSha256": "508eeff6de15de7cae41ec84281a52115088c9faef8640e4c89726f063821b00" + }, + "native-linux-final": { + "command": [ + "bash", + "/tmp/busymax-native-checks.sh" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:12:49.868408+00:00", + "exit": 0, + "elapsedSeconds": 20.436, + "log": "native-linux-final.log", + "logSha256": "fffdb4efc17e2e656a8ed419a250e486b9a87908c9c72729758b62df327b5795" + }, + "linux-session-final": { + "command": [ + "env", + "BUSYMAX_ACCEPTANCE_ROOT=/home/albert/busystack/busymax/build/contacts-read-only/native-session", + "XDG_DATA_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/data", + "XDG_CONFIG_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/config", + "XDG_CACHE_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/cache", + "dbus-run-session", + "--", + "xvfb-run", + "-a", + "/home/albert/flutter-sdk/bin/flutter", + "test", + "integration_test/contacts_read_only_linux_test.dart", + "-d", + "linux", + "--reporter", + "expanded" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:13:10.513412+00:00", + "exit": 0, + "elapsedSeconds": 158.794, + "log": "linux-session-final.log", + "logSha256": "b3b714acab8e4bde0f5fbc88c25835242f564988a097b6189717ce236ae8a264" + }, + "linux-debug-final": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "build", + "linux", + "--debug", + "-t", + "lib/main_linux.dart" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:15:51.053606+00:00", + "exit": 0, + "elapsedSeconds": 58.835, + "log": "linux-debug-final.log", + "logSha256": "f2bdcc31bd06c32b9e3216c398f0c7567578f0b3737e80421e13a34ebfef5798" + }, + "linux-release-final": { + "command": [ + "/home/albert/flutter-sdk/bin/flutter", + "build", + "linux", + "--release", + "-t", + "lib/main_linux.dart" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:16:51.521909+00:00", + "exit": 0, + "elapsedSeconds": 228.576, + "log": "linux-release-final.log", + "logSha256": "cba4d7abad7028d9f049a4469a4c08d8eeb7823d35107f8b4ff5bb18a270a5a9" + }, + "android-native": { + "command": [ + "timeout", + "600", + "android/gradlew", + "--project-dir", + "android", + ":busymax_android_platform:testDebugUnitTest", + ":app:lintDebug", + "--console=plain" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f", + "tree": "bba83dd5c534ab05ed1c28a24d85de22942821a2", + "sourceFingerprint": "50fdbb2ef28c2a32ac592ce18abf4ca5a755de013c45bb6020116ea797e3fd38", + "startedUtc": "2026-10-11T12:51:09.823115+00:00", + "exit": 0, + "elapsedSeconds": 318.333, + "log": "android-native.log", + "logSha256": "cce1d7c344ddb8f37aa1c26ea3fe8946305cfb20d75eecb9d5751bcf3ba1ec9d" + }, + "android-release-isolated": { + "command": [ + "timeout", + "900", + "python3", + "/tmp/busymax-android-isolated.py" + ], + "cwd": "/home/albert/busystack/busymax", + "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", + "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", + "startedUtc": "2026-10-11T13:11:48.024453+00:00", + "exit": 0, + "elapsedSeconds": 575.814, + "log": "android-release-isolated.log", + "logSha256": "c6ad717468194d88b1e8a5512021c7cf0f33085f873cf7901cc1a0d42efa7427" + } + }, + "androidArtifacts": { + "commit": "4a4bd5528725fe1eca067099a9671ced9e4a745a", + "label": "TEST-SIGNED; not production distribution; never installed", + "artifacts": [ + { + "file": "build/contacts-read-only/android-test-signed/busymax-contacts-read-only-test-signed.apk", + "sha256": "dbe4a866878b437a0e8e99c00c4289199fabb2644fa545de110403ade4a9d7f5", + "bytes": 93277935 + }, + { + "file": "build/contacts-read-only/android-test-signed/busymax-contacts-read-only-test-signed.aab", + "sha256": "58a52279a6bc60ac835bfe63c85a90d6265dcf25bde139b06c842a1528fd5025", + "bytes": 88155491 + } + ], + "remainingGate": "AAB bundletool alignment metadata unverified: local bundletool jar unavailable", + "sourceLayout": "/tmp/busymax-contacts-android-check-etq4atas" + }, + "remainingGates": [ + "Windows native build/device acceptance: no Windows host. Real Fluent route tests passed on Linux; they are not native Windows acceptance.", + "Android device acceptance: no emulator/device installation performed or authorized.", + "AAB bundletool alignment metadata: no local bundletool jar. APK/AAB builds, signature/resource checks and 16 KB ELF/zip alignment inspection passed.", + "Live-provider authorization/sync/mutations: no disposable accounts or registrations authorized; 10 live DAV cases skipped. No live-provider success claimed.", + "Snap/engine/driver qualification: unchanged inputs; not restarted or relabeled." + ], + "diagnosticRuns": { + "full-suite": "3617 passed, 10 skipped, 1 Portuguese alias audit failure at 1c7a433; corrected and complete suite rerun.", + "android-release": "Shared-layout release exit 1: GeneratedPluginRegistrant referenced integration_test while its release dependency was absent. Independent committed source/sibling layout build and inspection exit 0.", + "locale-and-linked-regression": "Initial new fixture compilation/authority setup failures are retained in logs; corrected before final verification.", + "sdk-missing-arguments": "Verifier first invoked without required flags, exit 1. Correct pinned-SDK command exit 0." + }, + "siblingFinalState": { + "busystack_contacts": { + "branch": "main", + "head": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2", + "status": "", + "staged": "", + "unstaged": "" + }, + "busystack_dav": { + "branch": "main", + "head": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd", + "status": "", + "staged": "", + "unstaged": "" + }, + "busystack_graph": { + "branch": "main", + "head": "6572ee14ae992d4cd4f8c2218babaff5051bc132", + "status": "", + "staged": "", + "unstaged": "" + }, + "busycards": { + "branch": "main", + "head": "8cfb67e50e2d058228edc16dfcb053baf4abef56", + "status": "M .idea/busycards.iml\n M .idea/libraries/Dart_Packages.xml\n?? .idea/libraries/Flutter_Plugins.xml\n?? native/microsoft_auth_helper/__pycache__/", + "staged": "", + "unstaged": ".idea/busycards.iml | 3574 +++++++++++++++++++++++++++++++++++++\n .idea/libraries/Dart_Packages.xml | 558 +++++-\n 2 files changed, 4130 insertions(+), 2 deletions(-)" + }, + "busybox": { + "branch": "Release/v0.0.1", + "head": "5aeba272bde4f6798fadaa394c037e8b7aaf74e6", + "status": "", + "staged": "", + "unstaged": "" + }, + "busyfiles": { + "branch": "Release/v0.0.1", + "head": "14c2853cc089345d2b1ed053fc0d34b1a7477110", + "status": "", + "staged": "", + "unstaged": "" + } + }, + "preservedSourceInput": { + "originalPath": "dist/source/busymax-0.2.4-source.tar.gz", + "preservedPath": "build/contacts-read-only/preserved-source-input/busymax-0.2.4-source.tar.gz", + "sha256": "c6431035581860ab05e38cc115d81fcea1d8737354ff3ad423ce4902b194f94a", + "revision": "unverified pre-existing artifact; not a build of this correction" + } +} diff --git a/docs/google_setup.md b/docs/google_setup.md index 90e652c7..6c3f405e 100644 --- a/docs/google_setup.md +++ b/docs/google_setup.md @@ -39,3 +39,15 @@ https://www.googleapis.com/auth/calendar These are BusyMax's existing desktop scopes. Imported endpoints, redirects, and scope declarations do not replace BusyMax's OAuth settings. Cancellation or an invalid replacement preserves the previous valid selection; staged configuration expires after ten minutes and is single-use. Re-import after expiry. Ordinary Reconnect uses the account's saved issuing registration. Explicit Replace registration chooses a method and preserves account identity and local data transactionally. Android uses native authentication and is outside these desktop instructions. Production Google registration values and provider approvals are owner-controlled prerequisites, not results of local validation or tests. + +## Optional read-only contacts + +Contacts are optional and are not requested during first Calendar/Tasks sign-in. To enable cached attendee suggestions, enable **People API** in the same Google Cloud project and allow this separate scope: + +```text +https://www.googleapis.com/auth/contacts.readonly +``` + +BusyMax uses synchronized contacts read-only, including offline attendee lookup. It does not edit contacts or groups. A previously granted full contacts permission remains accurately recorded and can satisfy lookup authorization; BusyMax's policy does not revoke that grant or clear working refresh tokens. Explicit reconnect carries contact access forward as read-only in its requested scopes using the installed-app full-required-set PKCE flow. + +Review [Google's sensitive-scope verification guidance](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification): requested access must match the user-facing functionality. These instructions do not claim verification approval. diff --git a/docs/microsoft_setup.md b/docs/microsoft_setup.md index 9752ad1a..f643af55 100644 --- a/docs/microsoft_setup.md +++ b/docs/microsoft_setup.md @@ -36,3 +36,9 @@ These match the existing `microsoftTodoOAuthScopes`. Shared-calendar (`Calendars Instructions use the same setup modal with a fixed header and one scrolling body. Back preserves the form; Close cancels the whole setup. Audience changes clear the tenant both visibly and in submitted state. Valid staged configuration expires after ten minutes and is single-use. Re-enter/edit after expiry. Ordinary Reconnect, refresh, and optional consent use the registration saved with that account after restart. Explicit Replace registration preserves identity checks, rollback, and local data. Android's native MSAL flow remains separate. The production app ID, audience, redirect, delegated permissions, and applicable consent are owner-controlled release prerequisites. + +## Optional read-only contacts + +Contacts are optional and are not requested during first Calendar/Tasks sign-in. For cached attendee suggestions, add the Microsoft Graph **delegated** permission `Contacts.Read` separately from the required scopes above. Desktop requests use `https://graph.microsoft.com/Contacts.Read`; native Android MSAL requests use `Contacts.Read`. + +BusyMax synchronizes contacts for read-only attendee lookup, including offline suggestions. It does not edit contacts or folders. Existing `Contacts.ReadWrite` grants can satisfy read access and remain accurately recorded. BusyMax's policy does not revoke prior server-side grants. Explicit reconnect and optional consent request only contacts read access while retaining enabled calendar/category permissions. Refresh omits the optional scope parameter to renew the existing authorization; it does not claim a reduced grant. See [Microsoft's refresh contract](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token). diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md index 683afa53..d0375cdf 100644 --- a/docs/windows_packaging.md +++ b/docs/windows_packaging.md @@ -14,8 +14,10 @@ owner's registered values: - package identity name, Publisher CN, and publisher display name; - four-part Store package version and optional previous version; - privacy-policy, support, and homepage HTTPS URLs; -- Google desktop OAuth client ID and client secret; -- Microsoft public client ID and authority tenant; and +- Explicitly active BusyMax-managed Google desktop client ID, secret, and + project ID, and Microsoft public client ID (managed authority is `common`); +- protected original Google/Microsoft registrations and original authority, + separately from the active values; and - `production=true`, `fakeData=false`, and `developmentBackend=false`. Never commit the local file. Identity and desktop client values are embedded diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md index d60b613d..e77cc0c3 100644 --- a/docs/windows_release_checklist.md +++ b/docs/windows_release_checklist.md @@ -10,6 +10,12 @@ person/date responsible for each result. ## Automated gates +- [ ] The release PR matches its source version, has consistent application, + Snap, AppStream and Windows resource metadata, and incorporates current + `main`; follow [Development](development.md). +- [ ] v0.3.0 application resources report `0.3.0`. The owner supplies a separate + four-part MSIX version greater than the previous distributed version; + the CI-only `1.0.0.0` identity is not a production Store version. - [ ] A clean checkout uses Flutter 3.47.5 and its bundled Dart 3.13.4. - [ ] Dependency resolution, localization generation, and Drift generation follow [Development](development.md) and leave committed outputs clean. diff --git a/integration_test/contacts_read_only_linux_test.dart b/integration_test/contacts_read_only_linux_test.dart new file mode 100644 index 00000000..073ad553 --- /dev/null +++ b/integration_test/contacts_read_only_linux_test.dart @@ -0,0 +1,159 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:busymax/main_linux.dart' as app; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; +import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; +import 'package:yaru/yaru.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final output = Platform.environment['BUSYMAX_ACCEPTANCE_ROOT']; + testWidgets( + 'isolated native Linux Settings exposes cached read-only contacts and source selection', + (tester) async { + expect(Platform.environment['XDG_DATA_HOME'], '$output/data'); + await tester.runAsync(() => app.main(const [])); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ScheduleWorkspace)), + ); + final controller = container.read(busyMaxContactsControllerProvider); + await tester.runAsync(() async { + await controller.start(); + // Synthetic local cache only. No account tokens, provider registrations, + // external requests or owner's application data are used in this session. + const source = ContactSource( + accountId: 'isolated-cache', + id: 'book', + provider: ContactProviderKind.carddav, + kind: ContactSourceKind.carddavAddressBook, + name: 'Isolated cached source', + remotePath: 'https://fixture.invalid/book/', + capabilities: ContactCapabilities( + create: true, + update: true, + delete: true, + photoWrite: true, + ), + canRename: true, + canRemove: true, + ); + const identity = ContactIdentity( + accountId: 'isolated-cache', + provider: ContactProviderKind.carddav, + sourceId: 'book', + remoteId: 'ada.vcf', + ); + final vcard = newVcard( + const ContactPatch( + name: FieldEdit.set(ContactName(display: 'Ada Isolated')), + emails: FieldEdit.set([ContactValue('ada@example.test')]), + ), + uid: 'fixture', + ); + await controller.store.write((tx) async { + await tx.putAccount( + const ContactAccount( + id: 'isolated-cache', + provider: ContactProviderKind.carddav, + displayName: 'Isolated attendee cache', + subject: 'fixture', + grantedScopes: {'carddav:write'}, + ), + ); + await tx.putSource(source); + await tx.putContact( + CardDavContactRecord( + identity: identity, + vcard: vcard, + revision: '"cached"', + projection: projectVcard(identity, vcard), + ), + ); + }); + expect( + (await controller.sourceSettings()).single.source.capabilities.update, + isFalse, + ); + expect( + (await controller.suggestAttendees('ada')).single.email, + 'ada@example.test', + ); + }); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyS); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsOneWidget); + final accounts = find.text('Accounts'); + if (accounts.evaluate().isNotEmpty) { + await tester.tap(accounts.first); + await tester.pumpAndSettle(); + } + expect(find.text('Enable contact editing'), findsNothing); + expect(find.text('Read and write access'), findsNothing); + expect( + find.text('Read-only contacts for attendee suggestions'), + findsWidgets, + ); + final setting = (await tester.runAsync(controller.sourceSettings))! + .single; + final toggle = find.byKey( + ValueKey('contact-source-${setting.source.key}'), + ); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(tester.widget(toggle).value, isFalse); + await tester.runAsync(() async { + expect(await controller.suggestAttendees('ada'), isEmpty); + }); + await tester.tap(toggle); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + expect( + (await controller.suggestAttendees('ada')).single.email, + 'ada@example.test', + ); + }); + await tester.tap(find.byKey(const ValueKey('add-carddav-contacts'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('carddav-contacts-label')), + findsOneWidget, + ); + expect(find.byType(SwitchListTile), findsNothing); + expect( + find.text('Read-only contacts for attendee suggestions'), + findsWidgets, + ); + final boundary = tester.renderObject( + find.byType(RepaintBoundary).first, + ); + final picture = await boundary.toImage(); + try { + final bytes = await picture.toByteData(format: ui.ImageByteFormat.png); + await File('$output/contacts-read-only-settings.png') + .writeAsBytes(bytes!.buffer.asUint8List()); + } finally { + picture.dispose(); + } + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + skip: output == null, + ); +} diff --git a/integration_test/desktop_registration_setup_test.dart b/integration_test/desktop_registration_setup_test.dart index b28c948a..c242d7a4 100644 --- a/integration_test/desktop_registration_setup_test.dart +++ b/integration_test/desktop_registration_setup_test.dart @@ -1,6 +1,7 @@ import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/l10n/registration_setup_content.dart'; import 'package:busymax/src/providers/busy_provider.dart'; + import 'dart:io'; import 'package:busymax/main_linux.dart' as app; @@ -20,370 +21,388 @@ import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final output = Platform.environment['BUSYMAX_ACCEPTANCE_ROOT']; - testWidgets('Settings owns desktop registration forms, guides and error states', ( - tester, - ) async { - expect(Platform.isLinux, isTrue); - expect(Platform.environment['XDG_DATA_HOME'], '$output/data'); - await tester.runAsync(() => app.main(const [])); - await tester.pumpAndSettle(); - final container = ProviderScope.containerOf( - tester.element(find.byType(ScheduleWorkspace)), - ); - expect(await container.read(accountsStreamProvider.future), isEmpty); - final settings = container.read(appSettingsControllerProvider.notifier); - await tester.runAsync( - () => settings.setThemeModePreference(BusyMaxThemeModePreference.light), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Add account')); - await tester.pumpAndSettle(); - expect(find.byType(SettingsScreen), findsOneWidget); - final staging = container.read(registrationStagingProvider); - final picker = _Picker(); - final previousPicker = FileSelectorPlatform.instance; - FileSelectorPlatform.instance = picker; - addTearDown(() => FileSelectorPlatform.instance = previousPicker); - final file = File('$output/desktop.json'); - await file.writeAsString( - await File('test/fixtures/oauth/desktop_synthetic.json').readAsString(), - ); - picker.selection = XFile(file.path); - await tester.tap(find.text('Add Google account')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/google-methods.png'); - expect(tester.widget(_key('busymax')).onPressed, isNull); - expect(_key('shared-unavailable'), findsOneWidget); - await _back(tester); - expect(_key('methods-dialog'), findsOneWidget); - expect(find.byType(SettingsScreen), findsOneWidget); - await tester.tap(_key('workspace')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/workspace-empty.png'); - await tester.tap(_key('import')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/workspace-imported.png'); - await tester.tap(_key('guide')); - await tester.pumpAndSettle(); - await _inspectInstructions( - tester, - BusyProvider.google, - DesktopConnectionMethod.googleWorkspace, - '$output/workspace', - ); - await _back(tester); - expect(_key('summary'), findsOneWidget); - await _back(tester); - await tester.tap(_key('custom')); - await tester.pumpAndSettle(); - // Both ownership pages share the same transactional importer and selection. - expect(_key('summary'), findsOneWidget); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Add Google account')); - await tester.pumpAndSettle(); - await tester.tap(_key('custom')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/google-empty.png'); - await tester.tap(_key('import')); - await tester.pumpAndSettle(); - expect(_key('summary'), findsOneWidget); - await _capture(tester, '$output/google-imported.png'); - picker.selection = null; - await tester.tap(_key('import')); - await tester.pumpAndSettle(); - expect(_key('summary'), findsOneWidget); - picker.selection = XFile(file.path); - await file.writeAsString('{"web":{}}'); - await tester.tap(_key('import')); - await tester.pumpAndSettle(); - expect(_key('error'), findsOneWidget); - expect(_key('summary'), findsOneWidget); - await _capture(tester, '$output/google-invalid-replacement.png'); - await tester.tap(_key('guide')); - await tester.pumpAndSettle(); - expect(_key('instructions-dialog'), findsOneWidget); - expect(_key('back'), findsOneWidget); - await _capture(tester, '$output/google-guide.png'); - await _inspectInstructions( - tester, - BusyProvider.google, - DesktopConnectionMethod.custom, - '$output/google', - ); - expect(_key('authorize').hitTestable(), findsNothing); - await tester.ensureVisible(_key('copy-all')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/google-guide-scrolled.png'); - await tester.ensureVisible(find.text('Open Google Cloud Console')); - await tester.pumpAndSettle(); - // Native Linux launcher returns an error string; exercise its real exception path. - const channel = BasicMessageChannel( - 'dev.flutter.pigeon.url_launcher_linux.UrlLauncherApi.launchUrl', - StandardMessageCodec(), - ); - tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( - channel, - (_) async => ['test launch failure'], - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger - .setMockDecodedMessageHandler(channel, null), - ); - await tester.tap(find.text('Open Google Cloud Console')); - await tester.pumpAndSettle(); - expect(_key('link-error'), findsOneWidget); - await _capture(tester, '$output/google-guide-link-error.png'); - await tester.ensureVisible(_key('back')); - await tester.tap(_key('back')); - await tester.pumpAndSettle(); - expect(_key('instructions-dialog'), findsNothing); - expect(_key('summary'), findsOneWidget); - await _capture(tester, '$output/google-instructions-closed.png'); - staging - .cancel(); // Same notification as expiry, without waiting ten minutes. - await tester.pumpAndSettle(); - expect(tester.widget(_key('authorize')).onPressed, isNull); - await _capture(tester, '$output/google-expired.png'); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Add Microsoft account')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-methods.png'); - expect(tester.widget(_key('busymax')).onPressed, isNull); - expect(_key('workspace'), findsNothing); - await _back(tester); - expect(_key('methods-dialog'), findsOneWidget); - expect(find.byType(SettingsScreen), findsOneWidget); - await tester.tap(_key('custom')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-empty.png'); - await tester.enterText(_key('client-id'), 'invalid'); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-invalid.png'); - await tester.enterText( - _key('client-id'), - '11223344-5566-7788-99aa-bbccddeeff00', - ); - await _audience(tester, 3, capture: '$output/microsoft-menu-open.png'); - await tester.enterText( - _key('tenant-id'), - 'aabbccdd-1122-3344-5566-77889900aabb', - ); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-tenant.png'); - await tester.tap(_key('guide')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-guide.png'); - await _inspectInstructions( - tester, - BusyProvider.microsoft, - DesktopConnectionMethod.custom, - '$output/microsoft', - ); - expect(_key('authorize').hitTestable(), findsNothing); - await tester.ensureVisible(_key('copy-all')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-guide-scrolled.png'); - await tester.ensureVisible(find.text('Open Microsoft Entra admin center')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open Microsoft Entra admin center')); - await tester.pumpAndSettle(); - await _capture(tester, '$output/microsoft-guide-link-error.png'); - await tester.ensureVisible(_key('back')); - await tester.tap(_key('back')); - await tester.pumpAndSettle(); - expect(_key('instructions-dialog'), findsNothing); - expect( - tester.widget(_key('tenant-id')).controller!.text, - 'aabbccdd-1122-3344-5566-77889900aabb', - ); - await _capture(tester, '$output/microsoft-instructions-closed.png'); - await _audience(tester, 1); - expect(_key('tenant-id'), findsNothing); - expect( - tester.widget(_key('authorize')).onPressed, - isNotNull, - ); - await _capture(tester, '$output/microsoft-organizations.png'); - await _audience(tester, 0); - await _audience(tester, 2); - expect(_key('tenant-id'), findsNothing); - await _audience(tester, 3); - expect( - tester.widget(_key('tenant-id')).controller!.text, - isEmpty, - ); - expect(tester.widget(_key('authorize')).onPressed, isNull); - await _capture(tester, '$output/microsoft-tenant-reset.png'); - await tester.tap(find.text('Cancel')); - await tester.pumpAndSettle(); - await file.writeAsString( - await File('test/fixtures/oauth/desktop_synthetic.json').readAsString(), - ); - picker.selection = XFile(file.path); - for (final preference in [ - BusyMaxThemeModePreference.light, - BusyMaxThemeModePreference.dark, - ]) { - await tester.runAsync(() => settings.setThemeModePreference(preference)); - await tester.pumpAndSettle(); - final mode = preference.name; - for (final (name, methodKey) in [ - ('Google', 'workspace'), - ('Google', 'custom'), - ('Microsoft', 'custom'), + testWidgets( + 'Settings owns desktop registration forms, guides and error states', + (tester) async { + expect(Platform.isLinux, isTrue); + expect(Platform.environment['XDG_DATA_HOME'], '$output/data'); + await tester.runAsync(() => app.main(const [])); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ScheduleWorkspace)), + ); + expect(await container.read(accountsStreamProvider.future), isEmpty); + final settings = container.read(appSettingsControllerProvider.notifier); + await tester.runAsync( + () => settings.setThemeModePreference(BusyMaxThemeModePreference.light), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add account')); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsOneWidget); + final staging = container.read(registrationStagingProvider); + final picker = _Picker(); + final previousPicker = FileSelectorPlatform.instance; + FileSelectorPlatform.instance = picker; + addTearDown(() => FileSelectorPlatform.instance = previousPicker); + final file = File('$output/desktop.json'); + await file.writeAsString( + await File('test/fixtures/oauth/desktop_synthetic.json').readAsString(), + ); + picker.selection = XFile(file.path); + await tester.tap(find.text('Add Google account')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/google-methods.png'); + expect(tester.widget(_key('busymax')).onPressed, isNull); + expect(_key('shared-unavailable'), findsOneWidget); + await _back(tester); + expect(_key('methods-dialog'), findsOneWidget); + expect(find.byType(SettingsScreen), findsOneWidget); + await tester.tap(_key('workspace')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/workspace-empty.png'); + await tester.tap(_key('import')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/workspace-imported.png'); + await tester.tap(_key('guide')); + await tester.pumpAndSettle(); + await _inspectInstructions( + tester, + BusyProvider.google, + DesktopConnectionMethod.googleWorkspace, + '$output/workspace', + ); + await _back(tester); + expect(_key('summary'), findsOneWidget); + await _back(tester); + await tester.tap(_key('custom')); + await tester.pumpAndSettle(); + // Both ownership pages share the same transactional importer and selection. + expect(_key('summary'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add Google account')); + await tester.pumpAndSettle(); + await tester.tap(_key('custom')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/google-empty.png'); + await tester.tap(_key('import')); + await tester.pumpAndSettle(); + expect(_key('summary'), findsOneWidget); + await _capture(tester, '$output/google-imported.png'); + picker.selection = null; + await tester.tap(_key('import')); + await tester.pumpAndSettle(); + expect(_key('summary'), findsOneWidget); + picker.selection = XFile(file.path); + await file.writeAsString('{"web":{}}'); + await tester.tap(_key('import')); + await tester.pumpAndSettle(); + expect(_key('error'), findsOneWidget); + expect(_key('summary'), findsOneWidget); + await _capture(tester, '$output/google-invalid-replacement.png'); + await tester.tap(_key('guide')); + await tester.pumpAndSettle(); + expect(_key('instructions-dialog'), findsOneWidget); + expect(_key('back'), findsOneWidget); + await _capture(tester, '$output/google-guide.png'); + await _inspectInstructions( + tester, + BusyProvider.google, + DesktopConnectionMethod.custom, + '$output/google', + ); + expect(_key('authorize').hitTestable(), findsNothing); + await tester.ensureVisible(_key('copy-all')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/google-guide-scrolled.png'); + await tester.ensureVisible(find.text('Open Google Cloud Console')); + await tester.pumpAndSettle(); + // Native Linux launcher returns an error string; exercise its real exception path. + const channel = BasicMessageChannel( + 'dev.flutter.pigeon.url_launcher_linux.UrlLauncherApi.launchUrl', + StandardMessageCodec(), + ); + tester.binding.defaultBinaryMessenger + .setMockDecodedMessageHandler( + channel, + (_) async => ['test launch failure'], + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, null), + ); + await tester.tap(find.text('Open Google Cloud Console')); + await tester.pumpAndSettle(); + expect(_key('link-error'), findsOneWidget); + await _capture(tester, '$output/google-guide-link-error.png'); + await tester.ensureVisible(_key('back')); + await tester.tap(_key('back')); + await tester.pumpAndSettle(); + expect(_key('instructions-dialog'), findsNothing); + expect(_key('summary'), findsOneWidget); + await _capture(tester, '$output/google-instructions-closed.png'); + staging.cancel(); // Same notification as expiry, without waiting ten minutes. + await tester.pumpAndSettle(); + expect( + tester.widget(_key('authorize')).onPressed, + isNull, + ); + await _capture(tester, '$output/google-expired.png'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add Microsoft account')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-methods.png'); + expect(tester.widget(_key('busymax')).onPressed, isNull); + expect(_key('workspace'), findsNothing); + await _back(tester); + expect(_key('methods-dialog'), findsOneWidget); + expect(find.byType(SettingsScreen), findsOneWidget); + await tester.tap(_key('custom')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-empty.png'); + await tester.enterText(_key('client-id'), 'invalid'); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-invalid.png'); + await tester.enterText( + _key('client-id'), + '11223344-5566-7788-99aa-bbccddeeff00', + ); + await _audience(tester, 3, capture: '$output/microsoft-menu-open.png'); + await tester.enterText( + _key('tenant-id'), + 'aabbccdd-1122-3344-5566-77889900aabb', + ); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-tenant.png'); + await tester.tap(_key('guide')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-guide.png'); + await _inspectInstructions( + tester, + BusyProvider.microsoft, + DesktopConnectionMethod.custom, + '$output/microsoft', + ); + expect(_key('authorize').hitTestable(), findsNothing); + await tester.ensureVisible(_key('copy-all')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-guide-scrolled.png'); + await tester.ensureVisible( + find.text('Open Microsoft Entra admin center'), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open Microsoft Entra admin center')); + await tester.pumpAndSettle(); + await _capture(tester, '$output/microsoft-guide-link-error.png'); + await tester.ensureVisible(_key('back')); + await tester.tap(_key('back')); + await tester.pumpAndSettle(); + expect(_key('instructions-dialog'), findsNothing); + expect( + tester.widget(_key('tenant-id')).controller!.text, + 'aabbccdd-1122-3344-5566-77889900aabb', + ); + await _capture(tester, '$output/microsoft-instructions-closed.png'); + await _audience(tester, 1); + expect(_key('tenant-id'), findsNothing); + expect( + tester.widget(_key('authorize')).onPressed, + isNotNull, + ); + await _capture(tester, '$output/microsoft-organizations.png'); + await _audience(tester, 0); + await _audience(tester, 2); + expect(_key('tenant-id'), findsNothing); + await _audience(tester, 3); + expect( + tester.widget(_key('tenant-id')).controller!.text, + isEmpty, + ); + expect( + tester.widget(_key('authorize')).onPressed, + isNull, + ); + await _capture(tester, '$output/microsoft-tenant-reset.png'); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await file.writeAsString( + await File('test/fixtures/oauth/desktop_synthetic.json').readAsString(), + ); + picker.selection = XFile(file.path); + for (final preference in [ + BusyMaxThemeModePreference.light, + BusyMaxThemeModePreference.dark, ]) { - await tester.tap(find.text('Add $name account')); - await tester.pumpAndSettle(); - final prefix = '$output/${name.toLowerCase()}-$methodKey-$mode'; - await _capture(tester, '$prefix-methods.png'); - await tester.tap(_key(methodKey)); + await tester.runAsync( + () => settings.setThemeModePreference(preference), + ); await tester.pumpAndSettle(); - await _capture(tester, '$prefix-empty.png'); - tester.platformDispatcher.textScaleFactorTestValue = 1.6; + final mode = preference.name; + for (final (name, methodKey) in [ + ('Google', 'workspace'), + ('Google', 'custom'), + ('Microsoft', 'custom'), + ]) { + await tester.tap(find.text('Add $name account')); + await tester.pumpAndSettle(); + final prefix = '$output/${name.toLowerCase()}-$methodKey-$mode'; + await _capture(tester, '$prefix-methods.png'); + await tester.tap(_key(methodKey)); + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-empty.png'); + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-empty-large.png'); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + await tester.pumpAndSettle(); + if (name == 'Google') { + await tester.tap(_key('import')); + } else { + await tester.enterText( + _key('client-id'), + '11223344-5566-7788-99aa-bbccddeeff00', + ); + await _audience(tester, 3, capture: '$prefix-menu.png'); + await tester.enterText( + _key('tenant-id'), + 'aabbccdd-1122-3344-5566-77889900aabb', + ); + } + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-populated.png'); + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-large-text.png'); + if (name == 'Microsoft') { + await _audience(tester, 3, capture: '$prefix-menu-large.png'); + } + await tester.tap(_key('guide')); + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-guide-large.png'); + await tester.ensureVisible(_key('copy-all')); + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-guide-scrolled-large.png'); + await tester.tap(_key('back')); + await tester.pumpAndSettle(); + expect( + _key(name == 'Google' ? 'summary' : 'tenant-id'), + findsOneWidget, + ); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + await tester.pumpAndSettle(); + if (name == 'Microsoft') { + // Traverse from the client entry to the native combo using Tab. + await tester.tap(_key('client-id')); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus!.debugLabel, + 'BusyMax menu trigger', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-menu-keyboard.png'); + await _nativeKeys(tester, [ + 'Home', + 'Down', + 'Down', + 'Down', + 'Return', + ]); + await tester.pumpAndSettle(); + await tester.enterText(_key('tenant-id'), 'invalid'); + await tester.pumpAndSettle(); + await _capture(tester, '$prefix-error.png'); + } + await tester.tap(_key('guide')); + await tester.pumpAndSettle(); + await _back(tester); + await tester.pumpAndSettle(); + expect(_key('instructions-dialog'), findsNothing); + expect( + _key(name == 'Google' ? 'summary' : 'client-id'), + findsOneWidget, + ); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + } + } + await tester.runAsync(() => settings.setLocaleTag('de')); + await tester.pumpAndSettle(); + await _resize(tester, 720, 700); + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + await tester.pumpAndSettle(); + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + final l10n = AppLocalizations.of( + tester.element(find.byType(SettingsScreen)), + ); + final label = provider == BusyProvider.google + ? l10n.addGoogleAccount + : l10n.addMicrosoftAccount; + await tester.ensureVisible(find.text(label)); + await tester.tap(find.text(label)); await tester.pumpAndSettle(); - await _capture(tester, '$prefix-empty-large.png'); - tester.platformDispatcher.clearTextScaleFactorTestValue(); + await _capture( + tester, + '$output/${provider.storageValue}-narrow-german-methods.png', + ); + await tester.ensureVisible(_key('custom')); + await tester.tap(_key('custom')); await tester.pumpAndSettle(); - if (name == 'Google') { + if (provider == BusyProvider.google) { await tester.tap(_key('import')); } else { await tester.enterText( _key('client-id'), '11223344-5566-7788-99aa-bbccddeeff00', ); - await _audience(tester, 3, capture: '$prefix-menu.png'); - await tester.enterText( - _key('tenant-id'), - 'aabbccdd-1122-3344-5566-77889900aabb', - ); - } - await tester.pumpAndSettle(); - await _capture(tester, '$prefix-populated.png'); - tester.platformDispatcher.textScaleFactorTestValue = 1.6; - await tester.pumpAndSettle(); - await _capture(tester, '$prefix-large-text.png'); - if (name == 'Microsoft') { - await _audience(tester, 3, capture: '$prefix-menu-large.png'); } - await tester.tap(_key('guide')); await tester.pumpAndSettle(); - await _capture(tester, '$prefix-guide-large.png'); - await tester.ensureVisible(_key('copy-all')); - await tester.pumpAndSettle(); - await _capture(tester, '$prefix-guide-scrolled-large.png'); - await tester.tap(_key('back')); - await tester.pumpAndSettle(); - expect( - _key(name == 'Google' ? 'summary' : 'tenant-id'), - findsOneWidget, + await _capture( + tester, + '$output/${provider.storageValue}-narrow-german-form.png', ); - tester.platformDispatcher.clearTextScaleFactorTestValue(); - await tester.pumpAndSettle(); - if (name == 'Microsoft') { - // Traverse from the client entry to the native combo using Tab. - await tester.tap(_key('client-id')); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pumpAndSettle(); - expect( - FocusManager.instance.primaryFocus!.debugLabel, - 'BusyMax menu trigger', + if (provider == BusyProvider.microsoft) { + await _audience( + tester, + 0, + capture: '$output/microsoft-narrow-german-menu.png', ); - await tester.sendKeyEvent(LogicalKeyboardKey.space); - await tester.pumpAndSettle(); - await _capture(tester, '$prefix-menu-keyboard.png'); - await _nativeKeys(tester, ['Home', 'Down', 'Down', 'Down', 'Return']); - await tester.pumpAndSettle(); - await tester.enterText(_key('tenant-id'), 'invalid'); - await tester.pumpAndSettle(); - await _capture(tester, '$prefix-error.png'); } + await tester.ensureVisible(_key('guide')); await tester.tap(_key('guide')); await tester.pumpAndSettle(); - await _back(tester); - await tester.pumpAndSettle(); - expect(_key('instructions-dialog'), findsNothing); - expect( - _key(name == 'Google' ? 'summary' : 'client-id'), - findsOneWidget, + await _capture( + tester, + '$output/${provider.storageValue}-narrow-german-guide.png', ); - await tester.tap(find.text('Cancel')); + await tester.ensureVisible(_key('copy-all')); await tester.pumpAndSettle(); - } - } - await tester.runAsync(() => settings.setLocaleTag('de')); - await tester.pumpAndSettle(); - await _resize(tester, 720, 700); - tester.platformDispatcher.textScaleFactorTestValue = 1.6; - await tester.pumpAndSettle(); - for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { - final l10n = AppLocalizations.of( - tester.element(find.byType(SettingsScreen)), - ); - final label = provider == BusyProvider.google - ? l10n.addGoogleAccount - : l10n.addMicrosoftAccount; - await tester.ensureVisible(find.text(label)); - await tester.tap(find.text(label)); - await tester.pumpAndSettle(); - await _capture( - tester, - '$output/${provider.storageValue}-narrow-german-methods.png', - ); - await tester.ensureVisible(_key('custom')); - await tester.tap(_key('custom')); - await tester.pumpAndSettle(); - if (provider == BusyProvider.google) { - await tester.tap(_key('import')); - } else { - await tester.enterText( - _key('client-id'), - '11223344-5566-7788-99aa-bbccddeeff00', - ); - } - await tester.pumpAndSettle(); - await _capture( - tester, - '$output/${provider.storageValue}-narrow-german-form.png', - ); - if (provider == BusyProvider.microsoft) { - await _audience( + await _capture( tester, - 0, - capture: '$output/microsoft-narrow-german-menu.png', + '$output/${provider.storageValue}-narrow-german-guide-scrolled.png', ); + await _back(tester); + expect(_key('guide').hitTestable(), findsOneWidget); + await tester.tap(_key('guide')); + await tester.pumpAndSettle(); + await tester.tap(_key('close')); + await tester.pumpAndSettle(); + expect(find.byType(SettingsScreen), findsOneWidget); + expect(_key('methods-dialog'), findsNothing); + expect(tester.takeException(), isNull); } - await tester.ensureVisible(_key('guide')); - await tester.tap(_key('guide')); - await tester.pumpAndSettle(); - await _capture( - tester, - '$output/${provider.storageValue}-narrow-german-guide.png', - ); - await tester.ensureVisible(_key('copy-all')); - await tester.pumpAndSettle(); - await _capture( - tester, - '$output/${provider.storageValue}-narrow-german-guide-scrolled.png', - ); - await _back(tester); - expect(_key('guide').hitTestable(), findsOneWidget); - await tester.tap(_key('guide')); - await tester.pumpAndSettle(); - await tester.tap(_key('close')); - await tester.pumpAndSettle(); - expect(find.byType(SettingsScreen), findsOneWidget); - expect(_key('methods-dialog'), findsNothing); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + await tester.runAsync(() => settings.setLocaleTag('en')); + expect(await container.read(accountsStreamProvider.future), isEmpty); expect(tester.takeException(), isNull); - } - tester.platformDispatcher.clearTextScaleFactorTestValue(); - await tester.runAsync(() => settings.setLocaleTag('en')); - expect(await container.read(accountsStreamProvider.future), isEmpty); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - }, skip: output == null); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }, + skip: output == null, + ); } Finder _key(String value) => find.byKey(ValueKey('registration-$value')); diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 69bc2c49..3ea67585 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "جهات الاتصال", + "contactsLinkedDescription": "استخدم جهات الاتصال المخزنة مؤقتًا من هذا الحساب عند إضافة مدعوين إلى الأحداث.", + "contactsEnableSuggestions": "تفعيل اقتراحات جهات الاتصال", + "contactsReadOnlyUse": "جهات اتصال للقراءة فقط لاقتراح الحضور", + "contactsReadPermissionDescription": "يطلب إذن قراءة جهات الاتصال فقط", + "contactsSuggestionsEnabled": "تم تفعيل اقتراحات جهات الاتصال", + "contactsNeedsAttention": "تحتاج جهات الاتصال إلى الانتباه", + "contactsDisableForAccount": "تعطيل جهات الاتصال لهذا الحساب", + "contactsOnlyDescription": "هذه المصادر مستقلة عن حسابات التقويم والمهام.", + "contactsAddCardDav": "إضافة جهات اتصال CardDAV", + "contactsAddNextcloud": "إضافة جهات اتصال Nextcloud", + "contactsUseForAttendees": "استخدامها لاقتراحات الحضور", + "contactsServerUrlLabel": "عنوان URL لخادم HTTPS", + "contactsUsernameLabel": "اسم المستخدم", + "contactsPasswordLabel": "كلمة المرور", "launchAtLoginManagedExternally": "تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.", "timeFormat": "تنسيق الوقت", "firstDayOfWeek": "أول أيام الأسبوع", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0f9030c4..c32c2f02 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Kontakte", + "contactsLinkedDescription": "Beim Hinzufügen von Veranstaltungsgästen zwischengespeicherte Kontakte dieses Kontos verwenden.", + "contactsEnableSuggestions": "Kontaktvorschläge aktivieren", + "contactsReadOnlyUse": "Schreibgeschützte Kontakte für Gästervorschläge", + "contactsReadPermissionDescription": "Fordert schreibgeschützten Zugriff auf Kontakte an", + "contactsSuggestionsEnabled": "Kontaktvorschläge aktiviert", + "contactsNeedsAttention": "Kontakte erfordern Aufmerksamkeit", + "contactsDisableForAccount": "Kontakte für dieses Konto deaktivieren", + "contactsOnlyDescription": "Diese Quellen sind von Kalender- und Aufgabenkonten unabhängig.", + "contactsAddCardDav": "CardDAV-Kontakte hinzufügen", + "contactsAddNextcloud": "Nextcloud-Kontakte hinzufügen", + "contactsUseForAttendees": "Für Teilnehmervorschläge verwenden", + "contactsServerUrlLabel": "HTTPS-Server-URL", + "contactsUsernameLabel": "Benutzername", + "contactsPasswordLabel": "Passwort", "launchAtLoginManagedExternally": "BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.", "timeFormat": "Zeitformat", "firstDayOfWeek": "Erster Wochentag", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cd7474d3..bae0f81f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1962,5 +1962,20 @@ "registrationGuideGooglePublishing": "In Audience, select Publish app and confirm In production. Do not leave normal use in Testing: these Calendar/Tasks authorizations and refresh tokens expire after seven days. Publishing and verification are separate. Personal use with fewer than 100 users can be exempt from verification, with unverified-app warnings and a user cap; broader distribution may require brand and scope approval. Production authorizations can still expire or be revoked.", "registrationGuideWorkspacePermissions": "Review the scopes below with your administrator. Internal apps do not need a consent-screen scope listing. If your administrator requests one, open Data Access → Add or remove scopes, add these values, then Update and Save. Administrator controls can still restrict authorization.", "registrationGuideMicrosoftOptionalPermissions": "Shared-calendar and category permissions are requested separately when those optional features are enabled; do not add them to the required setup above.", - "registrationGuideWorkspaceAudience": "Go to Google Auth Platform → Branding. If setup has not started, select Get started. Enter BusyMax as the App name, choose your own User support email and select Next. In Audience, choose Internal. Select Next, enter your email under Contact Information and select Next. Accept the User Data Policy, then select Continue and Create. If already configured, review Branding and Audience.\n\nInternal allows only accounts in the project’s parent organization, subject to administrator controls. No test-user list is needed. BusyMax cannot verify these console settings from the downloaded JSON." + "registrationGuideWorkspaceAudience": "Go to Google Auth Platform → Branding. If setup has not started, select Get started. Enter BusyMax as the App name, choose your own User support email and select Next. In Audience, choose Internal. Select Next, enter your email under Contact Information and select Next. Accept the User Data Policy, then select Continue and Create. If already configured, review Branding and Audience.\n\nInternal allows only accounts in the project’s parent organization, subject to administrator controls. No test-user list is needed. BusyMax cannot verify these console settings from the downloaded JSON.", + "contactsTitle": "Contacts", + "contactsLinkedDescription": "Use cached contacts from this account when adding event guests.", + "contactsEnableSuggestions": "Enable contact suggestions", + "contactsReadOnlyUse": "Read-only contacts for attendee suggestions", + "contactsReadPermissionDescription": "Requests read-only contact access", + "contactsSuggestionsEnabled": "Contact suggestions enabled", + "contactsNeedsAttention": "Contacts need attention", + "contactsDisableForAccount": "Disable contacts for this account", + "contactsOnlyDescription": "These sources are independent of calendar and task accounts.", + "contactsAddCardDav": "Add CardDAV contacts", + "contactsAddNextcloud": "Add Nextcloud contacts", + "contactsUseForAttendees": "Use for attendee suggestions", + "contactsServerUrlLabel": "HTTPS server URL", + "contactsUsernameLabel": "Username", + "contactsPasswordLabel": "Password" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 956e677b..04fc1e29 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Contactos", + "contactsLinkedDescription": "Usa los contactos almacenados de esta cuenta al añadir invitados a eventos.", + "contactsEnableSuggestions": "Activar sugerencias de contactos", + "contactsReadOnlyUse": "Contactos de solo lectura para sugerir asistentes", + "contactsReadPermissionDescription": "Solicita acceso de solo lectura a los contactos", + "contactsSuggestionsEnabled": "Sugerencias de contactos activadas", + "contactsNeedsAttention": "Los contactos requieren atención", + "contactsDisableForAccount": "Desactivar contactos para esta cuenta", + "contactsOnlyDescription": "Estas fuentes son independientes de las cuentas de calendarios y tareas.", + "contactsAddCardDav": "Añadir contactos CardDAV", + "contactsAddNextcloud": "Añadir contactos de Nextcloud", + "contactsUseForAttendees": "Usar para sugerencias de asistentes", + "contactsServerUrlLabel": "URL del servidor HTTPS", + "contactsUsernameLabel": "Nombre de usuario", + "contactsPasswordLabel": "Contraseña", "launchAtLoginManagedExternally": "BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.", "timeFormat": "Formato de hora", "firstDayOfWeek": "Primer día de la semana", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index bc4098ad..bf827c88 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Kontaktid", + "contactsLinkedDescription": "Kasuta sündmuse külaliste lisamisel selle konto vahemällu salvestatud kontakte.", + "contactsEnableSuggestions": "Luba kontaktisoovitused", + "contactsReadOnlyUse": "Kirjutuskaitstud kontaktid osalejate soovitamiseks", + "contactsReadPermissionDescription": "Taotleb kontaktidele kirjutuskaitstud juurdepääsu", + "contactsSuggestionsEnabled": "Kontaktisoovitused on lubatud", + "contactsNeedsAttention": "Kontaktid vajavad tähelepanu", + "contactsDisableForAccount": "Keela selle konto kontaktid", + "contactsOnlyDescription": "Need allikad on kalendri- ja ülesandekontodest sõltumatud.", + "contactsAddCardDav": "Lisa CardDAV-i kontaktid", + "contactsAddNextcloud": "Lisa Nextcloudi kontaktid", + "contactsUseForAttendees": "Kasuta osalejate soovitamiseks", + "contactsServerUrlLabel": "HTTPS-serveri URL", + "contactsUsernameLabel": "Kasutajanimi", + "contactsPasswordLabel": "Parool", "launchAtLoginManagedExternally": "BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.", "timeFormat": "Kellaajavorming", "firstDayOfWeek": "Nädala esimene päev", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 23b58e08..4e9b7e12 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "مخاطبان", + "contactsLinkedDescription": "هنگام افزودن مهمانان رویداد از مخاطبان ذخیره‌شدهٔ این حساب استفاده شود.", + "contactsEnableSuggestions": "فعال‌کردن پیشنهادهای مخاطبان", + "contactsReadOnlyUse": "مخاطبین فقط خواندنی برای پیشنهاد شرکت‌کنندگان", + "contactsReadPermissionDescription": "درخواست دسترسی فقط‌خواندنی به مخاطبان", + "contactsSuggestionsEnabled": "پیشنهادهای مخاطبان فعال است", + "contactsNeedsAttention": "مخاطبان نیاز به توجه دارند", + "contactsDisableForAccount": "غیرفعال‌کردن مخاطبان برای این حساب", + "contactsOnlyDescription": "این منابع از حساب‌های تقویم و وظایف مستقل هستند.", + "contactsAddCardDav": "افزودن مخاطبان CardDAV", + "contactsAddNextcloud": "افزودن مخاطبان Nextcloud", + "contactsUseForAttendees": "استفاده برای پیشنهاد شرکت‌کنندگان", + "contactsServerUrlLabel": "نشانی سرور HTTPS", + "contactsUsernameLabel": "نام کاربری", + "contactsPasswordLabel": "گذرواژه", "launchAtLoginManagedExternally": "BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.", "timeFormat": "قالب زمان", "firstDayOfWeek": "روز اول هفته", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 5264ba32..64edb880 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Yhteystiedot", + "contactsLinkedDescription": "Käytä tämän tilin välimuistissa olevia yhteystietoja tapahtuman vieraita lisättäessä.", + "contactsEnableSuggestions": "Ota yhteystietoehdotukset käyttöön", + "contactsReadOnlyUse": "Vain luku -yhteystiedot osallistujaehdotuksiin", + "contactsReadPermissionDescription": "Pyytää yhteystietoihin vain luku -oikeuden", + "contactsSuggestionsEnabled": "Yhteystietoehdotukset käytössä", + "contactsNeedsAttention": "Yhteystiedot vaativat huomiota", + "contactsDisableForAccount": "Poista tämän tilin yhteystiedot käytöstä", + "contactsOnlyDescription": "Nämä lähteet ovat erillisiä kalenteri- ja tehtävätileistä.", + "contactsAddCardDav": "Lisää CardDAV-yhteystiedot", + "contactsAddNextcloud": "Lisää Nextcloud-yhteystiedot", + "contactsUseForAttendees": "Käytä osallistujaehdotuksiin", + "contactsServerUrlLabel": "HTTPS-palvelimen URL", + "contactsUsernameLabel": "Käyttäjänimi", + "contactsPasswordLabel": "Salasana", "launchAtLoginManagedExternally": "BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.", "timeFormat": "Aikamuoto", "firstDayOfWeek": "Viikon ensimmäinen päivä", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c2657cd0..97ecd228 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Carnet d’adresses", + "contactsLinkedDescription": "Utiliser les contacts en cache de ce compte lors de l’ajout d’invités aux événements.", + "contactsEnableSuggestions": "Activer les suggestions de contacts", + "contactsReadOnlyUse": "Contacts en lecture seule pour suggérer des invités", + "contactsReadPermissionDescription": "Demande un accès en lecture seule aux contacts", + "contactsSuggestionsEnabled": "Suggestions de contacts activées", + "contactsNeedsAttention": "Les contacts nécessitent votre attention", + "contactsDisableForAccount": "Désactiver les contacts pour ce compte", + "contactsOnlyDescription": "Ces sources sont indépendantes des comptes de calendrier et de tâches.", + "contactsAddCardDav": "Ajouter des contacts CardDAV", + "contactsAddNextcloud": "Ajouter des contacts Nextcloud", + "contactsUseForAttendees": "Utiliser pour les suggestions de participants", + "contactsServerUrlLabel": "URL du serveur HTTPS", + "contactsUsernameLabel": "Nom d’utilisateur", + "contactsPasswordLabel": "Mot de passe", "launchAtLoginManagedExternally": "BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.", "timeFormat": "Format de l’heure", "firstDayOfWeek": "Premier jour de la semaine", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6a00fcd0..e14aa8c6 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "संपर्क", + "contactsLinkedDescription": "इवेंट में अतिथि जोड़ते समय इस खाते के कैश किए गए संपर्कों का उपयोग करें।", + "contactsEnableSuggestions": "संपर्क सुझाव चालू करें", + "contactsReadOnlyUse": "सहभागियों के सुझावों के लिए केवल पढ़ने योग्य संपर्क", + "contactsReadPermissionDescription": "संपर्कों के लिए केवल पढ़ने की अनुमति माँगता है", + "contactsSuggestionsEnabled": "संपर्क सुझाव चालू हैं", + "contactsNeedsAttention": "संपर्कों पर ध्यान देना आवश्यक है", + "contactsDisableForAccount": "इस खाते के संपर्क बंद करें", + "contactsOnlyDescription": "ये स्रोत कैलेंडर और कार्य खातों से स्वतंत्र हैं।", + "contactsAddCardDav": "CardDAV संपर्क जोड़ें", + "contactsAddNextcloud": "Nextcloud संपर्क जोड़ें", + "contactsUseForAttendees": "उपस्थित व्यक्ति के सुझावों के लिए उपयोग करें", + "contactsServerUrlLabel": "HTTPS सर्वर URL", + "contactsUsernameLabel": "उपयोगकर्ता नाम", + "contactsPasswordLabel": "पासवर्ड", "launchAtLoginManagedExternally": "BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।", "timeFormat": "समय प्रारूप", "firstDayOfWeek": "सप्ताह का पहला दिन", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ecb33cba..a3d8d497 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Contatti", + "contactsLinkedDescription": "Usa i contatti memorizzati di questo account quando aggiungi invitati agli eventi.", + "contactsEnableSuggestions": "Attiva i suggerimenti dei contatti", + "contactsReadOnlyUse": "Contatti in sola lettura per suggerire partecipanti", + "contactsReadPermissionDescription": "Richiede l’accesso ai contatti in sola lettura", + "contactsSuggestionsEnabled": "Suggerimenti dei contatti attivati", + "contactsNeedsAttention": "I contatti richiedono attenzione", + "contactsDisableForAccount": "Disattiva i contatti per questo account", + "contactsOnlyDescription": "Queste fonti sono indipendenti dagli account di calendario e attività.", + "contactsAddCardDav": "Aggiungi contatti CardDAV", + "contactsAddNextcloud": "Aggiungi contatti Nextcloud", + "contactsUseForAttendees": "Usa per i suggerimenti dei partecipanti", + "contactsServerUrlLabel": "URL del server HTTPS", + "contactsUsernameLabel": "Nome utente", + "contactsPasswordLabel": "Parola d’accesso", "launchAtLoginManagedExternally": "BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.", "timeFormat": "Formato ora", "firstDayOfWeek": "Primo giorno della settimana", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 56f1bcc3..423eed24 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "連絡先", + "contactsLinkedDescription": "予定のゲストを追加するときに、このアカウントのキャッシュ済み連絡先を使用します。", + "contactsEnableSuggestions": "連絡先の候補を有効にする", + "contactsReadOnlyUse": "参加者候補のための読み取り専用連絡先", + "contactsReadPermissionDescription": "連絡先への読み取り専用アクセスを要求します", + "contactsSuggestionsEnabled": "連絡先の候補が有効です", + "contactsNeedsAttention": "連絡先の確認が必要です", + "contactsDisableForAccount": "このアカウントの連絡先を無効にする", + "contactsOnlyDescription": "これらのソースはカレンダーおよびタスクのアカウントから独立しています。", + "contactsAddCardDav": "CardDAV の連絡先を追加", + "contactsAddNextcloud": "Nextcloud の連絡先を追加", + "contactsUseForAttendees": "出席者の候補に使用", + "contactsServerUrlLabel": "HTTPS サーバーの URL", + "contactsUsernameLabel": "ユーザー名", + "contactsPasswordLabel": "パスワード", "launchAtLoginManagedExternally": "BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。", "timeFormat": "時刻の表示形式", "firstDayOfWeek": "週の最初の曜日", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 437a36a3..8547247c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "연락처", + "contactsLinkedDescription": "이벤트 참석자를 추가할 때 이 계정에 캐시된 연락처를 사용합니다.", + "contactsEnableSuggestions": "연락처 추천 사용", + "contactsReadOnlyUse": "참석자 추천을 위한 읽기 전용 연락처", + "contactsReadPermissionDescription": "연락처 읽기 전용 권한을 요청합니다", + "contactsSuggestionsEnabled": "연락처 추천을 사용 중입니다", + "contactsNeedsAttention": "연락처를 확인해야 합니다", + "contactsDisableForAccount": "이 계정의 연락처 사용 중지", + "contactsOnlyDescription": "이 원본은 캘린더 및 작업 계정과 별개입니다.", + "contactsAddCardDav": "CardDAV 연락처 추가", + "contactsAddNextcloud": "Nextcloud 연락처 추가", + "contactsUseForAttendees": "참석자 추천에 사용", + "contactsServerUrlLabel": "HTTPS 서버 URL", + "contactsUsernameLabel": "사용자 이름", + "contactsPasswordLabel": "비밀번호", "launchAtLoginManagedExternally": "데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.", "timeFormat": "시간 형식", "firstDayOfWeek": "한 주의 시작 요일", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 22e0caab..191e8e41 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Kontakty", + "contactsLinkedDescription": "Używaj kontaktów z pamięci podręcznej tego konta podczas dodawania gości wydarzenia.", + "contactsEnableSuggestions": "Włącz sugestie kontaktów", + "contactsReadOnlyUse": "Kontakty tylko do odczytu na potrzeby sugestii uczestników", + "contactsReadPermissionDescription": "Prosi o dostęp do kontaktów tylko do odczytu", + "contactsSuggestionsEnabled": "Sugestie kontaktów są włączone", + "contactsNeedsAttention": "Kontakty wymagają uwagi", + "contactsDisableForAccount": "Wyłącz kontakty dla tego konta", + "contactsOnlyDescription": "Te źródła są niezależne od kont kalendarza i zadań.", + "contactsAddCardDav": "Dodaj kontakty CardDAV", + "contactsAddNextcloud": "Dodaj kontakty Nextcloud", + "contactsUseForAttendees": "Używaj do sugerowania uczestników", + "contactsServerUrlLabel": "Adres URL serwera HTTPS", + "contactsUsernameLabel": "Nazwa użytkownika", + "contactsPasswordLabel": "Hasło", "launchAtLoginManagedExternally": "BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.", "timeFormat": "Format czasu", "firstDayOfWeek": "Pierwszy dzień tygodnia", diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index a2d032e1..15c14946 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Contactos", + "contactsLinkedDescription": "Utilize os contactos em cache desta conta ao adicionar convidados a eventos.", + "contactsEnableSuggestions": "Ativar sugestões de contactos", + "contactsReadOnlyUse": "Contactos só de leitura para sugestões de participantes", + "contactsReadPermissionDescription": "Pede acesso só de leitura aos contactos", + "contactsSuggestionsEnabled": "Sugestões de contactos ativadas", + "contactsNeedsAttention": "Os contactos precisam de atenção", + "contactsDisableForAccount": "Desativar contactos para esta conta", + "contactsOnlyDescription": "Estas fontes são independentes das contas de calendário e tarefas.", + "contactsAddCardDav": "Adicionar contactos CardDAV", + "contactsAddNextcloud": "Adicionar contactos Nextcloud", + "contactsUseForAttendees": "Utilizar para sugestões de participantes", + "contactsServerUrlLabel": "URL do servidor HTTPS", + "contactsUsernameLabel": "Nome de utilizador", + "contactsPasswordLabel": "Palavra-passe", "launchAtLoginManagedExternally": "O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.", "timeFormat": "Formato da hora", "firstDayOfWeek": "Primeiro dia da semana", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fd78d870..f35edf91 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Контакты", + "contactsLinkedDescription": "Использовать кэшированные контакты этой учётной записи при добавлении гостей события.", + "contactsEnableSuggestions": "Включить предложения контактов", + "contactsReadOnlyUse": "Контакты только для чтения для предложений участников", + "contactsReadPermissionDescription": "Запрашивает доступ к контактам только для чтения", + "contactsSuggestionsEnabled": "Предложения контактов включены", + "contactsNeedsAttention": "Контакты требуют внимания", + "contactsDisableForAccount": "Отключить контакты для этой учётной записи", + "contactsOnlyDescription": "Эти источники не зависят от учётных записей календаря и задач.", + "contactsAddCardDav": "Добавить контакты CardDAV", + "contactsAddNextcloud": "Добавить контакты Nextcloud", + "contactsUseForAttendees": "Использовать для предложений участников", + "contactsServerUrlLabel": "URL-адрес сервера HTTPS", + "contactsUsernameLabel": "Имя пользователя", + "contactsPasswordLabel": "Пароль", "launchAtLoginManagedExternally": "BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.", "timeFormat": "Формат времени", "firstDayOfWeek": "Первый день недели", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index c59dfa9c..b908ff2f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "Danh bạ", + "contactsLinkedDescription": "Dùng các liên hệ đã lưu đệm của tài khoản này khi thêm khách vào sự kiện.", + "contactsEnableSuggestions": "Bật gợi ý liên hệ", + "contactsReadOnlyUse": "Danh bạ chỉ đọc để gợi ý người tham dự", + "contactsReadPermissionDescription": "Yêu cầu quyền chỉ đọc danh bạ", + "contactsSuggestionsEnabled": "Đã bật gợi ý liên hệ", + "contactsNeedsAttention": "Danh bạ cần được chú ý", + "contactsDisableForAccount": "Tắt danh bạ cho tài khoản này", + "contactsOnlyDescription": "Các nguồn này độc lập với tài khoản lịch và công việc.", + "contactsAddCardDav": "Thêm danh bạ CardDAV", + "contactsAddNextcloud": "Thêm danh bạ Nextcloud", + "contactsUseForAttendees": "Dùng để gợi ý người tham dự", + "contactsServerUrlLabel": "URL máy chủ HTTPS", + "contactsUsernameLabel": "Tên người dùng", + "contactsPasswordLabel": "Mật khẩu", "launchAtLoginManagedExternally": "BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.", "timeFormat": "Định dạng giờ", "firstDayOfWeek": "Ngày đầu tuần", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0009c9db..0ed139c9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "联系人", + "contactsLinkedDescription": "添加日程参与者时使用此账号缓存的联系人。", + "contactsEnableSuggestions": "启用联系人建议", + "contactsReadOnlyUse": "用于与会者建议的只读联系人", + "contactsReadPermissionDescription": "请求联系人只读权限", + "contactsSuggestionsEnabled": "联系人建议已启用", + "contactsNeedsAttention": "联系人需要处理", + "contactsDisableForAccount": "停用此账号的联系人", + "contactsOnlyDescription": "这些来源独立于日历和任务账号。", + "contactsAddCardDav": "添加 CardDAV 联系人", + "contactsAddNextcloud": "添加 Nextcloud 联系人", + "contactsUseForAttendees": "用于参与者建议", + "contactsServerUrlLabel": "HTTPS 服务器网址", + "contactsUsernameLabel": "用户名", + "contactsPasswordLabel": "密码", "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", "firstDayOfWeek": "一周的第一天", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index d111b5b5..abcc8f16 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "联系人", + "contactsLinkedDescription": "添加日程参与者时使用此账号缓存的联系人。", + "contactsEnableSuggestions": "启用联系人建议", + "contactsReadOnlyUse": "用于与会者建议的只读联系人", + "contactsReadPermissionDescription": "请求联系人只读权限", + "contactsSuggestionsEnabled": "联系人建议已启用", + "contactsNeedsAttention": "联系人需要处理", + "contactsDisableForAccount": "停用此账号的联系人", + "contactsOnlyDescription": "这些来源独立于日历和任务账号。", + "contactsAddCardDav": "添加 CardDAV 联系人", + "contactsAddNextcloud": "添加 Nextcloud 联系人", + "contactsUseForAttendees": "用于参与者建议", + "contactsServerUrlLabel": "HTTPS 服务器网址", + "contactsUsernameLabel": "用户名", + "contactsPasswordLabel": "密码", "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", "firstDayOfWeek": "一周的第一天", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 1101d129..23c7179c 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,19 @@ { + "contactsTitle": "聯絡人", + "contactsLinkedDescription": "新增活動來賓時使用此帳號快取的聯絡人。", + "contactsEnableSuggestions": "啟用聯絡人建議", + "contactsReadOnlyUse": "用於與會者建議的唯讀聯絡人", + "contactsReadPermissionDescription": "要求聯絡人的唯讀權限", + "contactsSuggestionsEnabled": "已啟用聯絡人建議", + "contactsNeedsAttention": "聯絡人需要處理", + "contactsDisableForAccount": "停用此帳號的聯絡人", + "contactsOnlyDescription": "這些來源獨立於行事曆和工作帳號。", + "contactsAddCardDav": "新增 CardDAV 聯絡人", + "contactsAddNextcloud": "新增 Nextcloud 聯絡人", + "contactsUseForAttendees": "用於參與者建議", + "contactsServerUrlLabel": "HTTPS 伺服器網址", + "contactsUsernameLabel": "使用者名稱", + "contactsPasswordLabel": "密碼", "launchAtLoginManagedExternally": "BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。", "timeFormat": "時間格式", "firstDayOfWeek": "一週的第一天", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 29a079fc..2c9c3ab7 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5903,6 +5903,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Go to Google Auth Platform → Branding. If setup has not started, select Get started. Enter BusyMax as the App name, choose your own User support email and select Next. In Audience, choose Internal. Select Next, enter your email under Contact Information and select Next. Accept the User Data Policy, then select Continue and Create. If already configured, review Branding and Audience.\n\nInternal allows only accounts in the project’s parent organization, subject to administrator controls. No test-user list is needed. BusyMax cannot verify these console settings from the downloaded JSON.'** String get registrationGuideWorkspaceAudience; + + /// No description provided for @contactsTitle. + /// + /// In en, this message translates to: + /// **'Contacts'** + String get contactsTitle; + + /// No description provided for @contactsLinkedDescription. + /// + /// In en, this message translates to: + /// **'Use cached contacts from this account when adding event guests.'** + String get contactsLinkedDescription; + + /// No description provided for @contactsEnableSuggestions. + /// + /// In en, this message translates to: + /// **'Enable contact suggestions'** + String get contactsEnableSuggestions; + + /// No description provided for @contactsReadOnlyUse. + /// + /// In en, this message translates to: + /// **'Read-only contacts for attendee suggestions'** + String get contactsReadOnlyUse; + + /// No description provided for @contactsReadPermissionDescription. + /// + /// In en, this message translates to: + /// **'Requests read-only contact access'** + String get contactsReadPermissionDescription; + + /// No description provided for @contactsSuggestionsEnabled. + /// + /// In en, this message translates to: + /// **'Contact suggestions enabled'** + String get contactsSuggestionsEnabled; + + /// No description provided for @contactsNeedsAttention. + /// + /// In en, this message translates to: + /// **'Contacts need attention'** + String get contactsNeedsAttention; + + /// No description provided for @contactsDisableForAccount. + /// + /// In en, this message translates to: + /// **'Disable contacts for this account'** + String get contactsDisableForAccount; + + /// No description provided for @contactsOnlyDescription. + /// + /// In en, this message translates to: + /// **'These sources are independent of calendar and task accounts.'** + String get contactsOnlyDescription; + + /// No description provided for @contactsAddCardDav. + /// + /// In en, this message translates to: + /// **'Add CardDAV contacts'** + String get contactsAddCardDav; + + /// No description provided for @contactsAddNextcloud. + /// + /// In en, this message translates to: + /// **'Add Nextcloud contacts'** + String get contactsAddNextcloud; + + /// No description provided for @contactsUseForAttendees. + /// + /// In en, this message translates to: + /// **'Use for attendee suggestions'** + String get contactsUseForAttendees; + + /// No description provided for @contactsServerUrlLabel. + /// + /// In en, this message translates to: + /// **'HTTPS server URL'** + String get contactsServerUrlLabel; + + /// No description provided for @contactsUsernameLabel. + /// + /// In en, this message translates to: + /// **'Username'** + String get contactsUsernameLabel; + + /// No description provided for @contactsPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password'** + String get contactsPasswordLabel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 11807de8..4fde87e0 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3571,4 +3572,52 @@ class AppLocalizationsAr extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'انتقل إلى Google Auth Platform → Branding. إن لم يبدأ الإعداد، اختر Get started. أدخل BusyMax في App name واختر بريدك في User support email ثم Next. في Audience، اختر Internal. اختر Next وأدخل بريدك ضمن Contact Information ثم Next. وافق على User Data Policy ثم اختر Continue وCreate. إذا كان الإعداد موجودًا، راجع Branding وAudience.\n\nيسمح Internal فقط بحسابات المؤسسة الأم للمشروع، مع الخضوع لقيود المسؤول. لا حاجة لقائمة مستخدمي اختبار. لا يستطيع BusyMax التحقق من إعدادات وحدة التحكم عبر ملف JSON.'; + + @override + String get contactsTitle => 'جهات الاتصال'; + + @override + String get contactsLinkedDescription => + 'استخدم جهات الاتصال المخزنة مؤقتًا من هذا الحساب عند إضافة مدعوين إلى الأحداث.'; + + @override + String get contactsEnableSuggestions => 'تفعيل اقتراحات جهات الاتصال'; + + @override + String get contactsReadOnlyUse => 'جهات اتصال للقراءة فقط لاقتراح الحضور'; + + @override + String get contactsReadPermissionDescription => + 'يطلب إذن قراءة جهات الاتصال فقط'; + + @override + String get contactsSuggestionsEnabled => 'تم تفعيل اقتراحات جهات الاتصال'; + + @override + String get contactsNeedsAttention => 'تحتاج جهات الاتصال إلى الانتباه'; + + @override + String get contactsDisableForAccount => 'تعطيل جهات الاتصال لهذا الحساب'; + + @override + String get contactsOnlyDescription => + 'هذه المصادر مستقلة عن حسابات التقويم والمهام.'; + + @override + String get contactsAddCardDav => 'إضافة جهات اتصال CardDAV'; + + @override + String get contactsAddNextcloud => 'إضافة جهات اتصال Nextcloud'; + + @override + String get contactsUseForAttendees => 'استخدامها لاقتراحات الحضور'; + + @override + String get contactsServerUrlLabel => 'عنوان URL لخادم HTTPS'; + + @override + String get contactsUsernameLabel => 'اسم المستخدم'; + + @override + String get contactsPasswordLabel => 'كلمة المرور'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 71f6f565..c3572e6e 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3519,4 +3520,54 @@ class AppLocalizationsDe extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Öffnen Sie Google Auth Platform → Branding. Wählen Sie bei einer neuen Einrichtung Get started. Geben Sie BusyMax als App name ein, wählen Sie Ihre eigene User support email und anschließend Next. Wählen Sie unter Audience Internal. Wählen Sie Next, geben Sie unter Contact Information Ihre E-Mail-Adresse ein und wählen Sie Next. Akzeptieren Sie die User Data Policy und wählen Sie Continue und Create. Prüfen Sie bei einer vorhandenen Einrichtung Branding und Audience.\n\nInternal erlaubt nur Konten der übergeordneten Projektorganisation und unterliegt Administratorkontrollen. Eine Testnutzerliste ist nicht nötig. BusyMax kann diese Konsoleneinstellungen nicht anhand der JSON prüfen.'; + + @override + String get contactsTitle => 'Kontakte'; + + @override + String get contactsLinkedDescription => + 'Beim Hinzufügen von Veranstaltungsgästen zwischengespeicherte Kontakte dieses Kontos verwenden.'; + + @override + String get contactsEnableSuggestions => 'Kontaktvorschläge aktivieren'; + + @override + String get contactsReadOnlyUse => + 'Schreibgeschützte Kontakte für Gästervorschläge'; + + @override + String get contactsReadPermissionDescription => + 'Fordert schreibgeschützten Zugriff auf Kontakte an'; + + @override + String get contactsSuggestionsEnabled => 'Kontaktvorschläge aktiviert'; + + @override + String get contactsNeedsAttention => 'Kontakte erfordern Aufmerksamkeit'; + + @override + String get contactsDisableForAccount => + 'Kontakte für dieses Konto deaktivieren'; + + @override + String get contactsOnlyDescription => + 'Diese Quellen sind von Kalender- und Aufgabenkonten unabhängig.'; + + @override + String get contactsAddCardDav => 'CardDAV-Kontakte hinzufügen'; + + @override + String get contactsAddNextcloud => 'Nextcloud-Kontakte hinzufügen'; + + @override + String get contactsUseForAttendees => 'Für Teilnehmervorschläge verwenden'; + + @override + String get contactsServerUrlLabel => 'HTTPS-Server-URL'; + + @override + String get contactsUsernameLabel => 'Benutzername'; + + @override + String get contactsPasswordLabel => 'Passwort'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 3bb38d61..e052357c 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3542,4 +3543,53 @@ class AppLocalizationsEn extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Go to Google Auth Platform → Branding. If setup has not started, select Get started. Enter BusyMax as the App name, choose your own User support email and select Next. In Audience, choose Internal. Select Next, enter your email under Contact Information and select Next. Accept the User Data Policy, then select Continue and Create. If already configured, review Branding and Audience.\n\nInternal allows only accounts in the project’s parent organization, subject to administrator controls. No test-user list is needed. BusyMax cannot verify these console settings from the downloaded JSON.'; + + @override + String get contactsTitle => 'Contacts'; + + @override + String get contactsLinkedDescription => + 'Use cached contacts from this account when adding event guests.'; + + @override + String get contactsEnableSuggestions => 'Enable contact suggestions'; + + @override + String get contactsReadOnlyUse => + 'Read-only contacts for attendee suggestions'; + + @override + String get contactsReadPermissionDescription => + 'Requests read-only contact access'; + + @override + String get contactsSuggestionsEnabled => 'Contact suggestions enabled'; + + @override + String get contactsNeedsAttention => 'Contacts need attention'; + + @override + String get contactsDisableForAccount => 'Disable contacts for this account'; + + @override + String get contactsOnlyDescription => + 'These sources are independent of calendar and task accounts.'; + + @override + String get contactsAddCardDav => 'Add CardDAV contacts'; + + @override + String get contactsAddNextcloud => 'Add Nextcloud contacts'; + + @override + String get contactsUseForAttendees => 'Use for attendee suggestions'; + + @override + String get contactsServerUrlLabel => 'HTTPS server URL'; + + @override + String get contactsUsernameLabel => 'Username'; + + @override + String get contactsPasswordLabel => 'Password'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index c1c69281..3c04f147 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3526,4 +3527,54 @@ class AppLocalizationsEs extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Ve a Google Auth Platform → Branding. Si aún no has iniciado la configuración, selecciona Get started. Introduce BusyMax en App name, elige tu propio User support email y selecciona Next. En Audience, elige Internal. Selecciona Next, introduce tu correo en Contact Information y selecciona Next. Acepta User Data Policy y selecciona Continue y Create. Si ya está configurado, revisa Branding y Audience.\n\nInternal solo admite cuentas de la organización matriz del proyecto, sujetas a los controles del administrador. No necesita lista de usuarios de prueba. BusyMax no puede verificar estos ajustes de la consola a partir del JSON.'; + + @override + String get contactsTitle => 'Contactos'; + + @override + String get contactsLinkedDescription => + 'Usa los contactos almacenados de esta cuenta al añadir invitados a eventos.'; + + @override + String get contactsEnableSuggestions => 'Activar sugerencias de contactos'; + + @override + String get contactsReadOnlyUse => + 'Contactos de solo lectura para sugerir asistentes'; + + @override + String get contactsReadPermissionDescription => + 'Solicita acceso de solo lectura a los contactos'; + + @override + String get contactsSuggestionsEnabled => 'Sugerencias de contactos activadas'; + + @override + String get contactsNeedsAttention => 'Los contactos requieren atención'; + + @override + String get contactsDisableForAccount => + 'Desactivar contactos para esta cuenta'; + + @override + String get contactsOnlyDescription => + 'Estas fuentes son independientes de las cuentas de calendarios y tareas.'; + + @override + String get contactsAddCardDav => 'Añadir contactos CardDAV'; + + @override + String get contactsAddNextcloud => 'Añadir contactos de Nextcloud'; + + @override + String get contactsUseForAttendees => 'Usar para sugerencias de asistentes'; + + @override + String get contactsServerUrlLabel => 'URL del servidor HTTPS'; + + @override + String get contactsUsernameLabel => 'Nombre de usuario'; + + @override + String get contactsPasswordLabel => 'Contraseña'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 48f2b7a4..a1096e94 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3494,4 +3495,53 @@ class AppLocalizationsEt extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Ava Google Auth Platform → Branding. Kui seadistust pole alustatud, vali Get started. Sisesta väljale App name BusyMax, vali oma User support email ja vali Next. Vali jaotises Audience Internal. Vali Next, sisesta oma e-post jaotises Contact Information ning vali Next. Nõustu User Data Policyga ja vali Continue ning Create. Olemasoleva seadistuse puhul vaata üle Branding ja Audience.\n\nInternal lubab vaid projekti emaorganisatsiooni kontosid administraatori piirangute kohaselt. Testkasutajate loendit pole vaja. BusyMax ei saa neid konsoolisätteid JSON-faili põhjal kontrollida.'; + + @override + String get contactsTitle => 'Kontaktid'; + + @override + String get contactsLinkedDescription => + 'Kasuta sündmuse külaliste lisamisel selle konto vahemällu salvestatud kontakte.'; + + @override + String get contactsEnableSuggestions => 'Luba kontaktisoovitused'; + + @override + String get contactsReadOnlyUse => + 'Kirjutuskaitstud kontaktid osalejate soovitamiseks'; + + @override + String get contactsReadPermissionDescription => + 'Taotleb kontaktidele kirjutuskaitstud juurdepääsu'; + + @override + String get contactsSuggestionsEnabled => 'Kontaktisoovitused on lubatud'; + + @override + String get contactsNeedsAttention => 'Kontaktid vajavad tähelepanu'; + + @override + String get contactsDisableForAccount => 'Keela selle konto kontaktid'; + + @override + String get contactsOnlyDescription => + 'Need allikad on kalendri- ja ülesandekontodest sõltumatud.'; + + @override + String get contactsAddCardDav => 'Lisa CardDAV-i kontaktid'; + + @override + String get contactsAddNextcloud => 'Lisa Nextcloudi kontaktid'; + + @override + String get contactsUseForAttendees => 'Kasuta osalejate soovitamiseks'; + + @override + String get contactsServerUrlLabel => 'HTTPS-serveri URL'; + + @override + String get contactsUsernameLabel => 'Kasutajanimi'; + + @override + String get contactsPasswordLabel => 'Parool'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 2a348504..96be11d2 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3549,4 +3550,53 @@ class AppLocalizationsFa extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'به Google Auth Platform → Branding بروید. اگر راه‌اندازی شروع نشده، Get started را بزنید. در App name مقدار BusyMax را وارد کنید، User support email خود را انتخاب کنید و Next را بزنید. در Audience گزینهٔ Internal را انتخاب کنید. Next را بزنید، ایمیل خود را در Contact Information وارد کنید و Next را بزنید. User Data Policy را بپذیرید و Continue و Create را بزنید. اگر قبلاً تنظیم شده، Branding و Audience را بررسی کنید.\n\nInternal فقط حساب‌های سازمان مادر پروژه را با محدودیت‌های مدیر می‌پذیرد. فهرست کاربران آزمایشی لازم نیست. BusyMax نمی‌تواند این تنظیمات کنسول را از JSON دانلودشده تأیید کند.'; + + @override + String get contactsTitle => 'مخاطبان'; + + @override + String get contactsLinkedDescription => + 'هنگام افزودن مهمانان رویداد از مخاطبان ذخیره‌شدهٔ این حساب استفاده شود.'; + + @override + String get contactsEnableSuggestions => 'فعال‌کردن پیشنهادهای مخاطبان'; + + @override + String get contactsReadOnlyUse => + 'مخاطبین فقط خواندنی برای پیشنهاد شرکت‌کنندگان'; + + @override + String get contactsReadPermissionDescription => + 'درخواست دسترسی فقط‌خواندنی به مخاطبان'; + + @override + String get contactsSuggestionsEnabled => 'پیشنهادهای مخاطبان فعال است'; + + @override + String get contactsNeedsAttention => 'مخاطبان نیاز به توجه دارند'; + + @override + String get contactsDisableForAccount => 'غیرفعال‌کردن مخاطبان برای این حساب'; + + @override + String get contactsOnlyDescription => + 'این منابع از حساب‌های تقویم و وظایف مستقل هستند.'; + + @override + String get contactsAddCardDav => 'افزودن مخاطبان CardDAV'; + + @override + String get contactsAddNextcloud => 'افزودن مخاطبان Nextcloud'; + + @override + String get contactsUseForAttendees => 'استفاده برای پیشنهاد شرکت‌کنندگان'; + + @override + String get contactsServerUrlLabel => 'نشانی سرور HTTPS'; + + @override + String get contactsUsernameLabel => 'نام کاربری'; + + @override + String get contactsPasswordLabel => 'گذرواژه'; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 4f00e92b..17c79ce8 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3544,4 +3545,54 @@ class AppLocalizationsFi extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Avaa Google Auth Platform → Branding. Aloita uusi määritys valitsemalla Get started. Anna App name -kenttään BusyMax, valitse oma User support email ja valitse Next. Valitse Audience-kohdassa Internal. Valitse Next, anna sähköpostiosoitteesi Contact Information -kohdassa ja valitse Next. Hyväksy User Data Policy ja valitse Continue ja Create. Tarkista aiemmassa määrityksessä Branding ja Audience.\n\nInternal sallii vain projektin emo-organisaation tilit ylläpitäjän rajoitusten mukaisesti. Testikäyttäjäluetteloa ei tarvita. BusyMax ei voi vahvistaa näitä konsoliasetuksia JSON-tiedostosta.'; + + @override + String get contactsTitle => 'Yhteystiedot'; + + @override + String get contactsLinkedDescription => + 'Käytä tämän tilin välimuistissa olevia yhteystietoja tapahtuman vieraita lisättäessä.'; + + @override + String get contactsEnableSuggestions => 'Ota yhteystietoehdotukset käyttöön'; + + @override + String get contactsReadOnlyUse => + 'Vain luku -yhteystiedot osallistujaehdotuksiin'; + + @override + String get contactsReadPermissionDescription => + 'Pyytää yhteystietoihin vain luku -oikeuden'; + + @override + String get contactsSuggestionsEnabled => 'Yhteystietoehdotukset käytössä'; + + @override + String get contactsNeedsAttention => 'Yhteystiedot vaativat huomiota'; + + @override + String get contactsDisableForAccount => + 'Poista tämän tilin yhteystiedot käytöstä'; + + @override + String get contactsOnlyDescription => + 'Nämä lähteet ovat erillisiä kalenteri- ja tehtävätileistä.'; + + @override + String get contactsAddCardDav => 'Lisää CardDAV-yhteystiedot'; + + @override + String get contactsAddNextcloud => 'Lisää Nextcloud-yhteystiedot'; + + @override + String get contactsUseForAttendees => 'Käytä osallistujaehdotuksiin'; + + @override + String get contactsServerUrlLabel => 'HTTPS-palvelimen URL'; + + @override + String get contactsUsernameLabel => 'Käyttäjänimi'; + + @override + String get contactsPasswordLabel => 'Salasana'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 66966780..20944867 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3529,4 +3530,56 @@ class AppLocalizationsFr extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Allez dans Google Auth Platform → Branding. Pour une nouvelle configuration, sélectionnez Get started. Saisissez BusyMax dans App name, choisissez votre propre User support email et sélectionnez Next. Dans Audience, choisissez Internal. Sélectionnez Next, saisissez votre adresse e-mail dans Contact Information et sélectionnez Next. Acceptez User Data Policy, puis sélectionnez Continue et Create. Si la configuration existe, vérifiez Branding et Audience.\n\nInternal autorise uniquement les comptes de l’organisation parente du projet, sous réserve des contrôles administratifs. Aucune liste de testeurs n’est nécessaire. BusyMax ne peut pas vérifier ces réglages à partir du JSON.'; + + @override + String get contactsTitle => 'Carnet d’adresses'; + + @override + String get contactsLinkedDescription => + 'Utiliser les contacts en cache de ce compte lors de l’ajout d’invités aux événements.'; + + @override + String get contactsEnableSuggestions => 'Activer les suggestions de contacts'; + + @override + String get contactsReadOnlyUse => + 'Contacts en lecture seule pour suggérer des invités'; + + @override + String get contactsReadPermissionDescription => + 'Demande un accès en lecture seule aux contacts'; + + @override + String get contactsSuggestionsEnabled => 'Suggestions de contacts activées'; + + @override + String get contactsNeedsAttention => + 'Les contacts nécessitent votre attention'; + + @override + String get contactsDisableForAccount => + 'Désactiver les contacts pour ce compte'; + + @override + String get contactsOnlyDescription => + 'Ces sources sont indépendantes des comptes de calendrier et de tâches.'; + + @override + String get contactsAddCardDav => 'Ajouter des contacts CardDAV'; + + @override + String get contactsAddNextcloud => 'Ajouter des contacts Nextcloud'; + + @override + String get contactsUseForAttendees => + 'Utiliser pour les suggestions de participants'; + + @override + String get contactsServerUrlLabel => 'URL du serveur HTTPS'; + + @override + String get contactsUsernameLabel => 'Nom d’utilisateur'; + + @override + String get contactsPasswordLabel => 'Mot de passe'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 7aa0be55..23c4d454 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3514,4 +3515,54 @@ class AppLocalizationsHi extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Google Auth Platform → Branding में जाएँ। सेटअप शुरू नहीं हुआ है तो Get started चुनें। App name में BusyMax दर्ज करें, अपना User support email चुनें और Next चुनें। Audience में Internal चुनें। Next चुनें, Contact Information में अपना ईमेल दर्ज करें और Next चुनें। User Data Policy स्वीकार करें और Continue तथा Create चुनें। पहले से सेट है तो Branding और Audience जाँचें।\n\nInternal सिर्फ़ प्रोजेक्ट के मूल संगठन के खातों को अनुमति देता है और व्यवस्थापक के नियंत्रण लागू रहते हैं। परीक्षण उपयोगकर्ताओं की सूची नहीं चाहिए। BusyMax डाउनलोड किए JSON से ये कंसोल सेटिंग सत्यापित नहीं कर सकता।'; + + @override + String get contactsTitle => 'संपर्क'; + + @override + String get contactsLinkedDescription => + 'इवेंट में अतिथि जोड़ते समय इस खाते के कैश किए गए संपर्कों का उपयोग करें।'; + + @override + String get contactsEnableSuggestions => 'संपर्क सुझाव चालू करें'; + + @override + String get contactsReadOnlyUse => + 'सहभागियों के सुझावों के लिए केवल पढ़ने योग्य संपर्क'; + + @override + String get contactsReadPermissionDescription => + 'संपर्कों के लिए केवल पढ़ने की अनुमति माँगता है'; + + @override + String get contactsSuggestionsEnabled => 'संपर्क सुझाव चालू हैं'; + + @override + String get contactsNeedsAttention => 'संपर्कों पर ध्यान देना आवश्यक है'; + + @override + String get contactsDisableForAccount => 'इस खाते के संपर्क बंद करें'; + + @override + String get contactsOnlyDescription => + 'ये स्रोत कैलेंडर और कार्य खातों से स्वतंत्र हैं।'; + + @override + String get contactsAddCardDav => 'CardDAV संपर्क जोड़ें'; + + @override + String get contactsAddNextcloud => 'Nextcloud संपर्क जोड़ें'; + + @override + String get contactsUseForAttendees => + 'उपस्थित व्यक्ति के सुझावों के लिए उपयोग करें'; + + @override + String get contactsServerUrlLabel => 'HTTPS सर्वर URL'; + + @override + String get contactsUsernameLabel => 'उपयोगकर्ता नाम'; + + @override + String get contactsPasswordLabel => 'पासवर्ड'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 7859003e..ff09eb1b 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3539,4 +3540,55 @@ class AppLocalizationsIt extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Vai a Google Auth Platform → Branding. Se la configurazione non è iniziata, seleziona Get started. Inserisci BusyMax in App name, scegli la tua User support email e seleziona Next. In Audience scegli Internal. Seleziona Next, inserisci la tua email in Contact Information e seleziona Next. Accetta User Data Policy e seleziona Continue e Create. Se è già configurato, controlla Branding e Audience.\n\nInternal consente solo account dell’organizzazione principale del progetto, soggetti ai controlli amministrativi. Non serve un elenco di utenti di test. BusyMax non può verificare queste impostazioni dal JSON.'; + + @override + String get contactsTitle => 'Contatti'; + + @override + String get contactsLinkedDescription => + 'Usa i contatti memorizzati di questo account quando aggiungi invitati agli eventi.'; + + @override + String get contactsEnableSuggestions => 'Attiva i suggerimenti dei contatti'; + + @override + String get contactsReadOnlyUse => + 'Contatti in sola lettura per suggerire partecipanti'; + + @override + String get contactsReadPermissionDescription => + 'Richiede l’accesso ai contatti in sola lettura'; + + @override + String get contactsSuggestionsEnabled => 'Suggerimenti dei contatti attivati'; + + @override + String get contactsNeedsAttention => 'I contatti richiedono attenzione'; + + @override + String get contactsDisableForAccount => + 'Disattiva i contatti per questo account'; + + @override + String get contactsOnlyDescription => + 'Queste fonti sono indipendenti dagli account di calendario e attività.'; + + @override + String get contactsAddCardDav => 'Aggiungi contatti CardDAV'; + + @override + String get contactsAddNextcloud => 'Aggiungi contatti Nextcloud'; + + @override + String get contactsUseForAttendees => + 'Usa per i suggerimenti dei partecipanti'; + + @override + String get contactsServerUrlLabel => 'URL del server HTTPS'; + + @override + String get contactsUsernameLabel => 'Nome utente'; + + @override + String get contactsPasswordLabel => 'Parola d’accesso'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 0a13457c..e48858b9 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3435,4 +3436,50 @@ class AppLocalizationsJa extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Google Auth Platform → Branding に移動します。未設定の場合は Get started を選択します。App name に BusyMax を入力し、ご自身の User support email を選んで Next を選択します。Audience ではInternal を選びます。Next を選び、Contact Information にメールアドレスを入力して Next を選択します。User Data Policy に同意し、Continue、Create の順に選択します。設定済みの場合は Branding と Audience を確認します。\n\nInternal はプロジェクトの親組織のアカウントのみ利用でき、管理者の制限も適用されます。テストユーザーの一覧は不要です。BusyMax はダウンロードした JSON からこれらの設定を確認できません。'; + + @override + String get contactsTitle => '連絡先'; + + @override + String get contactsLinkedDescription => + '予定のゲストを追加するときに、このアカウントのキャッシュ済み連絡先を使用します。'; + + @override + String get contactsEnableSuggestions => '連絡先の候補を有効にする'; + + @override + String get contactsReadOnlyUse => '参加者候補のための読み取り専用連絡先'; + + @override + String get contactsReadPermissionDescription => '連絡先への読み取り専用アクセスを要求します'; + + @override + String get contactsSuggestionsEnabled => '連絡先の候補が有効です'; + + @override + String get contactsNeedsAttention => '連絡先の確認が必要です'; + + @override + String get contactsDisableForAccount => 'このアカウントの連絡先を無効にする'; + + @override + String get contactsOnlyDescription => 'これらのソースはカレンダーおよびタスクのアカウントから独立しています。'; + + @override + String get contactsAddCardDav => 'CardDAV の連絡先を追加'; + + @override + String get contactsAddNextcloud => 'Nextcloud の連絡先を追加'; + + @override + String get contactsUseForAttendees => '出席者の候補に使用'; + + @override + String get contactsServerUrlLabel => 'HTTPS サーバーの URL'; + + @override + String get contactsUsernameLabel => 'ユーザー名'; + + @override + String get contactsPasswordLabel => 'パスワード'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 2f362da8..ce1aa62f 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3438,4 +3439,50 @@ class AppLocalizationsKo extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Google Auth Platform → Branding으로 이동하세요. 아직 설정하지 않았다면 Get started를 선택하세요. App name에 BusyMax를 입력하고 본인 User support email을 선택한 뒤 Next를 선택하세요. Audience에서 Internal을 선택하세요. Next를 선택하고 Contact Information에 이메일을 입력한 뒤 Next를 선택하세요. User Data Policy에 동의하고 Continue와 Create를 선택하세요. 이미 설정했다면 Branding과 Audience를 확인하세요.\n\nInternal은 프로젝트의 상위 조직 계정만 허용하며 관리자 제어도 적용됩니다. 테스트 사용자 목록은 필요 없습니다. BusyMax는 다운로드한 JSON으로 이러한 콘솔 설정을 확인할 수 없습니다.'; + + @override + String get contactsTitle => '연락처'; + + @override + String get contactsLinkedDescription => + '이벤트 참석자를 추가할 때 이 계정에 캐시된 연락처를 사용합니다.'; + + @override + String get contactsEnableSuggestions => '연락처 추천 사용'; + + @override + String get contactsReadOnlyUse => '참석자 추천을 위한 읽기 전용 연락처'; + + @override + String get contactsReadPermissionDescription => '연락처 읽기 전용 권한을 요청합니다'; + + @override + String get contactsSuggestionsEnabled => '연락처 추천을 사용 중입니다'; + + @override + String get contactsNeedsAttention => '연락처를 확인해야 합니다'; + + @override + String get contactsDisableForAccount => '이 계정의 연락처 사용 중지'; + + @override + String get contactsOnlyDescription => '이 원본은 캘린더 및 작업 계정과 별개입니다.'; + + @override + String get contactsAddCardDav => 'CardDAV 연락처 추가'; + + @override + String get contactsAddNextcloud => 'Nextcloud 연락처 추가'; + + @override + String get contactsUseForAttendees => '참석자 추천에 사용'; + + @override + String get contactsServerUrlLabel => 'HTTPS 서버 URL'; + + @override + String get contactsUsernameLabel => '사용자 이름'; + + @override + String get contactsPasswordLabel => '비밀번호'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index f6823f69..4b462cef 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3694,4 +3695,53 @@ class AppLocalizationsPl extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Przejdź do Google Auth Platform → Branding. Jeśli konfiguracja nie została rozpoczęta, wybierz Get started. Wpisz BusyMax w App name, wybierz własny User support email i wybierz Next. W Audience wybierz Internal. Wybierz Next, wpisz swój e-mail w Contact Information i wybierz Next. Zaakceptuj User Data Policy, a następnie wybierz Continue i Create. Jeśli konfiguracja już istnieje, sprawdź Branding i Audience.\n\nInternal dopuszcza tylko konta organizacji nadrzędnej projektu, z ograniczeniami administratora. Lista testerów nie jest potrzebna. BusyMax nie potwierdzi tych ustawień konsoli na podstawie JSON.'; + + @override + String get contactsTitle => 'Kontakty'; + + @override + String get contactsLinkedDescription => + 'Używaj kontaktów z pamięci podręcznej tego konta podczas dodawania gości wydarzenia.'; + + @override + String get contactsEnableSuggestions => 'Włącz sugestie kontaktów'; + + @override + String get contactsReadOnlyUse => + 'Kontakty tylko do odczytu na potrzeby sugestii uczestników'; + + @override + String get contactsReadPermissionDescription => + 'Prosi o dostęp do kontaktów tylko do odczytu'; + + @override + String get contactsSuggestionsEnabled => 'Sugestie kontaktów są włączone'; + + @override + String get contactsNeedsAttention => 'Kontakty wymagają uwagi'; + + @override + String get contactsDisableForAccount => 'Wyłącz kontakty dla tego konta'; + + @override + String get contactsOnlyDescription => + 'Te źródła są niezależne od kont kalendarza i zadań.'; + + @override + String get contactsAddCardDav => 'Dodaj kontakty CardDAV'; + + @override + String get contactsAddNextcloud => 'Dodaj kontakty Nextcloud'; + + @override + String get contactsUseForAttendees => 'Używaj do sugerowania uczestników'; + + @override + String get contactsServerUrlLabel => 'Adres URL serwera HTTPS'; + + @override + String get contactsUsernameLabel => 'Nazwa użytkownika'; + + @override + String get contactsPasswordLabel => 'Hasło'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 37312cc6..a99ec166 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3542,6 +3543,55 @@ class AppLocalizationsPt extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Go to Google Auth Platform → Branding. If setup has not started, select Get started. Enter BusyMax as the App name, choose your own User support email and select Next. In Audience, choose Internal. Select Next, enter your email under Contact Information and select Next. Accept the User Data Policy, then select Continue and Create. If already configured, review Branding and Audience.\n\nInternal allows only accounts in the project’s parent organization, subject to administrator controls. No test-user list is needed. BusyMax cannot verify these console settings from the downloaded JSON.'; + + @override + String get contactsTitle => 'Contacts'; + + @override + String get contactsLinkedDescription => + 'Use cached contacts from this account when adding event guests.'; + + @override + String get contactsEnableSuggestions => 'Enable contact suggestions'; + + @override + String get contactsReadOnlyUse => + 'Read-only contacts for attendee suggestions'; + + @override + String get contactsReadPermissionDescription => + 'Requests read-only contact access'; + + @override + String get contactsSuggestionsEnabled => 'Contact suggestions enabled'; + + @override + String get contactsNeedsAttention => 'Contacts need attention'; + + @override + String get contactsDisableForAccount => 'Disable contacts for this account'; + + @override + String get contactsOnlyDescription => + 'These sources are independent of calendar and task accounts.'; + + @override + String get contactsAddCardDav => 'Add CardDAV contacts'; + + @override + String get contactsAddNextcloud => 'Add Nextcloud contacts'; + + @override + String get contactsUseForAttendees => 'Use for attendee suggestions'; + + @override + String get contactsServerUrlLabel => 'HTTPS server URL'; + + @override + String get contactsUsernameLabel => 'Username'; + + @override + String get contactsPasswordLabel => 'Password'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -7076,4 +7126,54 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get registrationGuideWorkspaceAudience => 'Aceda a Google Auth Platform → Branding. Se a configuração ainda não começou, selecione Get started. Introduza BusyMax em App name, escolha o seu User support email e selecione Next. Em Audience, escolha Internal. Selecione Next, introduza o seu email em Contact Information e selecione Next. Aceite User Data Policy e selecione Continue e Create. Se já estiver configurado, reveja Branding e Audience.\n\nInternal permite apenas contas da organização principal do projeto, sujeitas aos controlos administrativos. Não é necessária uma lista de utilizadores de teste. O BusyMax não verifica estas definições a partir do JSON.'; + + @override + String get contactsTitle => 'Contactos'; + + @override + String get contactsLinkedDescription => + 'Utilize os contactos em cache desta conta ao adicionar convidados a eventos.'; + + @override + String get contactsEnableSuggestions => 'Ativar sugestões de contactos'; + + @override + String get contactsReadOnlyUse => + 'Contactos só de leitura para sugestões de participantes'; + + @override + String get contactsReadPermissionDescription => + 'Pede acesso só de leitura aos contactos'; + + @override + String get contactsSuggestionsEnabled => 'Sugestões de contactos ativadas'; + + @override + String get contactsNeedsAttention => 'Os contactos precisam de atenção'; + + @override + String get contactsDisableForAccount => 'Desativar contactos para esta conta'; + + @override + String get contactsOnlyDescription => + 'Estas fontes são independentes das contas de calendário e tarefas.'; + + @override + String get contactsAddCardDav => 'Adicionar contactos CardDAV'; + + @override + String get contactsAddNextcloud => 'Adicionar contactos Nextcloud'; + + @override + String get contactsUseForAttendees => + 'Utilizar para sugestões de participantes'; + + @override + String get contactsServerUrlLabel => 'URL do servidor HTTPS'; + + @override + String get contactsUsernameLabel => 'Nome de utilizador'; + + @override + String get contactsPasswordLabel => 'Palavra-passe'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 78a4eb27..6195525c 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3551,4 +3552,55 @@ class AppLocalizationsRu extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Откройте Google Auth Platform → Branding. Для новой настройки выберите Get started. Введите BusyMax в App name, выберите свой User support email и нажмите Next. В Audience выберите Internal. Нажмите Next, введите свой адрес в Contact Information и нажмите Next. Примите User Data Policy и выберите Continue и Create. Если настройка уже есть, проверьте Branding и Audience.\n\nInternal допускает только аккаунты родительской организации проекта с учётом ограничений администратора. Список тестовых пользователей не нужен. BusyMax не может проверить эти настройки консоли по JSON.'; + + @override + String get contactsTitle => 'Контакты'; + + @override + String get contactsLinkedDescription => + 'Использовать кэшированные контакты этой учётной записи при добавлении гостей события.'; + + @override + String get contactsEnableSuggestions => 'Включить предложения контактов'; + + @override + String get contactsReadOnlyUse => + 'Контакты только для чтения для предложений участников'; + + @override + String get contactsReadPermissionDescription => + 'Запрашивает доступ к контактам только для чтения'; + + @override + String get contactsSuggestionsEnabled => 'Предложения контактов включены'; + + @override + String get contactsNeedsAttention => 'Контакты требуют внимания'; + + @override + String get contactsDisableForAccount => + 'Отключить контакты для этой учётной записи'; + + @override + String get contactsOnlyDescription => + 'Эти источники не зависят от учётных записей календаря и задач.'; + + @override + String get contactsAddCardDav => 'Добавить контакты CardDAV'; + + @override + String get contactsAddNextcloud => 'Добавить контакты Nextcloud'; + + @override + String get contactsUseForAttendees => + 'Использовать для предложений участников'; + + @override + String get contactsServerUrlLabel => 'URL-адрес сервера HTTPS'; + + @override + String get contactsUsernameLabel => 'Имя пользователя'; + + @override + String get contactsPasswordLabel => 'Пароль'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 911849d7..da26ba04 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3510,4 +3511,52 @@ class AppLocalizationsVi extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => 'Vào Google Auth Platform → Branding. Nếu chưa bắt đầu thiết lập, chọn Get started. Nhập BusyMax vào App name, chọn User support email của bạn rồi chọn Next. Trong Audience, chọn Internal. Chọn Next, nhập email vào Contact Information rồi chọn Next. Chấp nhận User Data Policy rồi chọn Continue và Create. Nếu đã thiết lập, kiểm tra Branding và Audience.\n\nInternal chỉ cho phép tài khoản thuộc tổ chức mẹ của dự án, chịu kiểm soát của quản trị viên. Không cần danh sách người dùng thử. BusyMax không thể xác minh các thiết lập này từ JSON tải xuống.'; + + @override + String get contactsTitle => 'Danh bạ'; + + @override + String get contactsLinkedDescription => + 'Dùng các liên hệ đã lưu đệm của tài khoản này khi thêm khách vào sự kiện.'; + + @override + String get contactsEnableSuggestions => 'Bật gợi ý liên hệ'; + + @override + String get contactsReadOnlyUse => 'Danh bạ chỉ đọc để gợi ý người tham dự'; + + @override + String get contactsReadPermissionDescription => + 'Yêu cầu quyền chỉ đọc danh bạ'; + + @override + String get contactsSuggestionsEnabled => 'Đã bật gợi ý liên hệ'; + + @override + String get contactsNeedsAttention => 'Danh bạ cần được chú ý'; + + @override + String get contactsDisableForAccount => 'Tắt danh bạ cho tài khoản này'; + + @override + String get contactsOnlyDescription => + 'Các nguồn này độc lập với tài khoản lịch và công việc.'; + + @override + String get contactsAddCardDav => 'Thêm danh bạ CardDAV'; + + @override + String get contactsAddNextcloud => 'Thêm danh bạ Nextcloud'; + + @override + String get contactsUseForAttendees => 'Dùng để gợi ý người tham dự'; + + @override + String get contactsServerUrlLabel => 'URL máy chủ HTTPS'; + + @override + String get contactsUsernameLabel => 'Tên người dùng'; + + @override + String get contactsPasswordLabel => 'Mật khẩu'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 5d236e6e..7099ff1f 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -4,6 +4,7 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -3384,6 +3385,51 @@ class AppLocalizationsZh extends AppLocalizations { @override String get registrationGuideWorkspaceAudience => '进入 Google Auth Platform → Branding。如果尚未开始设置,选择 Get started。在 App name 中输入 BusyMax,选择您自己的 User support email,然后选择 Next。在 Audience 中,请选择 Internal。选择 Next,在 Contact Information 中输入您的邮箱,再选择 Next。接受 User Data Policy,然后选择 Continue 和 Create。如果已完成设置,请检查 Branding 和 Audience。\n\nInternal 仅允许项目父组织中的帐号,并受管理员控制。不需要测试用户列表。BusyMax 无法从下载的 JSON 验证这些控制台设置。'; + + @override + String get contactsTitle => '联系人'; + + @override + String get contactsLinkedDescription => '添加日程参与者时使用此账号缓存的联系人。'; + + @override + String get contactsEnableSuggestions => '启用联系人建议'; + + @override + String get contactsReadOnlyUse => '用于与会者建议的只读联系人'; + + @override + String get contactsReadPermissionDescription => '请求联系人只读权限'; + + @override + String get contactsSuggestionsEnabled => '联系人建议已启用'; + + @override + String get contactsNeedsAttention => '联系人需要处理'; + + @override + String get contactsDisableForAccount => '停用此账号的联系人'; + + @override + String get contactsOnlyDescription => '这些来源独立于日历和任务账号。'; + + @override + String get contactsAddCardDav => '添加 CardDAV 联系人'; + + @override + String get contactsAddNextcloud => '添加 Nextcloud 联系人'; + + @override + String get contactsUseForAttendees => '用于参与者建议'; + + @override + String get contactsServerUrlLabel => 'HTTPS 服务器网址'; + + @override + String get contactsUsernameLabel => '用户名'; + + @override + String get contactsPasswordLabel => '密码'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6762,6 +6808,51 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get registrationGuideWorkspaceAudience => '进入 Google Auth Platform → Branding。如果尚未开始设置,选择 Get started。在 App name 中输入 BusyMax,选择您自己的 User support email,然后选择 Next。在 Audience 中,请选择 Internal。选择 Next,在 Contact Information 中输入您的邮箱,再选择 Next。接受 User Data Policy,然后选择 Continue 和 Create。如果已完成设置,请检查 Branding 和 Audience。\n\nInternal 仅允许项目父组织中的帐号,并受管理员控制。不需要测试用户列表。BusyMax 无法从下载的 JSON 验证这些控制台设置。'; + + @override + String get contactsTitle => '联系人'; + + @override + String get contactsLinkedDescription => '添加日程参与者时使用此账号缓存的联系人。'; + + @override + String get contactsEnableSuggestions => '启用联系人建议'; + + @override + String get contactsReadOnlyUse => '用于与会者建议的只读联系人'; + + @override + String get contactsReadPermissionDescription => '请求联系人只读权限'; + + @override + String get contactsSuggestionsEnabled => '联系人建议已启用'; + + @override + String get contactsNeedsAttention => '联系人需要处理'; + + @override + String get contactsDisableForAccount => '停用此账号的联系人'; + + @override + String get contactsOnlyDescription => '这些来源独立于日历和任务账号。'; + + @override + String get contactsAddCardDav => '添加 CardDAV 联系人'; + + @override + String get contactsAddNextcloud => '添加 Nextcloud 联系人'; + + @override + String get contactsUseForAttendees => '用于参与者建议'; + + @override + String get contactsServerUrlLabel => 'HTTPS 服务器网址'; + + @override + String get contactsUsernameLabel => '用户名'; + + @override + String get contactsPasswordLabel => '密码'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -10141,4 +10232,49 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get registrationGuideWorkspaceAudience => '進入 Google Auth Platform → Branding。如果尚未開始設定,選擇 Get started。在 App name 中輸入 BusyMax,選擇您自己的 User support email,然後選擇 Next。在 Audience 中,請選擇 Internal。選擇 Next,在 Contact Information 中輸入您的電子郵件,再選擇 Next。接受 User Data Policy,然後選擇 Continue 和 Create。如果已完成設定,請檢查 Branding 和 Audience。\n\nInternal 僅允許專案父組織中的帳號,並受管理員控制。不需要測試使用者清單。BusyMax 無法從下載的 JSON 驗證這些主控台設定。'; + + @override + String get contactsTitle => '聯絡人'; + + @override + String get contactsLinkedDescription => '新增活動來賓時使用此帳號快取的聯絡人。'; + + @override + String get contactsEnableSuggestions => '啟用聯絡人建議'; + + @override + String get contactsReadOnlyUse => '用於與會者建議的唯讀聯絡人'; + + @override + String get contactsReadPermissionDescription => '要求聯絡人的唯讀權限'; + + @override + String get contactsSuggestionsEnabled => '已啟用聯絡人建議'; + + @override + String get contactsNeedsAttention => '聯絡人需要處理'; + + @override + String get contactsDisableForAccount => '停用此帳號的聯絡人'; + + @override + String get contactsOnlyDescription => '這些來源獨立於行事曆和工作帳號。'; + + @override + String get contactsAddCardDav => '新增 CardDAV 聯絡人'; + + @override + String get contactsAddNextcloud => '新增 Nextcloud 聯絡人'; + + @override + String get contactsUseForAttendees => '用於參與者建議'; + + @override + String get contactsServerUrlLabel => 'HTTPS 伺服器網址'; + + @override + String get contactsUsernameLabel => '使用者名稱'; + + @override + String get contactsPasswordLabel => '密碼'; } diff --git a/lib/main_windows.dart b/lib/main_windows.dart index c7f89ada..a4d6eca1 100644 --- a/lib/main_windows.dart +++ b/lib/main_windows.dart @@ -63,9 +63,8 @@ Future main(List arguments) async { : DesktopNotificationReadinessState.unavailableUnpackaged, diagnosticCode: 'windows-notifications/package-identity-query-failed', ); - Logger( - 'busymax.windows.notifications', - ).severe('Windows notification package identity could not be queried.'); + Logger('busymax.windows.notifications') + .severe('Windows notification package identity could not be queried.'); } if (notificationReadiness != null) { // The failed identity probe above already selected the only safe backend. @@ -111,18 +110,16 @@ Future main(List arguments) async { DesktopNotificationReadinessState.failedInstalledPackage, diagnosticCode: 'windows-notifications/${error.code}', ); - Logger( - 'busymax.windows.notifications', - ).severe('Windows notification initialization failed: ${error.code}.'); + Logger('busymax.windows.notifications') + .severe('Windows notification initialization failed: ${error.code}.'); notificationBackend = const DisabledDesktopNotificationBackend(); } on Object { notificationReadiness = const DesktopNotificationReadiness( DesktopNotificationReadinessState.failedInstalledPackage, diagnosticCode: 'windows-notifications/initialization-failed', ); - Logger( - 'busymax.windows.notifications', - ).severe('Windows notification initialization failed.'); + Logger('busymax.windows.notifications') + .severe('Windows notification initialization failed.'); notificationBackend = const DisabledDesktopNotificationBackend(); } } diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart index d55be8cc..013aab10 100644 --- a/lib/src/android/android_app.dart +++ b/lib/src/android/android_app.dart @@ -239,9 +239,8 @@ class _AndroidHomeShellState extends ConsumerState .getSingleOrNull(); if (!mounted) return; if (row == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.noEventsOrTasks))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(context.l10n.noEventsOrTasks))); return; } ref.read(androidSelectedDestinationProvider.notifier).state = 0; diff --git a/lib/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart index eb42817b..e7eb4557 100644 --- a/lib/src/android/android_authorization.dart +++ b/lib/src/android/android_authorization.dart @@ -5,11 +5,14 @@ import '../core/auth/registration_staging.dart'; import '../core/auth/authorization_persistence.dart'; import '../db/app_database.dart' hide AuthorizationCommit; import 'android_account_gate.dart'; + import 'dart:async'; + import '../core/http/bounded_http.dart'; import 'package:busymax_android_platform/busymax_android_platform.dart'; import 'package:flutter/services.dart'; +import 'package:drift/drift.dart' show Value; import 'package:http/http.dart' as http; import '../config/build_config.dart'; @@ -39,6 +42,8 @@ const _microsoftNativeScopes = [ const _microsoftSharedNativeScope = 'Calendars.ReadWrite.Shared'; const _microsoftCategoryNativeScope = 'MailboxSettings.Read'; +const _microsoftContactsReadNativeScope = 'Contacts.Read'; +const _microsoftContactsWriteNativeScope = 'Contacts.ReadWrite'; const _microsoftStoredScopes = { 'https://graph.microsoft.com/User.Read', @@ -56,9 +61,8 @@ String? microsoftTenantIdFromAuthority(String? authority) { } final segment = uri.pathSegments.first.toLowerCase(); if (segment == 'consumers') return microsoftPersonalTenantId; - return RegExp( - r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', - ).hasMatch(segment) + return RegExp(r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$') + .hasMatch(segment) ? segment : null; } @@ -72,7 +76,11 @@ final class AndroidAuthorizationBroker MicrosoftOAuthGateway, MicrosoftSharedCalendarAuthorization, MicrosoftCategoryAuthorization, - AccountTokenBroker { + MicrosoftContactsAuthorization, + GoogleContactsAuthorization, + AccountTokenBroker, + ContactsAuthorizationBroker, + MicrosoftGraphAuthorizationBroker { AndroidAuthorizationBroker({ required BusyMaxAndroidPlatform platform, required http.Client httpClient, @@ -124,7 +132,7 @@ final class AndroidAuthorizationBroker @override Future connectGoogle(AuthorizationRequest request) { final attempt = _attempts.begin(_nowUtc, request.cancellation); - return _connectGoogle(request, attempt).catchError(( + return _connectGoogle(request, attempt, const []).catchError(( Object error, StackTrace stack, ) { @@ -137,6 +145,7 @@ final class AndroidAuthorizationBroker Future _connectGoogle( AuthorizationRequest request, AuthorizationAttempt attempt, + List optional, ) async { if (request.registration != null || request.intent == AuthorizationIntent.replaceRegistration) { @@ -164,13 +173,28 @@ final class AndroidAuthorizationBroker try { final native = await _boundedNative( attempt, - () => _platform.authorizeGoogleInteractively( - scopes: _googleScopes, + () async => _platform.authorizeGoogleInteractively( + scopes: { + ..._googleScopes, + ...optional, + ...await _existingOptionalScopes(target, BusyProvider.google), + }.toList(), authorizationAttemptId: attempt.id, ), ); final tokens = _tokenSet(native); _requireSilentScopes(BusyProvider.google, tokens, candidate: true); + if (!optional.every( + (scope) => + tokens.scopes.contains(scope) || + scope == googleContactsReadScope && + tokens.scopes.contains(googleContactsWriteScope), + )) { + throw const OAuthException( + 'OAuthMissingRequiredScope', + 'The requested Google Contacts permission was not granted.', + ); + } final user = await fetchUserInfo(tokens, cancellation: cancellation); final subject = user?.subject; if (subject == null || subject.isEmpty) { @@ -198,7 +222,14 @@ final class AndroidAuthorizationBroker accountId: id, tokenSet: tokens, user: user, - commit: _nativeCommit(id, expected, attempt, candidate, target != null), + commit: _nativeCommit( + id, + expected, + attempt, + candidate, + target != null, + tokens, + ), ); } on PlatformException catch (error) { throw _oauthError(error, provider: 'Google'); @@ -298,9 +329,13 @@ final class AndroidAuthorizationBroker try { final native = await _boundedNative( attempt, - () => _platform.authorizeMicrosoftInteractively( + () async => _platform.authorizeMicrosoftInteractively( authorizationAttemptId: attempt.id, - scopes: [..._microsoftNativeScopes, ...optional], + scopes: { + ..._microsoftNativeScopes, + ...optional, + ...await _existingOptionalScopes(target, BusyProvider.microsoft), + }.toList(), clientId: registration.clientId, authorityTenant: registration.authorityTenant, ), @@ -308,7 +343,9 @@ final class AndroidAuthorizationBroker final tokens = _tokenSet(native); _requireSilentScopes(BusyProvider.microsoft, tokens, candidate: true); for (final scope in optional) { - if (!tokens.scopes.contains('https://graph.microsoft.com/$scope')) { + if (!tokens.scopes.contains('https://graph.microsoft.com/$scope') && + !(scope == _microsoftContactsReadNativeScope && + tokens.scopes.contains(microsoftContactsWriteScope))) { throw const OAuthException( 'OAuthMissingRequiredScope', 'The requested optional permission was not granted.', @@ -353,7 +390,14 @@ final class AndroidAuthorizationBroker tokenSet: tokens, user: user, tenantId: tenant, - commit: _nativeCommit(id, expected, attempt, candidate, target != null), + commit: _nativeCommit( + id, + expected, + attempt, + candidate, + target != null, + tokens, + ), ); } on PlatformException catch (error) { throw _oauthError(error, provider: 'Microsoft'); @@ -366,6 +410,7 @@ final class AndroidAuthorizationBroker AuthorizationAttempt attempt, NativeOAuthCredential candidate, bool existing, + OAuthTokenSet tokens, ) => (persistAccount) async { try { attempt.check(); @@ -384,7 +429,14 @@ final class AndroidAuthorizationBroker attempt.check(); }, requireExisting: existing, - persistAccount: persistAccount, + persistAccount: () async { + // Accepted provider grants and the optional contacts link share the + // native credential commit and its rollback/generation fencing. + if (existing) { + await _writeNativeGrants(id, tokens); + } + await persistAccount(); + }, ); attempt.committed(); } finally { @@ -695,23 +747,104 @@ final class AndroidAuthorizationBroker await _optionalNativeConsent(id, _microsoftSharedNativeScope, cancellation); } + @override + Future authorizeGoogleContacts( + String id, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) async { + final attempt = _attempts.begin(_nowUtc, cancellation); + NativeOAuthCredential? current; + const scope = googleContactsReadScope; + try { + current = await attempt.wait(nativeCredential(id, BusyProvider.google)); + if (await _persistExistingContacts( + id, + current!, + attempt, + persistContacts, + )) { + attempt.committed(); + return; + } + final result = await _connectGoogle( + AuthorizationRequest.reconnect(id), + attempt, + [scope], + ); + await result.commit!(persistContacts ?? () async {}); + } on OAuthException catch (error) { + if (current == null) rethrow; + throw AuthorizationScopedOAuthException( + accountId: id, + generation: current.generation, + cause: error, + ); + } finally { + _attempts.finish(attempt); + } + } + + @override + Future authorizeMicrosoftContacts( + String id, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => _optionalNativeConsent( + id, + _microsoftContactsReadNativeScope, + cancellation, + persistContacts: persistContacts, + ); + + @override + Future authorizeContacts( + BusyProvider provider, + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => switch (provider) { + BusyProvider.google => authorizeGoogleContacts( + accountId, + cancellation: cancellation, + persistContacts: persistContacts, + ), + BusyProvider.microsoft => authorizeMicrosoftContacts( + accountId, + cancellation: cancellation, + persistContacts: persistContacts, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + Future _optionalNativeConsent( String id, String scope, - AuthorizationCancellation? cancellation, - ) async { + AuthorizationCancellation? cancellation, { + Future Function()? persistContacts, + }) async { final attempt = _attempts.begin(_nowUtc, cancellation); NativeOAuthCredential? current; try { current = await attempt.wait( nativeCredential(id, BusyProvider.microsoft), ); + if (scope == _microsoftContactsReadNativeScope && + await _persistExistingContacts( + id, + current!, + attempt, + persistContacts, + )) { + attempt.committed(); + return; + } final result = await _connectMicrosoft( AuthorizationRequest.reconnect(id), optional: [scope], capturedAttempt: attempt, ); - await result.commit!(() async {}); + await result.commit!(persistContacts ?? () async {}); } on OAuthException catch (error) { if (current == null) rethrow; throw AuthorizationScopedOAuthException( @@ -724,6 +857,79 @@ final class AndroidAuthorizationBroker } } + Future _writeNativeGrants(String id, OAuthTokenSet tokens) async => + await (persistence!.database.update( + persistence!.database.accounts, + )..where((row) => row.id.equals(id))).write( + AccountsCompanion(grantedScopes: Value(tokens.scopes.join(' '))), + ); + + Future> _existingOptionalScopes( + String? id, + BusyProvider provider, + ) async { + if (id == null) { + return const []; + } + final row = await (persistence!.database.select( + persistence!.database.accounts, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + final scopes = (row?.grantedScopes ?? '') + .split(RegExp(r'\s+')) + .map(_storedScope) + .toSet(); + if (provider == BusyProvider.google) { + return [ + if (scopes.contains(googleContactsReadScope) || + scopes.contains(googleContactsWriteScope)) + googleContactsReadScope, + ]; + } + return [ + if (scopes.contains(microsoftSharedCalendarScope)) + _microsoftSharedNativeScope, + if (scopes.contains(microsoftCategoryScope)) + _microsoftCategoryNativeScope, + if (scopes.contains(microsoftContactsReadScope) || + scopes.contains(microsoftContactsWriteScope)) + _microsoftContactsReadNativeScope, + ]; + } + + Future _persistExistingContacts( + String id, + NativeOAuthCredential current, + AuthorizationAttempt attempt, + Future Function()? persistContacts, + ) async { + final optional = await _existingOptionalScopes(id, current.provider); + final readScope = current.provider == BusyProvider.google + ? googleContactsReadScope + : _microsoftContactsReadNativeScope; + if (!optional.contains(readScope)) return false; + final native = await attempt.wait( + current.provider == BusyProvider.google + ? _googleContactsToken(id) + : _microsoftContactsToken(id), + ); + final store = persistence!; + await store.run(id, () async { + attempt.check(); + if (await store.generation(id) != current.generation) { + throw const OAuthException( + 'OAuthStaleAuthorization', + 'The account authorization changed.', + ); + } + await store.database.transaction(() async { + await _writeNativeGrants(id, _tokenSet(native)); + await persistContacts?.call(); + attempt.check(); + }); + }); + return true; + } + @override Future fetchUserInfo( OAuthTokenSet tokenSet, { @@ -806,6 +1012,175 @@ final class AndroidAuthorizationBroker } } + @override + Future microsoftGraphAuthorizationHeader( + String accountId, + MicrosoftGraphAuthorizationKind kind, { + String? claims, + }) { + if (claims == null || claims.isEmpty) { + return switch (kind) { + MicrosoftGraphAuthorizationKind.ordinary => authorizationHeader( + BusyProvider.microsoft, + accountId, + ), + MicrosoftGraphAuthorizationKind.sharedCalendar => + microsoftSharedCalendarAuthorizationHeader(accountId), + MicrosoftGraphAuthorizationKind.category => + microsoftCategoryAuthorizationHeader(accountId), + }; + } + return _microsoftGraphHeader( + accountId, + claims: claims, + extraScope: switch (kind) { + MicrosoftGraphAuthorizationKind.ordinary => null, + MicrosoftGraphAuthorizationKind.sharedCalendar => + _microsoftSharedNativeScope, + MicrosoftGraphAuthorizationKind.category => + _microsoftCategoryNativeScope, + }, + ); + } + + Future _microsoftGraphHeader( + String accountId, { + required String claims, + required String? extraScope, + }) async { + try { + final current = persistence == null + ? null + : await nativeCredential(accountId, BusyProvider.microsoft) + as MicrosoftAndroidCredential; + final native = await _boundedSilent( + _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: [ + ..._microsoftNativeScopes, + if (extraScope != null) extraScope, + ], + clientId: current?.registration.clientId, + authorityTenant: current?.registration.authorityTenant, + nativeAccountId: current?.nativeAccountId, + authority: current?.authority, + claims: claims, + ), + ); + final tokenSet = _tokenSet(native); + _requireSilentScopes(BusyProvider.microsoft, tokenSet); + if (extraScope != null && + !tokenSet.scopes.any( + (scope) => + scope.toLowerCase() == extraScope.toLowerCase() || + scope.toLowerCase() == + 'https://graph.microsoft.com/${extraScope.toLowerCase()}', + )) { + throw const OAuthException( + 'MicrosoftOAuthOptionalConsentRequired', + 'The required optional Microsoft permission is unavailable.', + ); + } + await _acceptNativeToken(accountId, current, native); + return 'Bearer ${native.accessToken}'; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.microsoft); + } + } + + @override + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + String? claims, + }) => switch (provider) { + BusyProvider.google => googleContactsAuthorizationHeader(accountId), + BusyProvider.microsoft => microsoftContactsAuthorizationHeader( + accountId, + claims: claims, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + + @override + Future googleContactsAuthorizationHeader(String accountId) async => + 'Bearer ${(await _googleContactsToken(accountId)).accessToken}'; + + Future _googleContactsToken( + String accountId, + ) async { + try { + final current = persistence == null + ? null + : await nativeCredential(accountId, BusyProvider.google); + const requiredScope = googleContactsReadScope; + final native = await _boundedSilent( + _platform.authorizeGoogleSilently( + accountId: accountId, + scopes: [..._googleScopes, requiredScope], + ), + ); + final tokenSet = _tokenSet(native); + _requireSilentScopes(BusyProvider.google, tokenSet); + if (!tokenSet.scopes.contains(requiredScope) && + !tokenSet.scopes.contains(googleContactsWriteScope)) { + throw const OAuthException( + 'OAuthContactsConsentRequired', + 'Google Contacts permission must be granted for this account.', + ); + } + await _acceptNativeToken(accountId, current, native); + return native; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.google); + } + } + + @override + Future microsoftContactsAuthorizationHeader( + String accountId, { + String? claims, + }) async => + 'Bearer ${(await _microsoftContactsToken(accountId, claims: claims)).accessToken}'; + + Future _microsoftContactsToken( + String accountId, { + String? claims, + }) async { + try { + final current = persistence == null + ? null + : await nativeCredential(accountId, BusyProvider.microsoft) + as MicrosoftAndroidCredential; + const requiredNativeScope = _microsoftContactsReadNativeScope; + final native = await _boundedSilent( + _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: [..._microsoftNativeScopes, requiredNativeScope], + clientId: current?.registration.clientId, + authorityTenant: current?.registration.authorityTenant, + nativeAccountId: current?.nativeAccountId, + authority: current?.authority, + claims: claims, + ), + ); + final tokenSet = _tokenSet(native); + _requireSilentScopes(BusyProvider.microsoft, tokenSet); + const requiredScope = microsoftContactsReadScope; + if (!tokenSet.scopes.contains(requiredScope) && + !tokenSet.scopes.contains(microsoftContactsWriteScope)) { + throw const OAuthException( + 'MicrosoftOAuthContactsConsentRequired', + 'Microsoft Contacts permission must be granted for this account.', + ); + } + await _acceptNativeToken(accountId, current, native); + return native; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.microsoft); + } + } + @override Future microsoftSharedCalendarAuthorizationHeader( String accountId, @@ -1055,6 +1430,8 @@ String _storedScope(String value) { ..._microsoftNativeScopes, _microsoftSharedNativeScope, _microsoftCategoryNativeScope, + _microsoftContactsReadNativeScope, + _microsoftContactsWriteNativeScope, ]) { if (scope.toLowerCase() == nativeScope.toLowerCase()) { return 'https://graph.microsoft.com/$nativeScope'; diff --git a/lib/src/android/android_background.dart b/lib/src/android/android_background.dart index 7da0e565..e123d53d 100644 --- a/lib/src/android/android_background.dart +++ b/lib/src/android/android_background.dart @@ -1,5 +1,6 @@ import '../features/sync/domain_sync_schedule.dart'; import '../core/auth/registration_staging.dart'; + import 'dart:async'; import 'dart:ui'; diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart index c6c41c8c..1c9de7b1 100644 --- a/lib/src/android/android_notifications.dart +++ b/lib/src/android/android_notifications.dart @@ -542,9 +542,7 @@ final class AndroidNotificationService } } - _precisionDiagnostic = exact - ? 'Exact reminder alarms are enabled.' - : 'Exact alarm access is unavailable; reminders use Android inexact scheduling.'; + _precisionDiagnostic = exact ? 'Exact reminder alarms are enabled.' : 'Exact alarm access is unavailable; reminders use Android inexact scheduling.'; if (rows.length > _maximumScheduledAlarms) { _precisionDiagnostic = '$_precisionDiagnostic ${rows.length - _maximumScheduledAlarms} later reminders are waiting for scheduling capacity.'; diff --git a/lib/src/android/presentation/android_contacts_settings.dart b/lib/src/android/presentation/android_contacts_settings.dart new file mode 100644 index 00000000..ccf7c214 --- /dev/null +++ b/lib/src/android/presentation/android_contacts_settings.dart @@ -0,0 +1,331 @@ +import '../../l10n/l10n.dart'; + +import 'dart:async'; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../providers/busy_provider.dart'; + +import 'package:flutter/material.dart'; + +class AndroidContactsSettings extends ConsumerStatefulWidget { + const AndroidContactsSettings({super.key}); + @override + ConsumerState createState() => + _AndroidContactsSettingsState(); +} + +class _AndroidContactsSettingsState + extends ConsumerState { + bool _pending = false; + String? _error; + Future _run(Future Function() action) async { + if (_pending) return; + setState(() { + _pending = true; + _error = null; + }); + try { + await action(); + } on Object catch (error) { + if (mounted) { + setState( + () => _error = error is ContactsException + ? error.code + : context.l10n.operationFailed, + ); + } + } finally { + if (mounted) setState(() => _pending = false); + } + } + + Widget _action(String label, Future Function() action) => TextButton( + onPressed: _pending ? null : () => unawaited(_run(action)), + child: Text(label), + ); + + Future _add(bool nextcloud) async { + final input = await showDialog<_DavInput>( + context: context, + builder: (_) => _DavDialog(nextcloud: nextcloud), + ); + if (!mounted || input == null) return; + { + final controller = ref.read(busyMaxContactsControllerProvider); + final id = const Uuid().v7(); + if (nextcloud) { + await controller.addNextcloudContactsOnly( + id: id, + label: input.label, + server: Uri.parse(input.server), + ); + } else { + await controller.addCardDavContactsOnly( + id: id, + label: input.label, + server: Uri.parse(input.server), + username: input.username, + password: input.password, + ); + } + } + } + + Future _reconnect(ContactAccount account) async { + final controller = ref.read(busyMaxContactsControllerProvider); + final parent = await controller.store.linkedBusyMaxAccount(account.id); + if (parent != null || + account.provider != ContactProviderKind.carddav || + await controller.isIndependentNextcloud(account.id)) { + await controller.reconnectContactsAccount(account.id); + return; + } + if (!mounted) return; + final password = await showDialog( + context: context, + builder: (_) => const _DavReconnectDialog(), + ); + if (!mounted || password == null) return; + await controller.reconnectDavContactsOnly(account.id, password: password); + } + + @override + Widget build(BuildContext context) { + final parents = + ref.watch(accountManagementStreamProvider).valueOrNull ?? const []; + final accounts = + ref.watch(busyMaxContactAccountsProvider).valueOrNull ?? + const []; + final sources = + ref.watch(busyMaxContactSourceSettingsProvider).valueOrNull ?? const []; + final controller = ref.read(busyMaxContactsControllerProvider); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.contactsTitle), + if (accounts.isEmpty) Text(context.l10n.contactsLinkedDescription), + for (final parent in parents.where( + (p) => [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + ].contains(p.provider), + )) ...[ + Text('${context.l10n.contactsTitle}: ${parent.displayLabel}'), + Text(context.l10n.contactsReadPermissionDescription), + _action( + context.l10n.contactsEnableSuggestions, + () => parent.provider == BusyProvider.nextcloud + ? controller.enableLinkedNextcloudContacts(parent.id) + : controller.enableLinkedContacts(parent.id), + ), + ], + Wrap( + spacing: 8, + children: [ + _action(context.l10n.contactsAddCardDav, () => _add(false)), + _action(context.l10n.contactsAddNextcloud, () => _add(true)), + ], + ), + for (final account in accounts) ...[ + Text( + '${account.displayName} · ${context.l10n.contactsReadOnlyUse} · ${account.enabled ? context.l10n.contactsSuggestionsEnabled : context.l10n.off}', + ), + if (account.errorCode != null) Text(account.errorCode!), + Wrap( + spacing: 8, + children: [ + _action( + context.l10n.connectAccountAction, + () => _reconnect(account), + ), + _action( + context.l10n.contactsDisableForAccount, + () => controller.disableContactsAccount(account.id), + ), + _action( + context.l10n.removeAction, + () => controller.removeContactsAccount(account.id), + ), + ], + ), + ], + for (final setting in sources) + SwitchListTile( + key: ValueKey('contacts-source:${setting.source.key}'), + title: Text(setting.source.name), + subtitle: Text(context.l10n.contactsUseForAttendees), + value: setting.enabled, + onChanged: _pending + ? null + : (enabled) => unawaited( + _run( + () => controller.setSourceEnabled( + setting.source.key, + enabled: enabled, + ), + ), + ), + ), + if (_pending) const CircularProgressIndicator(), + if (_error != null) Text(_error!), + ], + ), + ), + ); + } +} + +final class _DavInput { + const _DavInput(this.label, this.server, this.username, this.password); + final String label, server, username, password; +} + +class _DavDialog extends StatefulWidget { + const _DavDialog({required this.nextcloud}); + final bool nextcloud; + @override + State<_DavDialog> createState() => _DavDialogState(); +} + +class _DavDialogState extends State<_DavDialog> { + final _label = TextEditingController(); + final _server = TextEditingController(); + final _username = TextEditingController(); + final _password = TextEditingController(); + String? _error; + @override + void dispose() { + _label.dispose(); + _server.dispose(); + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + void _submit() { + final uri = Uri.tryParse(_server.text.trim()); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + _label.text.trim().isEmpty || + !widget.nextcloud && + (_username.text.isEmpty || _password.text.isEmpty)) { + setState(() => _error = context.l10n.contactsReadPermissionDescription); + return; + } + Navigator.pop( + context, + _DavInput( + _label.text.trim(), + uri.toString(), + _username.text, + _password.text, + ), + ); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text( + widget.nextcloud + ? context.l10n.contactsAddNextcloud + : context.l10n.contactsAddCardDav, + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _label, + obscureText: false, + decoration: InputDecoration( + labelText: context.l10n.subscriptionName, + ), + ), + TextField( + controller: _server, + obscureText: false, + decoration: InputDecoration( + labelText: context.l10n.contactsServerUrlLabel, + ), + ), + if (!widget.nextcloud) + TextField( + controller: _username, + obscureText: false, + decoration: InputDecoration( + labelText: context.l10n.contactsUsernameLabel, + ), + ), + if (!widget.nextcloud) + TextField( + controller: _password, + obscureText: true, + decoration: InputDecoration( + labelText: context.l10n.contactsPasswordLabel, + ), + ), + Text(context.l10n.contactsReadOnlyUse), + if (_error != null) Text(_error!), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: _submit, + child: Text(context.l10n.connectAccountAction), + ), + ], + ); +} + +class _DavReconnectDialog extends StatefulWidget { + const _DavReconnectDialog(); + @override + State<_DavReconnectDialog> createState() => _DavReconnectDialogState(); +} + +class _DavReconnectDialogState extends State<_DavReconnectDialog> { + final _password = TextEditingController(); + @override + void dispose() { + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(context.l10n.connectAccountAction), + content: TextField( + controller: _password, + obscureText: true, + decoration: InputDecoration( + labelText: context.l10n.contactsPasswordLabel, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () { + if (_password.text.isNotEmpty) Navigator.pop(context, _password.text); + }, + child: Text(context.l10n.connectAccountAction), + ), + ], + ); +} diff --git a/lib/src/android/presentation/android_diagnostics_screen.dart b/lib/src/android/presentation/android_diagnostics_screen.dart index ffd17365..7fa1ac2a 100644 --- a/lib/src/android/presentation/android_diagnostics_screen.dart +++ b/lib/src/android/presentation/android_diagnostics_screen.dart @@ -179,9 +179,8 @@ class AndroidDiagnosticsScreen extends ConsumerWidget { } } on Object catch (error) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(redactForLog(error)))); } } } diff --git a/lib/src/android/presentation/android_event_attachments_dialog.dart b/lib/src/android/presentation/android_event_attachments_dialog.dart index ea0c706e..c3167995 100644 --- a/lib/src/android/presentation/android_event_attachments_dialog.dart +++ b/lib/src/android/presentation/android_event_attachments_dialog.dart @@ -286,8 +286,10 @@ class _AndroidEventAttachmentsDialogState contentType: document.mimeType ?? 'application/octet-stream', bytes: document.bytes, ); + if (!mounted) return; _refresh(); } on Object catch (error) { + if (!mounted) return; _refresh(); _error(error); } finally { diff --git a/lib/src/android/presentation/android_recurrence_dialog.dart b/lib/src/android/presentation/android_recurrence_dialog.dart index e7b33072..8905c8c7 100644 --- a/lib/src/android/presentation/android_recurrence_dialog.dart +++ b/lib/src/android/presentation/android_recurrence_dialog.dart @@ -121,9 +121,8 @@ Future showAndroidRecurrenceDialog( FilterChip( label: Text( DateFormat.E( - Localizations.localeOf( - context, - ).toLanguageTag(), + Localizations.localeOf(context) + .toLanguageTag(), ).format(DateTime(2024, 1, index + 1)), ), selected: rule.byDay.contains(rfcWeekdays[index]), diff --git a/lib/src/android/presentation/android_registration_setup_dialog.dart b/lib/src/android/presentation/android_registration_setup_dialog.dart index b8ff1eff..4e4e2cb9 100644 --- a/lib/src/android/presentation/android_registration_setup_dialog.dart +++ b/lib/src/android/presentation/android_registration_setup_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; + import '../../app/app_bootstrap.dart'; import '../../core/auth/oauth_registration.dart'; import '../../core/auth/registration_setup_controller.dart'; @@ -55,8 +56,7 @@ class _RegistrationDialogState extends State<_RegistrationDialog> { onError: (Object _) { if (mounted) { setState( - () => identityError = - 'The installed package/signature does not match its native redirect. Install a correctly configured build.', + () => identityError = 'The installed package/signature does not match its native redirect. Install a correctly configured build.', ); } }, diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index d9fb4fba..946d2ea7 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'dart:async'; import 'dart:convert'; @@ -9,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../app/app_bootstrap.dart'; +import '../../contacts/busymax_contacts_controller.dart'; import '../../app/common/busymax_motion_widgets.dart'; import '../../app/common/busymax_mutation_list.dart'; import '../../calendar_providers/calendar_mutation.dart'; @@ -201,9 +203,8 @@ class _AndroidScheduleScreenState extends ConsumerState { hintText: context.l10n.windowsSearch, border: InputBorder.none, suffixIcon: IconButton( - tooltip: MaterialLocalizations.of( - context, - ).clearButtonTooltip, + tooltip: MaterialLocalizations.of(context) + .clearButtonTooltip, icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -816,9 +817,8 @@ class _AndroidScheduleScreenState extends ConsumerState { child: Text(uri.toString()), ), ], - if (item case CalendarScheduleItem( - :final location?, - ) when location.isNotEmpty) + if (item case CalendarScheduleItem(:final location?) + when location.isNotEmpty) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.place), @@ -830,9 +830,8 @@ class _AndroidScheduleScreenState extends ConsumerState { ).open(projectedScheduleItemLocationDestination(item)), ), ), - if (item case TaskScheduleItem( - :final notes?, - ) when notes.isNotEmpty) + if (item case TaskScheduleItem(:final notes?) + when notes.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 12), child: Text(notes), @@ -1182,9 +1181,8 @@ class _AndroidScheduleScreenState extends ConsumerState { if (_taskMutationIntent?.generation == intent.generation) { setState(() => _taskMutationIntent = null); } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } } @@ -1592,12 +1590,11 @@ class _AndroidTimeGrid extends StatelessWidget { top: hour * 60 * _minuteHeight - 8, end: 6, child: Text( - BusyMaxTimeFormatScope.of( - context, - ).format(DateTime(2026, 1, 1, hour)), - style: Theme.of( - context, - ).textTheme.labelSmall, + BusyMaxTimeFormatScope.of(context) + .format(DateTime(2026, 1, 1, hour)), + style: Theme.of(context) + .textTheme + .labelSmall, ), ), ], @@ -2197,6 +2194,10 @@ class _AndroidEventEditorState extends ConsumerState { text: _draft.categories.join(', '), ); final Map _guestOptionalOverrides = {}; + final Map _suggestedGuestNames = {}; + List _guestSuggestions = const []; + int _guestSuggestionGeneration = 0; + StreamSubscription? _contactsChanges; int? _reminderMinutes; Object? _editedGoogleReminders; List? _editedNextcloudReminders; @@ -2220,6 +2221,13 @@ class _AndroidEventEditorState extends ConsumerState { @override void initState() { super.initState(); + _contactsChanges = ref + .read(busyMaxContactsControllerProvider) + .changes + .listen((_) { + _guestSuggestionGeneration++; + if (mounted) setState(() => _guestSuggestions = const []); + }); unawaited(_restoreRecovery()); _recoveryTimer = Timer.periodic( const Duration(seconds: 1), @@ -2229,6 +2237,8 @@ class _AndroidEventEditorState extends ConsumerState { @override void dispose() { + _guestSuggestionGeneration++; + unawaited(_contactsChanges?.cancel()); _consentCancellation?.cancel(); _recoveryTimer?.cancel(); if (_recoveryLoaded && _hasPendingEdits && !_allowPop) { @@ -2277,6 +2287,115 @@ class _AndroidEventEditorState extends ConsumerState { _editedNextcloudReminders != null || _recurrenceChanged; + List _currentAttendees() { + final merged = mergeAndroidEventAttendees( + widget.draft.attendees, + _guests.text, + ).attendees; + final originalEmails = { + for (final attendee in widget.draft.attendees) + attendee.email.toLowerCase(), + }; + return merged + .map((attendee) { + final key = attendee.email.toLowerCase(); + final name = _suggestedGuestNames[key]; + return !originalEmails.contains(key) && name != null + ? EventAttendeeDraft(email: attendee.email, displayName: name) + : attendee; + }) + .toList(growable: false); + } + + Future _loadGuestSuggestions(String query) async { + final generation = ++_guestSuggestionGeneration; + final accountId = _draft.accountId; + final sourceId = _draft.sourceId; + final term = query.split(',').last.trim(); + if (term.isEmpty) { + setState(() => _guestSuggestions = const []); + return; + } + try { + final suggestions = await ref + .read(busyMaxContactsControllerProvider) + .suggestAttendees( + term, + busyMaxAccountId: accountId, + excludedAddresses: { + for (final attendee in _currentAttendees()) attendee.email, + }, + ); + if (!mounted || + generation != _guestSuggestionGeneration || + _draft.accountId != accountId || + _draft.sourceId != sourceId || + _guests.text.split(',').last.trim() != term) { + return; + } + setState(() => _guestSuggestions = suggestions); + } on Object { + if (!mounted || + generation != _guestSuggestionGeneration || + _draft.accountId != accountId || + _draft.sourceId != sourceId || + _guests.text.split(',').last.trim() != term) { + return; + } + setState(() => _guestSuggestions = const []); + } + } + + Future _selectGuestSuggestion( + BusyMaxContactSuggestion suggestion, + ) async { + if (!_draft.canManageAttendees || !_guestSuggestions.contains(suggestion)) { + return; + } + final generation = _guestSuggestionGeneration; + final accountId = _draft.accountId; + final sourceId = _draft.sourceId; + try { + if (!await ref + .read(busyMaxContactsControllerProvider) + .suggestionEligible(suggestion, busyMaxAccountId: accountId)) { + return; + } + } on Object { + return; + } + if (!mounted || + generation != _guestSuggestionGeneration || + _draft.accountId != accountId || + _draft.sourceId != sourceId || + !_draft.canManageAttendees || + !_guestSuggestions.contains(suggestion)) { + return; + } + _guestSuggestionGeneration++; + final emails = _guests.text + .split(RegExp(r'[,;\n]')) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(); + if (emails.isNotEmpty && !_androidLooksLikeEmail(emails.last)) { + emails.removeLast(); + } + if (!emails.any( + (email) => email.toLowerCase() == suggestion.email.toLowerCase(), + )) { + emails.add(suggestion.email); + } + setState(() { + _guests.text = emails.join(', '); + if (suggestion.displayName.isNotEmpty) { + _suggestedGuestNames[suggestion.email.toLowerCase()] = + suggestion.displayName; + } + _guestSuggestions = const []; + }); + } + Future _editRecurrence() async { final start = _draft.start; if (start == null) return; @@ -2375,6 +2494,8 @@ class _AndroidEventEditorState extends ConsumerState { ); return; } + _guestSuggestionGeneration++; + _guestSuggestions = const []; setState( () => _draft = _draft.copyWith( accountId: source.accountId, @@ -2454,9 +2575,8 @@ class _AndroidEventEditorState extends ConsumerState { DropdownButtonFormField( key: ValueKey('android-event-visibility-${_provider.name}'), initialValue: - _eventVisibilityValues( - _provider, - ).contains(_draft.visibilityOrSensitivity) + _eventVisibilityValues(_provider) + .contains(_draft.visibilityOrSensitivity) ? _draft.visibilityOrSensitivity : _eventVisibilityValues(_provider).first, decoration: InputDecoration(labelText: context.l10n.visibility), @@ -2646,12 +2766,31 @@ class _AndroidEventEditorState extends ConsumerState { hintText: context.l10n.addGuestEmail, ), keyboardType: TextInputType.emailAddress, - onChanged: (_) => setState(() {}), + onChanged: (value) { + setState(() {}); + unawaited(_loadGuestSuggestions(value)); + }, ), - for (final attendee in mergeAndroidEventAttendees( - widget.draft.attendees, - _guests.text, - ).attendees.where((a) => !a.self && !a.organizer)) + for (final suggestion in _guestSuggestions) + ListTile( + key: ValueKey('android-guest-contact-${suggestion.email}'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_add_alt_outlined), + title: Text( + suggestion.displayName.isEmpty + ? suggestion.email + : suggestion.displayName, + ), + subtitle: suggestion.displayName.isEmpty + ? null + : Text(suggestion.email), + onTap: _canEdit && _draft.canManageAttendees + ? () => _selectGuestSuggestion(suggestion) + : null, + ), + for (final attendee in _currentAttendees().where( + (a) => !a.self && !a.organizer, + )) ListTile( key: ValueKey('android-event-guest-${attendee.email}'), contentPadding: EdgeInsets.zero, @@ -2713,6 +2852,9 @@ class _AndroidEventEditorState extends ConsumerState { _guestOptionalOverrides.remove( attendee.email.toLowerCase(), ); + _suggestedGuestNames.remove( + attendee.email.toLowerCase(), + ); }) : null, icon: const Icon(Icons.close), @@ -3312,7 +3454,10 @@ class _AndroidEventEditorState extends ConsumerState { final start = _draft.start; if (_title.text.trim().isEmpty || start == null) return; final attendeeEdit = applyAndroidEventAttendeeRoles( - mergeAndroidEventAttendees(widget.draft.attendees, _guests.text), + AndroidEventAttendeeEdit( + attendees: _currentAttendees(), + changed: !listEquals(_currentAttendees(), widget.draft.attendees), + ), _guestOptionalOverrides, ); final categories = _categories.text @@ -3419,9 +3564,8 @@ class _AndroidEventEditorState extends ConsumerState { } } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); @@ -3525,9 +3669,8 @@ class _AndroidEventEditorState extends ConsumerState { } } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); @@ -3690,6 +3833,14 @@ class _AndroidEventEditorState extends ConsumerState { } } } + _suggestedGuestNames.clear(); + if (map['guestDisplayNames'] case final Map names) { + for (final entry in names.entries) { + if (entry.key is String && entry.value is String) { + _suggestedGuestNames[entry.key as String] = entry.value as String; + } + } + } _categories.text = map['categories']?.toString() ?? _categories.text; _reminderMinutes = map['reminderMinutes'] as int?; _editedGoogleReminders = map['googleReminders'] is Map @@ -3797,6 +3948,7 @@ class _AndroidEventEditorState extends ConsumerState { 'description': _description.text, 'guests': _guests.text, 'guestOptionalOverrides': _guestOptionalOverrides, + 'guestDisplayNames': _suggestedGuestNames, 'categories': _categories.text, 'allDay': _draft.allDay, 'start': _draft.start?.toIso8601String(), @@ -3841,6 +3993,9 @@ class _AndroidEventEditorState extends ConsumerState { } } +bool _androidLooksLikeEmail(String value) => + RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(value); + final class AndroidEventAttendeeEdit { const AndroidEventAttendeeEdit({ required this.attendees, diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index e0fe74a4..20d48f42 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -1,8 +1,12 @@ +import 'android_contacts_settings.dart'; import '../../l10n/oauth_error_description.dart'; + import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import '../../l10n/registration_description.dart'; import 'android_registration_setup_dialog.dart'; import '../../core/auth/oauth_registration.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -83,6 +87,7 @@ class _AndroidSettingsScreenState extends ConsumerState { body: ListView( padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), children: [ + const AndroidContactsSettings(), _Section( title: context.l10n.accounts, children: [ @@ -170,9 +175,8 @@ class _AndroidSettingsScreenState extends ConsumerState { (account) => account.isSignedIn && account.calendarsEnabled && - accountCollectionCreationModes( - account.provider, - ).calendarMode != + accountCollectionCreationModes(account.provider) + .calendarMode != CalendarCollectionCreationMode.unavailable, )) ListTile( @@ -222,9 +226,8 @@ class _AndroidSettingsScreenState extends ConsumerState { (account) => account.isSignedIn && account.tasksEnabled && - accountCollectionCreationModes( - account.provider, - ).taskListMode != + accountCollectionCreationModes(account.provider) + .taskListMode != TaskListCreationMode.unavailable, )) ListTile( @@ -806,9 +809,8 @@ class _AndroidSettingsScreenState extends ConsumerState { case BusyProvider.nextcloud: final service = ref.read(davAccountOnboardingServiceProvider); accountId = reconnecting == null - ? (await service.connectNextcloud( - enteredServer: server!, - )).accountId + ? (await service.connectNextcloud(enteredServer: server!)) + .accountId : (await service.reconnectNextcloud( accountId: reconnecting.id, enteredServer: server!, @@ -1705,9 +1707,8 @@ class _AndroidSettingsScreenState extends ConsumerState { void _message(String message) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); } } diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index 52093e23..993aec9e 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -1,6 +1,8 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'dart:async'; import 'dart:convert'; + import 'package:busymax_android_platform/busymax_android_platform.dart'; import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; @@ -327,9 +329,8 @@ class _AndroidTasksScreenState extends ConsumerState { } on Object catch (error) { if (mounted) { setState(() => _mutationIntent = null); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } } @@ -363,9 +364,8 @@ class _AndroidTasksScreenState extends ConsumerState { .clearCompleted(listId); } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } } @@ -1527,9 +1527,8 @@ class _AndroidTaskEditorState extends ConsumerState { } } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } } @@ -1578,9 +1577,8 @@ class _AndroidTaskEditorState extends ConsumerState { ); } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } } @@ -1981,9 +1979,8 @@ class _AndroidTaskEditorState extends ConsumerState { widget.onMutationFailed?.call(); } if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); @@ -2188,9 +2185,8 @@ class _AndroidTaskEditorState extends ConsumerState { } on Object catch (error) { if (!mutationPersisted) widget.onMutationFailed?.call(); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); @@ -2212,15 +2208,13 @@ class _AndroidTaskEditorState extends ConsumerState { ), ); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.duplicateTask))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(context.l10n.duplicateTask))); } } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); @@ -2238,9 +2232,8 @@ class _AndroidTaskEditorState extends ConsumerState { .toList(); } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } return; } @@ -2314,9 +2307,8 @@ class _AndroidTaskEditorState extends ConsumerState { } on Object catch (error) { if (!mutationPersisted) widget.onMutationFailed?.call(); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); @@ -2335,15 +2327,13 @@ class _AndroidTaskEditorState extends ConsumerState { ), ); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.moveToTop))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(context.l10n.moveToTop))); } } on Object catch (error) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$error'))); } } finally { if (mounted) setState(() => _saving = false); diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 0adb664d..b476f134 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -3,18 +3,24 @@ import '../features/sync/domain_sync_schedule.dart'; import '../core/auth/authorization_persistence.dart'; import '../core/auth/registration_staging.dart'; import '../core/auth/oauth_registration.dart'; + import 'dart:async'; +import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:busystack_contacts/busystack_contacts.dart'; import 'package:uuid/uuid.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../core/time/provider_date_time.dart'; +import '../contacts/busymax_contacts_controller.dart'; +import '../contacts/busymax_contacts_store.dart'; import '../core/auth/account_token_broker.dart'; import '../core/http/native_terminating_http_client.dart'; import '../core/time/stored_temporal_projection.dart'; @@ -336,6 +342,52 @@ final accountTokenBrokerProvider = Provider((ref) { ); }); +final busyMaxContactsStoreProvider = Provider((ref) { + return BusyMaxContactsStore(ref.watch(databaseProvider)); +}); + +final busyMaxContactsControllerProvider = Provider(( + ref, +) { + final broker = ref.watch(accountTokenBrokerProvider); + if (broker is! ContactsAuthorizationBroker) { + throw StateError('Contacts authorization is unavailable.'); + } + final storage = ref.watch(secureStorageProvider); + String key(String accountId) => + 'busymax.contacts.dav.${base64UrlEncode(utf8.encode(accountId))}'; + final controller = BusyMaxContactsController( + store: ref.watch(busyMaxContactsStoreProvider), + accounts: ref.watch(accountsRepositoryProvider), + authorization: broker as ContactsAuthorizationBroker, + readAuthorizationGeneration: ref.watch( + authorizationGenerationReaderProvider, + ), + httpClient: ref.watch(baseHttpClientProvider), + readDavSecret: (accountId) => storage.read(key: key(accountId)), + writeDavSecret: (accountId, value) => + storage.write(key: key(accountId), value: value), + deleteDavSecret: (accountId) => storage.delete(key: key(accountId)), + readLinkedDavCredential: (accountId) async { + final credential = await ref + .read(secretStoreProvider) + .readCredential(accountId); + return credential is NextcloudSecretRecord + ? BusyMaxLinkedDavCredential( + server: credential.canonicalServer, + username: credential.loginName, + password: credential.appPassword, + ) + : null; + }, + launchBrowser: (uri) => + launchUrl(uri, mode: LaunchMode.externalApplication), + ); + controller.startInBackground(); + ref.onDispose(() => unawaited(controller.close())); + return controller; +}); + final authenticatedHttpClientProvider = Provider((ref) { return AuthenticatedHttpClient( inner: ref.watch(retryingHttpClientProvider), @@ -383,6 +435,59 @@ final operationalNotificationReporterProvider = ); }); +Stream _contactsConfigurationStream( + BusyMaxContactsController controller, + Future Function() read, +) => Stream.multi((output) { + var disposed = false; + var generation = 0; + void reload() { + final captured = ++generation; + unawaited( + Future.sync(read).then( + (value) { + if (!disposed && captured == generation) output.add(value); + }, + onError: (Object error, StackTrace stack) { + if (!disposed && captured == generation) { + output.addError(error, stack); + } + }, + ), + ); + } + + final subscription = controller.changes.listen( + (_) => reload(), + onError: output.addError, + ); + output.onCancel = () { + disposed = true; + generation++; + return subscription.cancel(); + }; + reload(); +}); + +final busyMaxContactAccountsProvider = StreamProvider>(( + ref, +) { + final controller = ref.watch(busyMaxContactsControllerProvider); + return _contactsConfigurationStream( + controller, + () => controller.store.read((tx) => tx.accounts()), + ); +}); + +final busyMaxContactSourceSettingsProvider = + StreamProvider>((ref) { + final controller = ref.watch(busyMaxContactsControllerProvider); + return _contactsConfigurationStream( + controller, + controller.sourceSettings, + ); + }); + final desktopWindowServiceProvider = Provider( (ref) => const NoOpDesktopWindowService(), ); @@ -463,6 +568,24 @@ final authRepositoryProvider = Provider((ref) { database: ref.watch(databaseProvider), accountsRepository: ref.watch(accountsRepositoryProvider), microsoftOAuth: ref.watch(applicationMicrosoftOAuthServiceProvider), + onAccountRemovalStarting: (accountId) => ref + .read(busyMaxContactsControllerProvider) + .beginLinkedAccountRemoval(accountId), + onAccountRemoving: (accountId) => ref + .read(busyMaxContactsControllerProvider) + .prepareLinkedAccountRemoval(accountId), + persistLinkedAccountRemoval: (accountId) => ref + .read(busyMaxContactsControllerProvider) + .persistLinkedAccountRemoval(accountId), + onAccountRemovalFailed: (accountId) => ref + .read(busyMaxContactsControllerProvider) + .restoreLinkedAccountAfterFailedRemoval(accountId), + onAccountRemoved: () => ref + .read(busyMaxContactsControllerProvider) + .linkedAccountRemovalCommitted(), + onAccountRemovalFinished: (accountId) => ref + .read(busyMaxContactsControllerProvider) + .finishLinkedAccountRemoval(accountId), ); }); @@ -510,9 +633,8 @@ final davConflictResolutionServiceProvider = database: database, pendingQueue: DavPendingOperationQueue(database: database), rebuildNotifications: (accountId, _) async { - await NotificationScheduleService( - database: database, - ).rebuildUpcomingNotifications(accountId); + await NotificationScheduleService(database: database) + .rebuildUpcomingNotifications(accountId); }, ); }); @@ -612,12 +734,21 @@ final googleEventLabelsForCalendarProvider = final microsoftTodoApiClientForAccountProvider = Provider.family((ref, accountId) { final config = ref.watch(buildConfigProvider); + final broker = ref.watch(accountTokenBrokerProvider); return MicrosoftTodoRestApiClient( - httpClient: ref.watch(retryingHttpClientProvider), + httpClient: ref.watch(baseHttpClientProvider), baseUri: Uri.parse(config.microsoftGraphBaseUrl), - authorizationHeaderProvider: () => ref - .read(accountTokenBrokerProvider) - .authorizationHeader(BusyProvider.microsoft, accountId), + authorizationHeaderWithClaimsProvider: (claims) => + broker is MicrosoftGraphAuthorizationBroker + ? (broker as MicrosoftGraphAuthorizationBroker) + .microsoftGraphAuthorizationHeader( + accountId, + MicrosoftGraphAuthorizationKind.ordinary, + claims: claims, + ) + : claims == null + ? broker.authorizationHeader(BusyProvider.microsoft, accountId) + : throw StateError('Claims authorization is unavailable.'), unauthorizedRefreshProvider: () => ref .read(accountTokenBrokerProvider) .recoverUnauthorized(BusyProvider.microsoft, accountId), @@ -734,20 +865,41 @@ final microsoftCalendarApiClientForAccountProvider = .valueOrNull ?.where((candidate) => candidate.id == accountId) .firstOrNull; + final broker = ref.watch(accountTokenBrokerProvider); + Future authorization( + MicrosoftGraphAuthorizationKind kind, + String? claims, + ) => broker is MicrosoftGraphAuthorizationBroker + ? (broker as MicrosoftGraphAuthorizationBroker) + .microsoftGraphAuthorizationHeader( + accountId, + kind, + claims: claims, + ) + : claims == null + ? switch (kind) { + MicrosoftGraphAuthorizationKind.ordinary => + broker.authorizationHeader(BusyProvider.microsoft, accountId), + MicrosoftGraphAuthorizationKind.sharedCalendar => + broker.microsoftSharedCalendarAuthorizationHeader(accountId), + MicrosoftGraphAuthorizationKind.category => + broker.microsoftCategoryAuthorizationHeader(accountId), + } + : throw StateError('Claims authorization is unavailable.'); return MicrosoftCalendarApiClient( - httpClient: ref.watch(retryingHttpClientProvider), + httpClient: ref.watch(baseHttpClientProvider), baseUri: Uri.parse(config.microsoftGraphBaseUrl), responseTimeZone: ref.watch(localTimeZoneProvider), accountTenantId: account?.tenantId, - authorizationHeaderProvider: () => ref - .read(accountTokenBrokerProvider) - .authorizationHeader(BusyProvider.microsoft, accountId), - sharedCalendarAuthorizationHeaderProvider: () => ref - .read(accountTokenBrokerProvider) - .microsoftSharedCalendarAuthorizationHeader(accountId), - categoryAuthorizationHeaderProvider: () => ref - .read(accountTokenBrokerProvider) - .microsoftCategoryAuthorizationHeader(accountId), + authorizationHeaderWithClaimsProvider: (claims) => + authorization(MicrosoftGraphAuthorizationKind.ordinary, claims), + sharedCalendarAuthorizationHeaderWithClaimsProvider: (claims) => + authorization( + MicrosoftGraphAuthorizationKind.sharedCalendar, + claims, + ), + categoryAuthorizationHeaderWithClaimsProvider: (claims) => + authorization(MicrosoftGraphAuthorizationKind.category, claims), unauthorizedRefreshProvider: () => ref .read(accountTokenBrokerProvider) .recoverUnauthorized(BusyProvider.microsoft, accountId), @@ -827,8 +979,10 @@ final calendarRemoteApiClientForAccountProvider = }; }); -typedef SyncEngineForAccountFactory = - SyncEngine Function(String accountId, BusyProvider provider); +typedef SyncEngineForAccountFactory = SyncEngine Function( + String accountId, + BusyProvider provider, +); final syncEngineForAccountFactoryProvider = Provider((ref) { @@ -869,8 +1023,10 @@ final syncEngineForAccountFactoryProvider = }; }); -typedef CalendarSyncEngineForAccountFactory = - CalendarSyncEngine Function(String accountId, BusyProvider provider); +typedef CalendarSyncEngineForAccountFactory = CalendarSyncEngine Function( + String accountId, + BusyProvider provider, +); final calendarSyncEngineForAccountFactoryProvider = Provider((ref) { @@ -911,8 +1067,9 @@ final calendarSyncEngineForAccountFactoryProvider = }; }); -typedef DavAccountSyncEngineFactory = - DavAccountSyncEngine Function(String accountId); +typedef DavAccountSyncEngineFactory = DavAccountSyncEngine Function( + String accountId, +); final davAccountSyncEngineFactoryProvider = Provider((ref) { @@ -1047,8 +1204,10 @@ final accountSyncOperationsProvider = Provider((ref) { ); }); -typedef SignedInSyncRunner = - Future Function(String accountId, bool initial); +typedef SignedInSyncRunner = Future Function( + String accountId, + bool initial, +); final signedInSyncRunnerProvider = Provider((ref) { return (accountId, initial) async { @@ -1173,9 +1332,8 @@ final icalImportServiceProvider = Provider((ref) { calendarRepository: ref.watch(calendarRepositoryProvider), onNativeImported: (accountId) async { try { - await NotificationScheduleService( - database: ref.read(databaseProvider), - ).rebuildUpcomingNotifications(accountId); + await NotificationScheduleService(database: ref.read(databaseProvider)) + .rebuildUpcomingNotifications(accountId); await ref.read(notificationReconcilerProvider).reconcile(); } finally { ref @@ -1561,9 +1719,8 @@ final trayPresentationServiceProvider = canCreateTask: (account, taskList) async { if (account.provider == BusyProvider.google || account.provider == BusyProvider.microsoft) { - return adapterDefaultTaskCapabilities( - account.provider, - ).canCreateTasks; + return adapterDefaultTaskCapabilities(account.provider) + .canCreateTasks; } if (account.provider != BusyProvider.nextcloud) return false; final capabilities = await ref.read( diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 9b6556ac..b9f66ef7 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -36,8 +36,9 @@ import '../features/calendar/presentation/ical_import_flow.dart'; import '../core/logging/redacting_logger.dart'; import 'common/desktop_calendar_open_readiness.dart'; -typedef BusyMaxTrayServiceFactory = - BusyMaxTrayService Function(BusyMaxTrayServiceConfiguration configuration); +typedef BusyMaxTrayServiceFactory = BusyMaxTrayService Function( + BusyMaxTrayServiceConfiguration configuration, +); BusyMaxNativeSurfaceTheme busyMaxNativeSurfaceThemeFor( ThemeData theme, { diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index 9c7d4179..cd24e0e5 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -645,9 +645,8 @@ ButtonStyle busyMaxHeaderTextButtonStyle( WidgetStateProperty? backgroundColor, WidgetStateProperty? overlayColor, }) { - final disabledForeground = BusyMaxSurfaceColors.of( - context, - ).disabledForeground; + final disabledForeground = BusyMaxSurfaceColors.of(context) + .disabledForeground; return ButtonStyle( minimumSize: const WidgetStatePropertyAll( Size(BusyMaxSizes.headerIconButton, BusyMaxSizes.headerIconButton), @@ -1293,9 +1292,8 @@ InputDecoration busyMaxGroupedTextFieldDecoration( if (errorText == null) { return resolved; } - final errorLabelStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.error); + final errorLabelStyle = Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.error); return resolved.copyWith( labelStyle: errorLabelStyle, floatingLabelStyle: errorLabelStyle, @@ -1509,9 +1507,8 @@ class BusyMaxGroupedList extends StatelessWidget { padding: padding, child: Text( description!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), ], @@ -1756,8 +1753,10 @@ class _BusyMaxGroupedListSurface extends StatelessWidget { } } -typedef BusyMaxRowActivationCallback = - void Function(BuildContext context, Offset? globalPosition); +typedef BusyMaxRowActivationCallback = void Function( + BuildContext context, + Offset? globalPosition, +); class BusyMaxActionRow extends StatefulWidget { const BusyMaxActionRow({ @@ -2365,9 +2364,8 @@ class BusyMaxComboRow extends StatelessWidget { final subtitleWidget = hasError ? Text( errorText!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), ) : subtitle == null ? null @@ -2434,9 +2432,8 @@ class BusyMaxComboRow extends StatelessWidget { child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maximumValueWidth), child: DefaultTextStyle.merge( - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: valueForeground, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: valueForeground), child: IconTheme.merge( data: IconThemeData( color: valueForeground, @@ -2559,8 +2556,10 @@ class BusyMaxComboRow extends StatelessWidget { } } -typedef BusyMaxYaruFocusedControlBuilder = - Widget Function(BuildContext context, FocusNode focusNode); +typedef BusyMaxYaruFocusedControlBuilder = Widget Function( + BuildContext context, + FocusNode focusNode, +); /// Presents Yaru's outline around the existing focus target during keyboard /// navigation without adding another focus node or interaction handler. @@ -3152,8 +3151,10 @@ Widget _busyMaxFallbackMenuEntry( return Tooltip(message: entry.tooltip!, child: content); } -typedef BusyMaxMenuTriggerBuilder = - Widget Function(BuildContext context, BusyMaxMenuTriggerDetails trigger); +typedef BusyMaxMenuTriggerBuilder = Widget Function( + BuildContext context, + BusyMaxMenuTriggerDetails trigger, +); /// Interaction and geometry supplied to a custom [BusyMaxMenuButton] trigger. /// @@ -4001,9 +4002,8 @@ class BusyMaxInlineBadge extends StatelessWidget { vertical: BusyMaxSpacing.xxs, ), borderRadius: BorderRadius.circular(BusyMaxRadius.sm), - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.onSurfaceVariant), title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ); return tooltip == null ? badge : Tooltip(message: tooltip!, child: badge); diff --git a/lib/src/app/busymax_dialog_identity.dart b/lib/src/app/busymax_dialog_identity.dart index 59fc4f5a..6d9eeeb2 100644 --- a/lib/src/app/busymax_dialog_identity.dart +++ b/lib/src/app/busymax_dialog_identity.dart @@ -79,9 +79,8 @@ class BusyMaxDialogIdentity extends StatelessWidget { @override Widget build(BuildContext context) { final titleStyle = - Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: titleWeight) ?? + Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: titleWeight) ?? const TextStyle(fontWeight: titleWeight); return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/src/app/common/busymax_mutation_list.dart b/lib/src/app/common/busymax_mutation_list.dart index fb32a057..f548b919 100644 --- a/lib/src/app/common/busymax_mutation_list.dart +++ b/lib/src/app/common/busymax_mutation_list.dart @@ -3,8 +3,11 @@ import 'package:flutter/widgets.dart'; import '../../schedule/task_list_mutation_intent.dart'; import 'busymax_design_values.dart'; -typedef BusyMaxMutationItemBuilder = - Widget Function(BuildContext context, T item, int index); +typedef BusyMaxMutationItemBuilder = Widget Function( + BuildContext context, + T item, + int index, +); /// Lazy list reconciliation for one identified, successful local mutation. /// Unrelated data changes replace the projection without entrance effects. diff --git a/lib/src/app/linux/linux_header_style.dart b/lib/src/app/linux/linux_header_style.dart index 0384de20..91f12e27 100644 --- a/lib/src/app/linux/linux_header_style.dart +++ b/lib/src/app/linux/linux_header_style.dart @@ -375,9 +375,8 @@ class BusyMaxGtkHeaderIcon extends StatelessWidget { @override Widget build(BuildContext context) { final effectiveDirection = direction ?? Directionality.of(context); - final asset = GtkHeaderIconScope.of( - context, - ).catalog.assetFor(icon, effectiveDirection); + final asset = GtkHeaderIconScope.of(context).catalog + .assetFor(icon, effectiveDirection); final color = IconTheme.of(context).color; return ExcludeSemantics( child: SizedBox.square( @@ -990,9 +989,8 @@ class _BusyMaxLinuxHeaderSearchFieldState Semantics( container: true, button: true, - label: MaterialLocalizations.of( - context, - ).clearButtonTooltip, + label: MaterialLocalizations.of(context) + .clearButtonTooltip, onTap: _clear, child: ExcludeSemantics( key: BusyMaxLinuxHeaderSearchField diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart index 783cd1d6..2fab597c 100644 --- a/lib/src/app/windows/windows_busymax_app.dart +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -312,9 +312,8 @@ class _WindowsBusyMaxAppState extends ConsumerState preference: settings.firstDayOfWeekPreference, systemValueInitialized: _firstWeekdayController.isInitialized, child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: clock.use24Hour), + data: MediaQuery.of(context) + .copyWith(alwaysUse24HourFormat: clock.use24Hour), child: WindowsDesktopRuntime( startMinimizedAtLaunch: widget.startMinimizedAtLaunch, child: child ?? const SizedBox.shrink(), diff --git a/lib/src/calendar_providers/attachment_upload_session.dart b/lib/src/calendar_providers/attachment_upload_session.dart index 6aac5f7d..a9c4118f 100644 --- a/lib/src/calendar_providers/attachment_upload_session.dart +++ b/lib/src/calendar_providers/attachment_upload_session.dart @@ -18,9 +18,8 @@ final class MicrosoftAttachmentUploadSession { if (ranges is! List || ranges.length != 1 || ranges.single is! String) { throw const FormatException('Invalid attachment upload progress.'); } - final match = RegExp( - r'^(\d+)(?:-\d*)?$', - ).firstMatch(ranges.single as String); + final match = RegExp(r'^(\d+)(?:-\d*)?$') + .firstMatch(ranges.single as String); final offset = match == null ? null : int.tryParse(match.group(1)!); if (offset == null || offset < 0 || offset > totalBytes) { throw const FormatException('Invalid attachment upload progress.'); diff --git a/lib/src/calendar_providers/calendar_provider_capabilities.dart b/lib/src/calendar_providers/calendar_provider_capabilities.dart index f70e5b9d..dbba45f7 100644 --- a/lib/src/calendar_providers/calendar_provider_capabilities.dart +++ b/lib/src/calendar_providers/calendar_provider_capabilities.dart @@ -9,9 +9,8 @@ MicrosoftAvailabilityAccountType microsoftAvailabilityAccountType( ) { final normalized = tenantId?.trim().toLowerCase(); if (normalized == null || - !RegExp( - r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', - ).hasMatch(normalized)) { + !RegExp(r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$') + .hasMatch(normalized)) { return MicrosoftAvailabilityAccountType.unknown; } return normalized == microsoftPersonalTenantId diff --git a/lib/src/calendar_providers/calendar_sync_dto.dart b/lib/src/calendar_providers/calendar_sync_dto.dart index 11fd23ea..decedc79 100644 --- a/lib/src/calendar_providers/calendar_sync_dto.dart +++ b/lib/src/calendar_providers/calendar_sync_dto.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/providers/busy_provider.dart'; + import '../features/maps/domain/geographic_point.dart'; class CalendarSourceDto { diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 7045e785..44c428a8 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/providers/busy_provider.dart'; + import 'calendar_mutation.dart'; import 'calendar_provider_capabilities.dart'; import 'calendar_sync_dto.dart'; diff --git a/lib/src/config/desktop_oauth_configuration.dart b/lib/src/config/desktop_oauth_configuration.dart index 8e7b004f..5c3ba41e 100644 --- a/lib/src/config/desktop_oauth_configuration.dart +++ b/lib/src/config/desktop_oauth_configuration.dart @@ -5,9 +5,8 @@ bool validGoogleDesktopConfiguration({ required String clientSecret, required String projectId, }) => - RegExp( - r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$', - ).hasMatch(clientId.trim()) && + RegExp(r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$') + .hasMatch(clientId.trim()) && RegExp(r'^[a-z][a-z0-9-]{4,62}[a-z0-9]$').hasMatch(projectId.trim()) && clientSecret.trim().isNotEmpty; diff --git a/lib/src/contacts/busymax_contacts_controller.dart b/lib/src/contacts/busymax_contacts_controller.dart new file mode 100644 index 00000000..f0a3d9aa --- /dev/null +++ b/lib/src/contacts/busymax_contacts_controller.dart @@ -0,0 +1,1227 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:logging/logging.dart'; + +import 'package:busystack_contacts/carddav.dart'; +import 'package:busystack_contacts/google.dart'; +import 'package:busystack_contacts/microsoft.dart'; +import 'package:busystack_dav/carddav.dart'; +import 'package:busystack_dav/nextcloud.dart'; +import 'package:busystack_graph/busystack_graph.dart'; +import 'package:http/http.dart' as http; + +import '../core/auth/account_token_broker.dart'; +import '../core/auth/authorization_attempt.dart'; +import '../core/auth/microsoft_graph_scopes.dart'; +import '../features/accounts/data/accounts_repository.dart'; +import '../google_tasks/oauth/oauth_service.dart'; +import '../providers/busy_provider.dart'; +import 'busymax_contacts_store.dart'; +import 'read_only_contacts_provider.dart'; + +typedef DavContactSecretReader = Future Function(String accountId); +typedef DavContactSecretWriter = Future Function( + String accountId, + String value, +); +typedef DavContactSecretDeleter = Future Function(String accountId); + +final class BusyMaxLinkedDavCredential { + const BusyMaxLinkedDavCredential({ + required this.server, + required this.username, + required this.password, + }); + + final Uri server; + final String username; + final String password; +} + +final class BusyMaxContactSuggestion { + const BusyMaxContactSuggestion({ + required this.displayName, + required this.email, + required this.identities, + }); + + final String displayName; + final String email; + final List identities; +} + +final class BusyMaxContactSourceSetting { + const BusyMaxContactSourceSetting({ + required this.source, + required this.enabled, + }); + + final ContactSource source; + final bool enabled; +} + +/// App-owned lifecycle and authorization adapter around the shared contacts +/// directory. Provider HTTP, normalization, sync and mutation behavior remain +/// in busystack_contacts; BusyMax owns its database, credentials and schedule. +final class BusyMaxContactsController { + BusyMaxContactsController({ + required this.store, + required this.accounts, + required this.authorization, + required this.httpClient, + required this.readDavSecret, + required this.writeDavSecret, + required this.deleteDavSecret, + required this.readLinkedDavCredential, + required this.launchBrowser, + this.syncInterval = const Duration(minutes: 15), + ContactsProvider Function(ContactAccount)? providerFactory, + this.readAuthorizationGeneration, + }) : _providerFactory = providerFactory, + directory = ContactsDirectory(store: store); + + final BusyMaxContactsStore store; + final AccountsRepository accounts; + final ContactsAuthorizationBroker authorization; + final http.Client httpClient; + final DavContactSecretReader readDavSecret; + final DavContactSecretWriter writeDavSecret; + final DavContactSecretDeleter deleteDavSecret; + final Future Function(String accountId) + readLinkedDavCredential; + final Future Function(Uri) launchBrowser; + final Duration syncInterval; + final ContactsDirectory directory; + final ContactsProvider Function(ContactAccount)? _providerFactory; + final Future Function(String accountId)? readAuthorizationGeneration; + + final Map> _syncing = >{}; + Timer? _timer; + Future? _startFuture; + Future? _closeFuture; + bool _closed = false; + final Set> _background = {}; + final Map _enrollments = {}; + final Map _linkedRemovals = {}; + final Map _boundGenerations = {}; + final Logger _log = Logger('busymax.contacts'); + + void _launch(Future Function() action) { + if (_closed) return; + late final Future observed; + observed = Future.sync(action) + .then( + (_) {}, + onError: (Object error, StackTrace stack) { + if (error is ContactsException && + error.kind == ContactsFailure.cancelled) { + return; + } + _log.warning('Scheduled contacts work failed', error, stack); + }, + ) + .whenComplete(() => _background.remove(observed)); + _background.add(observed); + } + + Future _drain(Iterable> work) => Future.wait( + work.map( + (future) => future.then( + (_) {}, + onError: (Object error, StackTrace stack) {}, + ), + ), + ); + + Stream get changes => store.changes; + + Future start() => _startFuture ??= _start(); + + Future _start() async { + await store.normalizeReadOnlySources(); + await directory.restore(); + final contactAccounts = await store.read((tx) => tx.accounts()); + for (final account in contactAccounts) { + if (!account.enabled) continue; + try { + await _bind(account); + } on Object { + await store.write((tx) async { + final current = await tx.account(account.id); + if (current != null) { + await tx.putAccount(current.copy(errorCode: 'reconnect-required')); + } + }); + } + } + if (_closed) return; + if (contactAccounts.any((account) => account.enabled)) { + _ensureScheduling(); + _launch(synchronizeAll); + } + } + + void startInBackground() => _launch(start); + void _ensureScheduling() { + if (!_closed && _timer == null) { + _timer = Timer.periodic(syncInterval, (_) => _launch(synchronizeAll)); + } + } + + Future> suggestAttendees( + String query, { + String? busyMaxAccountId, + Set excludedAddresses = const {}, + int limit = 8, + }) async { + await start(); + final selection = await store.selection(busyMaxAccountId: busyMaxAccountId); + if (selection.accountIds.isEmpty) return const []; + final recipients = await directory.suggestRecipients( + query.trim(), + accountIds: selection.accountIds, + sourceKeys: selection.sourceKeys, + excludedAddresses: excludedAddresses, + limit: limit, + ); + final currentSelection = await store.selection( + busyMaxAccountId: busyMaxAccountId, + ); + return recipients + .where( + (recipient) => recipient.identities.any( + (identity) => + currentSelection.accountIds.contains(identity.accountId) && + currentSelection.sourceKeys.contains( + jsonEncode([ + identity.accountId, + identity.provider.name, + identity.sourceId, + ]), + ), + ), + ) + .map( + (recipient) => BusyMaxContactSuggestion( + displayName: recipient.name, + email: recipient.email, + identities: recipient.identities, + ), + ) + .toList(growable: false); + } + + Future suggestionEligible( + BusyMaxContactSuggestion suggestion, { + required String busyMaxAccountId, + }) async { + if (_closed) return false; + final selected = await store.selection(busyMaxAccountId: busyMaxAccountId); + for (final identity in suggestion.identities) { + final key = jsonEncode([ + identity.accountId, + identity.provider.name, + identity.sourceId, + ]); + if (!selected.accountIds.contains(identity.accountId) || + !selected.sourceKeys.contains(key)) { + continue; + } + final record = await store.read((tx) => tx.contact(identity)); + if (record?.projection.emails.any( + (email) => + email.value.toLowerCase() == suggestion.email.toLowerCase(), + ) == + true) { + return true; + } + } + return false; + } + + Future> sourceSettings() async { + final sources = await store.read((tx) => tx.sources()); + return Future.wait([ + for (final source in sources) + store + .sourceEnabled(source.key) + .then( + (enabled) => + BusyMaxContactSourceSetting(source: source, enabled: enabled), + ), + ]); + } + + Future _enroll( + String key, + Future Function(_ContactsEnrollment) action, + ) async { + if (_closed) { + throw const ContactsException(ContactsFailure.cancelled, 'closed'); + } + if (_linkedRemovals.containsKey(key)) { + throw const ContactsException( + ContactsFailure.cancelled, + 'parent-account-removing', + ); + } + if (_enrollments.containsKey(key)) { + throw const ContactsException( + ContactsFailure.cancelled, + 'enrollment-in-progress', + ); + } + final attempt = _ContactsEnrollment(); + _enrollments[key] = attempt; + try { + return await action(attempt); + } finally { + attempt.cancel(); + _enrollments.remove(key); + attempt.done.complete(); + } + } + + Future _cancelEnrollment(String key) async { + final attempt = _enrollments[key]; + if (attempt == null) return; + attempt.cancel(); + await attempt.done.future; + } + + void _checkEnrollment(_ContactsEnrollment attempt) { + if (_closed || attempt.cancelled) { + throw const ContactsException( + ContactsFailure.cancelled, + 'stale-enrollment', + ); + } + } + + Future enableLinkedContacts( + String busyMaxAccountId, + ) => _enroll(linkedContactAccountId(busyMaxAccountId), (attempt) async { + await start(); + _checkEnrollment(attempt); + final parent = await accounts.accountById(busyMaxAccountId); + if (parent == null || + parent.provider != BusyProvider.google && + parent.provider != BusyProvider.microsoft) { + throw StateError('Only Google and Microsoft accounts can reuse OAuth.'); + } + final contactId = linkedContactAccountId(busyMaxAccountId); + final previous = await store.read((tx) => tx.account(contactId)); + final provider = parent.provider == BusyProvider.google + ? ContactProviderKind.google + : ContactProviderKind.microsoft; + final grantedScopes = parent.provider == BusyProvider.google + ? {googleContactsReadScope} + : {'Contacts.Read'}; + var contactAccount = ContactAccount( + id: contactId, + provider: provider, + displayName: parent.displayLabel, + subject: parent.providerAccountId, + grantedScopes: grantedScopes, + generation: (previous?.generation ?? -1) + 1, + ); + await authorization.authorizeContacts( + parent.provider, + busyMaxAccountId, + cancellation: attempt.authorization, + persistContacts: () async { + _checkEnrollment(attempt); + final granted = await accounts.accountById(busyMaxAccountId); + if (granted == null || + granted.providerAccountId != parent.providerAccountId || + granted.provider != parent.provider || + granted.tenantId != parent.tenantId) { + throw const ContactsException( + ContactsFailure.cancelled, + 'parent-account-changed', + ); + } + final scopes = granted.grantedScopes + .split(RegExp(r'\s+')) + .where((s) => s.isNotEmpty) + // The shared contacts adapter consumes short Graph scope names; + // parent metadata and credentials retain the provider's grant text. + .map( + (scope) => + provider == ContactProviderKind.microsoft && + scope.toLowerCase().startsWith( + 'https://graph.microsoft.com/', + ) + ? scope.substring('https://graph.microsoft.com/'.length) + : scope, + ) + .toSet(); + if (!scopes.any( + (scope) => + microsoftGraphScopeName(scope) == + microsoftGraphScopeName(grantedScopes.single) || + microsoftGraphScopeName(scope) == + (provider == ContactProviderKind.google + ? googleContactsWriteScope + : 'contacts.readwrite'), + )) { + throw const ContactsException( + ContactsFailure.permission, + 'contacts-permission-not-granted', + ); + } + contactAccount = ContactAccount( + id: contactAccount.id, + provider: contactAccount.provider, + displayName: contactAccount.displayName, + subject: contactAccount.subject, + grantedScopes: scopes, + generation: contactAccount.generation, + ); + await store.persistLinkedAccount( + account: contactAccount, + busyMaxAccountId: busyMaxAccountId, + reuseAuthorization: true, + ); + }, + ); + _checkEnrollment(attempt); + store.contactConfigurationChanged(); + if (previous?.enabled == true) await _detachForReplacement(contactId); + _checkEnrollment(attempt); + try { + await _bind(contactAccount); + } on Object { + await store.write( + (tx) => tx.putAccount( + contactAccount.copy(enabled: false, errorCode: 'reconnect-required'), + ), + ); + rethrow; + } + _ensureScheduling(); + _launch(() => synchronizeAccount(contactId)); + }); + + Future _detachForReplacement(String id) async { + await _detachBinding(id); + final running = _syncing[id]; + if (running != null) { + try { + await running; + } on Object { + /* The original caller owns the error. */ + } + } + } + + Future enableLinkedNextcloudContacts(String busyMaxAccountId) => + _enroll(linkedContactAccountId(busyMaxAccountId), (attempt) async { + await start(); + _checkEnrollment(attempt); + final parent = await accounts.accountById(busyMaxAccountId); + if (parent == null || parent.provider != BusyProvider.nextcloud) { + throw StateError('The selected Nextcloud account is unavailable.'); + } + final credential = await readLinkedDavCredential(busyMaxAccountId); + if (credential == null) { + throw StateError('The selected Nextcloud credential is unavailable.'); + } + _checkEnrollment(attempt); + final current = await accounts.accountById(busyMaxAccountId); + if (current?.providerAccountId != parent.providerAccountId || + current?.provider != parent.provider) { + throw const ContactsException( + ContactsFailure.cancelled, + 'parent-account-changed', + ); + } + final contactId = linkedContactAccountId(busyMaxAccountId); + final previous = await store.read((tx) => tx.account(contactId)); + final contactAccount = ContactAccount( + id: contactId, + provider: ContactProviderKind.carddav, + displayName: parent.displayLabel, + subject: credential.username, + grantedScopes: previous?.grantedScopes ?? {'carddav:read'}, + generation: (previous?.generation ?? -1) + 1, + ); + await store.saveLinkedAccount( + account: contactAccount, + busyMaxAccountId: busyMaxAccountId, + reuseAuthorization: true, + ); + if (previous?.enabled == true) await _detachForReplacement(contactId); + _checkEnrollment(attempt); + await _bind(contactAccount); + _ensureScheduling(); + _launch(() => synchronizeAccount(contactId)); + }); + + Future addCardDavContactsOnly({ + required String id, + required String label, + required Uri server, + required String username, + required String password, + bool nextcloud = false, + }) => _enroll( + id, + (attempt) => _addCardDav( + attempt, + id: id, + label: label, + server: server, + username: username, + password: password, + nextcloud: nextcloud, + ), + ); + + Future _addCardDav( + _ContactsEnrollment attempt, { + required String id, + required String label, + required Uri server, + required String username, + required String password, + required bool nextcloud, + }) async { + await start(); + _checkEnrollment(attempt); + _requireDavServer(server); + if (await store.read((tx) => tx.account(id)) != null) { + throw StateError('Contacts account already exists'); + } + final credential = DavBasicCredential( + username: username, + password: password, + ); + final account = ContactAccount( + id: id, + provider: ContactProviderKind.carddav, + displayName: label, + subject: username, + grantedScopes: {'carddav:read'}, + ); + final secret = jsonEncode({ + 'version': 1, + 'server': server.toString(), + 'username': credential.username, + 'password': password, + 'readOnly': true, + 'nextcloud': nextcloud, + }); + var durable = false; + try { + _checkEnrollment(attempt); + await writeDavSecret(id, secret); + _checkEnrollment(attempt); + await store.write((tx) => tx.putAccount(account)); + durable = true; + _checkEnrollment(attempt); + await _bind(account); + _ensureScheduling(); + await synchronizeAccount(id); + return account; + } on Object catch (error, stack) { + try { + if (durable) { + _boundGenerations.remove(id); + await directory.disconnect(id); + await store.write((tx) async { + final current = await tx.account(id); + if (current != null) { + await tx.putAccount( + current.copy(errorCode: 'reconnect-required'), + ); + } + }); + } else { + await deleteDavSecret(id); + } + } on Object catch (cleanup, cleanupStack) { + _log.warning( + 'Contacts enrollment cleanup failed', + cleanup, + cleanupStack, + ); + } + Error.throwWithStackTrace(error, stack); + } + } + + Future addNextcloudContactsOnly({ + required String id, + required String label, + required Uri server, + }) => _enroll(id, (attempt) async { + final cancellation = DavCancellationToken(); + final flow = NextcloudLoginFlowV2( + server: server, + launchBrowser: launchBrowser, + client: httpClient, + ); + attempt.onCancel = () { + cancellation.cancel(); + flow.close(); + }; + try { + final credential = await flow.authenticate(cancellation); + _checkEnrollment(attempt); + return await _addCardDav( + attempt, + id: id, + label: label, + server: credential.server, + username: credential.loginName, + password: credential.appPassword, + nextcloud: true, + ); + } finally { + flow.close(); + } + }); + + Future synchronizeAll() async { + if (_closed) return; + final accounts = await store.read((tx) => tx.accounts()); + await Future.wait([ + for (final account in accounts.where((value) => value.enabled)) + synchronizeAccount(account.id), + ]); + } + + Future synchronizeAccount(String id) => _syncing.putIfAbsent( + id, + () => _synchronize(id).whenComplete(() { + _syncing.remove(id); + }), + ); + + Future _synchronize(String id) async { + final captured = await store.read((tx) => tx.account(id)); + try { + if (_linkedRemovals[id] case final removal? when !removal.recovered) { + throw const ContactsException( + ContactsFailure.cancelled, + 'account-removal-in-progress', + ); + } + if (captured?.errorCode == 'contacts-removal-recovery-required') { + throw const ContactsException( + ContactsFailure.unknownOutcome, + 'contacts-removal-recovery-required', + ); + } + final sources = await directory.discover(id); + for (final source in sources) { + if (_closed) { + throw const ContactsException(ContactsFailure.cancelled, 'closed'); + } + final current = await store.read((tx) => tx.account(id)); + if (current?.generation != captured?.generation || + current?.enabled != true) { + throw const ContactsException( + ContactsFailure.cancelled, + 'stale-account-operation', + ); + } + if (source.capabilities.read) await directory.synchronize(source.key); + } + await _setAccountError(id, captured?.generation, null); + } on Object catch (error, stack) { + if (!(error is ContactsException && + error.kind == ContactsFailure.cancelled)) { + try { + await _setAccountError( + id, + captured?.generation, + error is ContactsException ? error.code : 'contacts-sync-failed', + ); + } on Object catch (cleanup, cleanupStack) { + _log.warning( + 'Could not persist contacts failure status', + cleanup, + cleanupStack, + ); + } + } + Error.throwWithStackTrace(error, stack); + } + } + + Future _setAccountError(String id, int? generation, String? error) => + store.write((tx) async { + final account = await tx.account(id); + if (account != null && + account.enabled && + account.generation == generation) { + await tx.putAccount(account.copy(errorCode: error)); + } + }); + + Future _bind( + ContactAccount account, { + void Function()? validateBinding, + }) async { + if (_closed) { + throw const ContactsException(ContactsFailure.cancelled, 'closed'); + } + if (_providerFactory != null) { + validateBinding?.call(); + _bindProvider( + _providerFactory(account), + accountGeneration: account.generation, + ); + _boundGenerations[account.id] = account.generation; + return; + } + switch (account.provider) { + case ContactProviderKind.google: + final parentId = await store.linkedBusyMaxAccount(account.id); + if (parentId == null) throw StateError('Google contacts link missing.'); + validateBinding?.call(); + _bindProvider( + GoogleContactsProvider( + accountId: account.id, + authorizedClient: _BusyMaxGoogleContactsClient( + authorization: authorization, + accountId: parentId, + inner: httpClient, + ), + grantedScopes: account.grantedScopes, + publicPhotoClient: httpClient, + ), + accountGeneration: account.generation, + ); + case ContactProviderKind.microsoft: + final parentId = await store.linkedBusyMaxAccount(account.id); + if (parentId == null) { + throw StateError('Microsoft contacts link missing.'); + } + final parent = await accounts.accountById(parentId); + validateBinding?.call(); + _bindProvider( + MicrosoftContactsProvider( + accountId: account.id, + graph: GraphClient( + httpClient: httpClient, + tokenProvider: ({claims}) async => GraphAuthorization( + (await authorization.contactsAuthorizationHeader( + BusyProvider.microsoft, + parentId, + claims: claims, + )).replaceFirst(RegExp(r'^Bearer '), ''), + grantedScopes: account.grantedScopes, + ), + ), + grantedScopes: account.grantedScopes, + personalAccount: + parent?.tenantId == '9188040d-6c67-4c5b-b112-36a304b66dad', + ), + accountGeneration: account.generation, + ); + case ContactProviderKind.carddav: + final parentId = await store.linkedBusyMaxAccount(account.id); + final linked = parentId == null + ? null + : await readLinkedDavCredential(parentId); + final encoded = linked == null ? await readDavSecret(account.id) : null; + if (linked == null && encoded == null) { + throw StateError('CardDAV credential missing.'); + } + final config = encoded == null + ? { + 'version': 1, + 'server': linked!.server.toString(), + 'username': linked.username, + 'password': linked.password, + 'readOnly': true, + 'nextcloud': true, + } + : (jsonDecode(encoded) as Map).cast(); + if (config['version'] != 1) { + throw const FormatException('Unsupported CardDAV credential.'); + } + validateBinding?.call(); + _bindProvider( + _davProvider(account, config), + accountGeneration: account.generation, + ); + } + _boundGenerations[account.id] = account.generation; + } + + void _bindProvider( + ContactsProvider provider, { + required int accountGeneration, + }) { + directory.bind( + ReadOnlyContactsProvider(provider), + accountGeneration: accountGeneration, + ); + } + + Future _detachBinding(String id) { + _boundGenerations.remove(id); + return directory.detach(id); + } + + CardDavContactsProvider _davProvider( + ContactAccount account, + Map config, + ) { + final server = Uri.parse(config['server'] as String); + _requireDavServer(server); + final profile = config['nextcloud'] == true + ? const DavProviderProfile.nextcloud() + : const DavProviderProfile(); + final resources = DavResourceClient( + accountId: account.id, + authority: server, + profile: profile, + transport: DavHttpTransport( + client: httpClient, + profile: profile, + accountAuthority: server, + ), + streaming: DavTransferClient( + client: httpClient, + authority: server, + profile: profile, + ), + credentialProvider: () async => DavBasicCredential( + username: config['username'] as String, + password: config['password'] as String, + ), + ); + return CardDavContactsProvider( + accountId: account.id, + client: CardDavClient(resources), + cachedRecord: (identity) => store.read((tx) => tx.contact(identity)), + readOnly: true, + ); + } + + Future removeLinkedAccount(String busyMaxAccountId) async { + final id = linkedContactAccountId(busyMaxAccountId); + _linkedRemovals[id]?.recoverable = false; + await _cancelEnrollment(id); + final account = await store.read((tx) => tx.account(id)); + if (account == null) return; + _boundGenerations.remove(id); + await directory.disconnect(id, remove: true); + } + + /// Cancels and drains provider work before the parent authorization is + /// removed. Data is retained, disabled and recoverable until the parent's + /// database transaction commits. + void beginLinkedAccountRemoval(String busyMaxAccountId) { + final id = linkedContactAccountId(busyMaxAccountId); + if (_linkedRemovals.containsKey(id)) { + throw StateError('Parent removal already owns this contacts account.'); + } + _linkedRemovals[id] = _LinkedAccountRemoval(); + // Invalidate synchronously. A staged enrollment may already own, or be + // queued for, authorization serialization; preparation drains it only + // after that boundary is released. + _enrollments[id]?.cancel(); + } + + Future prepareLinkedAccountRemoval(String busyMaxAccountId) async { + final id = linkedContactAccountId(busyMaxAccountId); + // Direct callers also claim ownership before awaiting. Production claims + // it when parent removal starts, before queuing authorization work. + if (!_linkedRemovals.containsKey(id)) { + beginLinkedAccountRemoval(busyMaxAccountId); + } + final removal = _linkedRemovals[id]!; + if (removal.prepared) { + throw StateError('Contacts removal already prepared.'); + } + removal.prepared = true; + removal.parent = await accounts.accountById(busyMaxAccountId); + removal.authorizationGeneration = await readAuthorizationGeneration?.call( + busyMaxAccountId, + ); + await _cancelEnrollment(id); + final account = await store.read((tx) => tx.account(id)); + removal.original = account; + removal.hadBinding = _boundGenerations[id] == account?.generation; + if (account?.enabled == true) { + // Detach/drain network work first. Change the setting only if this attempt + // still owns the exact account that it observed. + removal.detachmentStarted = true; + await _detachBinding(id); + await _drain([if (_syncing[id] case final running?) running]); + removal.detachmentCompleted = true; + // A successful statement is not a committed transaction. Keep the + // original binding receipt even if this write or its COMMIT rolls back. + removal.changed = await store.write((tx) async { + final current = await tx.account(id); + if (removal.recoverable && + identical(_linkedRemovals[id], removal) && + current?.generation == account!.generation && + current?.enabled == true) { + final disabled = current!.copy( + enabled: false, + generation: current.generation + 1, + ); + await tx.putAccount(disabled); + return disabled; + } + return null; + }); + } + } + + /// Called inside the parent's account-removal database transaction. + Future persistLinkedAccountRemoval(String busyMaxAccountId) => + store.persistRemoveAccount(linkedContactAccountId(busyMaxAccountId)); + + Future restoreLinkedAccountAfterFailedRemoval( + String busyMaxAccountId, + ) async { + final id = linkedContactAccountId(busyMaxAccountId); + final removal = _linkedRemovals[id]; + final original = removal?.original; + if (removal == null || + !removal.recoverable || + removal.recovered || + _closed || + original?.enabled != true || + !removal.detachmentStarted) { + return; + } + final parent = await accounts.accountById(busyMaxAccountId); + if (parent == null || + parent.provider != removal.parent?.provider || + parent.providerAccountId != removal.parent?.providerAccountId || + parent.tenantId != removal.parent?.tenantId || + parent.authority != removal.parent?.authority || + parent.credentialKind != removal.parent?.credentialKind || + parent.authState != removal.parent?.authState || + parent.grantedScopes != removal.parent?.grantedScopes || + await readAuthorizationGeneration?.call(busyMaxAccountId) != + removal.authorizationGeneration) { + return; + } + final expected = removal.changed ?? original!; + bool owns(ContactAccount? current) => + removal.recoverable && + identical(_linkedRemovals[id], removal) && + current != null && + jsonEncode(current.toJson()) == jsonEncode(expected.toJson()); + if (!owns(await store.read((tx) => tx.account(id)))) return; + // detach removes the runtime binding before its journal transaction. If + // that transaction failed, finish its existing uncertain-outcome handling + // before allowing any provider work; never replay an executing mutation. + if (!removal.detachmentCompleted) { + try { + await _detachBinding(id); + await _drain([if (_syncing[id] case final running?) running]); + removal.detachmentCompleted = true; + } on Object { + await store.write((tx) async { + final current = await tx.account(id); + if (owns(current)) { + await tx.putAccount( + current!.copy(errorCode: 'contacts-removal-recovery-required'), + ); + } + }); + rethrow; + } + } + final restored = await store.write((tx) async { + final current = await tx.account(id); + if (!owns(current)) return null; + if (removal.changed == null) { + // SQL rollback already retained the enabled setting and generation. + return current; + } + final restored = current!.copy( + enabled: true, + generation: current.generation + 1, + errorCode: original?.errorCode, + ); + await tx.putAccount(restored); + return restored; + }); + if (restored == null) return; + // A newer user action may run while the write completes. Never rebind it. + final current = await store.read((tx) => tx.account(id)); + if (!removal.recoverable || + !identical(_linkedRemovals[id], removal) || + current?.generation != restored.generation || + current?.enabled != true) { + return; + } + if (!removal.hadBinding) { + removal.recovered = true; + return; + } + await _bind( + restored, + validateBinding: () { + if (!removal.recoverable || !identical(_linkedRemovals[id], removal)) { + throw const ContactsException( + ContactsFailure.cancelled, + 'stale-removal-recovery', + ); + } + }, + ); + if (!removal.recoverable) return; + removal.recovered = true; + _ensureScheduling(); + _launch(() => synchronizeAccount(id)); + } + + void finishLinkedAccountRemoval(String busyMaxAccountId) { + _linkedRemovals.remove(linkedContactAccountId(busyMaxAccountId)); + } + + void linkedAccountRemovalCommitted() => store.contactConfigurationChanged(); + + Future removeContactsAccount(String id) async { + _linkedRemovals[id]?.recoverable = false; + await _cancelEnrollment(id); + final account = await store.read((tx) => tx.account(id)); + if (account == null) return; + _boundGenerations.remove(id); + await directory.disconnect(id, remove: true); + if (account.provider == ContactProviderKind.carddav) { + await deleteDavSecret(id); + } + } + + Future disableContactsAccount(String id) async { + _linkedRemovals[id]?.recoverable = false; + await _cancelEnrollment(id); + _boundGenerations.remove(id); + await directory.disconnect(id); + } + + Future isIndependentNextcloud(String id) async { + final encoded = await readDavSecret(id); + return encoded != null && (jsonDecode(encoded) as Map)['nextcloud'] == true; + } + + Future reconnectContactsAccount(String id) async { + final account = await store.read((tx) => tx.account(id)); + if (account == null) throw StateError('Contacts account unavailable'); + final parent = await store.linkedBusyMaxAccount(id); + if (parent != null) { + if (account.provider == ContactProviderKind.carddav) { + await enableLinkedNextcloudContacts(parent); + } else { + await enableLinkedContacts(parent); + } + return; + } + await reconnectDavContactsOnly(id); + } + + Future reconnectDavContactsOnly(String id, {String? password}) => + _enroll(id, (attempt) async { + await start(); + _checkEnrollment(attempt); + final previous = await store.read((tx) => tx.account(id)); + final encoded = await readDavSecret(id); + if (previous == null || + previous.provider != ContactProviderKind.carddav || + encoded == null || + await store.linkedBusyMaxAccount(id) != null) { + throw StateError('Independent DAV account unavailable'); + } + final config = (jsonDecode(encoded) as Map).cast(); + if (password != null) { + if (password.isEmpty) { + throw const ContactsException( + ContactsFailure.validation, + 'password-required', + ); + } + config['password'] = password; + } else if (config['nextcloud'] == true) { + final token = DavCancellationToken(); + final flow = NextcloudLoginFlowV2( + server: Uri.parse(config['server'] as String), + client: httpClient, + launchBrowser: launchBrowser, + ); + attempt.onCancel = () { + token.cancel(); + flow.close(); + }; + try { + final result = await flow.authenticate(token); + _checkEnrollment(attempt); + if (result.loginName != config['username'] || + result.server.origin != + Uri.parse(config['server'] as String).origin) { + throw const ContactsException( + ContactsFailure.authentication, + 'wrong-contact-identity', + ); + } + config['password'] = result.appPassword; + } finally { + flow.close(); + } + } + final replacement = ContactAccount( + id: previous.id, + provider: previous.provider, + displayName: previous.displayName, + subject: previous.subject, + enabled: true, + generation: previous.generation + 1, + grantedScopes: previous.grantedScopes, + ); + final provider = _davProvider(replacement, config); + final cancellation = ContactsCancellationToken(); + attempt.onCancel = cancellation.cancel; + try { + if ((await provider.discover(cancellation)).isEmpty) { + throw const ContactsException( + ContactsFailure.permission, + 'no-readable-address-books', + ); + } + } finally { + provider.close(); + } + _checkEnrollment(attempt); + final next = jsonEncode(config); + var durable = false; + try { + await writeDavSecret(id, next); + _checkEnrollment(attempt); + await store.write((tx) => tx.putAccount(replacement)); + durable = true; + await _detachForReplacement(id); + _checkEnrollment(attempt); + await _bind(replacement); + } on Object catch (error, stack) { + try { + if (!durable) { + await writeDavSecret(id, encoded); + } else { + await _detachBinding(id); + await store.write( + (tx) => tx.putAccount( + replacement.copy( + enabled: false, + errorCode: 'reconnect-required', + ), + ), + ); + } + } on Object catch (cleanup, cleanupStack) { + _log.warning('DAV reconnect cleanup failed', cleanup, cleanupStack); + } + Error.throwWithStackTrace(error, stack); + } + _ensureScheduling(); + await synchronizeAccount(id); + }); + + Future setSourceEnabled(String sourceKey, {required bool enabled}) { + return store.setSourceEnabled(sourceKey, enabled: enabled); + } + + Future close() => _closeFuture ??= _close(); + + Future _close() async { + _closed = true; + _timer?.cancel(); + Object? failure; + StackTrace? failureStack; + Future cleanup(Future Function() action) async { + try { + await action(); + } on Object catch (error, stack) { + failure ??= error; + failureStack ??= stack; + _log.warning('Contacts shutdown cleanup failed', error, stack); + } + } + + final attempts = _enrollments.values.toList(); + for (final attempt in attempts) { + attempt.cancel(); + } + await cleanup( + () => Future.wait(attempts.map((attempt) => attempt.done.future)), + ); + if (_startFuture != null) await cleanup(() => _drain([_startFuture!])); + await cleanup(directory.close); + await cleanup(() => _drain([..._syncing.values, ..._background])); + await cleanup(store.close); + if (failure != null) Error.throwWithStackTrace(failure!, failureStack!); + } + + static String linkedContactAccountId(String busyMaxAccountId) => + 'contacts:$busyMaxAccountId'; + + static void _requireDavServer(Uri server) { + if (server.scheme != 'https' || + server.host.isEmpty || + server.userInfo.isNotEmpty || + server.hasFragment) { + throw const FormatException('An HTTPS CardDAV server is required.'); + } + } +} + +final class _LinkedAccountRemoval { + AccountEntity? parent; + int? authorizationGeneration; + ContactAccount? original; + ContactAccount? changed; + bool hadBinding = false; + bool detachmentStarted = false; + bool detachmentCompleted = false; + bool recoverable = true; + bool prepared = false; + bool recovered = false; +} + +final class _ContactsEnrollment { + final AuthorizationCancellation authorization = AuthorizationCancellation(); + final Completer done = Zone.root.run(Completer.new); + bool cancelled = false; + void Function()? onCancel; + void cancel() { + cancelled = true; + authorization.cancel(); + onCancel?.call(); + } +} + +final class _BusyMaxGoogleContactsClient extends http.BaseClient { + _BusyMaxGoogleContactsClient({ + required this.authorization, + required this.accountId, + required this.inner, + }); + + final ContactsAuthorizationBroker authorization; + final String accountId; + final http.Client inner; + + @override + Future send(http.BaseRequest request) async { + request.headers['authorization'] = await authorization + .contactsAuthorizationHeader(BusyProvider.google, accountId); + return inner.send(request); + } + + // The app owns the shared HTTP client. + @override + void close() {} +} diff --git a/lib/src/contacts/busymax_contacts_store.dart b/lib/src/contacts/busymax_contacts_store.dart new file mode 100644 index 00000000..b4f53005 --- /dev/null +++ b/lib/src/contacts/busymax_contacts_store.dart @@ -0,0 +1,644 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:drift/drift.dart'; + +import '../db/app_database.dart'; +import 'read_only_contacts_provider.dart'; + +final class BusyMaxContactSelection { + const BusyMaxContactSelection({ + required this.accountIds, + required this.sourceKeys, + }); + + final Set accountIds; + final Set sourceKeys; +} + +/// Contacts storage inside BusyMax's existing Drift database. The application +/// owns the database; closing this adapter drains only contacts work and never +/// closes calendar or task persistence. +final class BusyMaxContactsStore implements ContactsStore { + BusyMaxContactsStore(this.database); + + final AppDatabase database; + final StreamController _changes = StreamController.broadcast(); + final Set> _active = >{}; + Future? _closeFuture; + bool _closing = false; + + @override + Stream get changes => _changes.stream; + + /// Normalize cached policy before discovery, including disabled/offline + /// sources. Native records, grants, cursors and journals are untouched. + Future normalizeReadOnlySources() => write((tx) async { + for (final source in await tx.sources()) { + await tx.putSource(ReadOnlyContactsProvider.effectiveSource(source)); + } + }); + + @override + Future read( + Future Function(ContactsReadTransaction transaction) action, + ) => _run(action, writable: false); + + @override + Future write( + Future Function(ContactsWriteTransaction transaction) action, + ) => _run(action, writable: true); + + Future _run( + Future Function(Transaction transaction) action, { + required bool writable, + }) { + return _track( + () => database.transaction(() async { + final transaction = _BusyMaxContactsTransaction(database, writable); + try { + return await action(transaction as Transaction); + } finally { + transaction.active = false; + } + }), + notify: writable, + ); + } + + // The returned operation retains its failure for the caller. The separate + // completion gate observes it only to let shutdown drain every operation. + Future _track(Future Function() action, {bool notify = false}) { + if (_closing) return Future.error(StateError('Contacts store closed')); + final done = Zone.root.run(Completer.new); + _active.add(done.future); + final operation = Future.sync(() async { + try { + final value = await action(); + if (notify && !_closing) _changes.add(null); + return value; + } finally { + _active.remove(done.future); + done.complete(); + } + }); + // Observe failure for owned cleanup; the returned future still fails. + unawaited( + operation.then( + (_) {}, + onError: (Object error, StackTrace stack) {}, + ), + ); + return operation; + } + + @override + Future close() { + final existing = _closeFuture; + if (existing != null) return existing; + _closing = true; + return _closeFuture = () async { + while (_active.isNotEmpty) { + await Future.wait(_active.toList()); + } + await _changes.close(); + }(); + } + + Future saveLinkedAccount({ + required ContactAccount account, + required String busyMaxAccountId, + required bool reuseAuthorization, + }) => _track( + () => database.transaction( + () => persistLinkedAccount( + account: account, + busyMaxAccountId: busyMaxAccountId, + reuseAuthorization: reuseAuthorization, + ), + ), + notify: true, + ); + + /// Writes through the caller's current Drift transaction. Authorization + /// upgrades use this so credentials, their generation and the contacts link + /// either commit together or all roll back. + Future persistLinkedAccount({ + required ContactAccount account, + required String busyMaxAccountId, + required bool reuseAuthorization, + }) async { + if (_closing) throw StateError('Contacts store closed'); + final transaction = _BusyMaxContactsTransaction(database, true); + try { + await transaction.putAccount(account); + await database.customStatement( + ''' +INSERT INTO bm_contact_account_links( + contact_account_id, busymax_account_id, reuse_authorization +) VALUES (?, ?, ?) +ON CONFLICT(contact_account_id) DO UPDATE SET + busymax_account_id = excluded.busymax_account_id, + reuse_authorization = excluded.reuse_authorization +''', + [account.id, busyMaxAccountId, reuseAuthorization ? 1 : 0], + ); + } finally { + transaction.active = false; + } + } + + /// Removes a linked contacts account through the caller's current Drift + /// transaction (the parent-account removal transaction in production). + Future persistRemoveAccount(String accountId) async { + if (_closing) throw StateError('Contacts store closed'); + final transaction = _BusyMaxContactsTransaction(database, true); + try { + await transaction.removeAccount(accountId); + } finally { + transaction.active = false; + } + } + + void contactConfigurationChanged() { + if (!_closing) _changes.add(null); + } + + Future linkedBusyMaxAccount(String contactAccountId) => + _track(() async { + final row = await database + .customSelect( + 'SELECT busymax_account_id FROM bm_contact_account_links ' + 'WHERE contact_account_id = ?', + variables: >[Variable(contactAccountId)], + ) + .getSingleOrNull(); + return row?.readNullable('busymax_account_id'); + }); + + Future> linkedContactAccounts(String busyMaxAccountId) => _track( + () async => + (await database + .customSelect( + 'SELECT contact_account_id FROM bm_contact_account_links ' + 'WHERE busymax_account_id = ? ORDER BY contact_account_id', + variables: >[ + Variable(busyMaxAccountId), + ], + ) + .get()) + .map((row) => row.read('contact_account_id')) + .toList(growable: false), + ); + + Future setSourceEnabled(String sourceKey, {required bool enabled}) => + _track( + () => database.transaction( + () => database.customStatement( + ''' +INSERT INTO bm_contact_source_preferences(source_key, enabled) VALUES (?, ?) +ON CONFLICT(source_key) DO UPDATE SET enabled = excluded.enabled +''', + [sourceKey, enabled ? 1 : 0], + ), + ), + notify: true, + ); + + Future sourceEnabled(String sourceKey) => _track(() async { + final row = await database + .customSelect( + 'SELECT enabled FROM bm_contact_source_preferences ' + 'WHERE source_key = ?', + variables: >[Variable(sourceKey)], + ) + .getSingleOrNull(); + return row == null || row.read('enabled') != 0; + }); + + /// Returns every enabled source by default. When [busyMaxAccountId] is set, + /// linked OAuth contacts are selected together with independent contacts-only + /// sources, which are intentionally available to the whole event editor. + Future selection({String? busyMaxAccountId}) => + _track(() async { + final rows = await database + .customSelect( + ''' +SELECT a.data AS account_data, s.source_key, + l.busymax_account_id, + COALESCE(p.enabled, 1) AS source_enabled + FROM bm_contact_accounts a + JOIN bm_contact_sources s ON s.account_id = a.id + LEFT JOIN bm_contact_account_links l ON l.contact_account_id = a.id + LEFT JOIN bm_contact_source_preferences p ON p.source_key = s.source_key + WHERE (? IS NULL OR l.busymax_account_id IS NULL OR l.busymax_account_id = ?) + ORDER BY a.id, s.source_key +''', + variables: >[ + Variable(busyMaxAccountId), + Variable(busyMaxAccountId), + ], + ) + .get(); + final accountIds = {}; + final sourceKeys = {}; + for (final row in rows) { + if (row.read('source_enabled') == 0) continue; + final account = ContactAccount.fromJson( + (jsonDecode(row.read('account_data')) as Map) + .cast(), + ); + if (!account.enabled) continue; + accountIds.add(account.id); + sourceKeys.add(row.read('source_key')); + } + return BusyMaxContactSelection( + accountIds: Set.unmodifiable(accountIds), + sourceKeys: Set.unmodifiable(sourceKeys), + ); + }); +} + +final class _BusyMaxContactsTransaction implements ContactsWriteTransaction { + _BusyMaxContactsTransaction(this.database, this.writable); + + final AppDatabase database; + final bool writable; + bool active = true; + + void _check({bool write = false}) { + if (!active || write && !writable) { + throw StateError('Invalid contacts transaction access'); + } + } + + Future> _list( + String sql, + T Function(Map) decode, [ + List> variables = const >[], + ]) async { + _check(); + final rows = await database.customSelect(sql, variables: variables).get(); + _check(); + return rows + .map( + (row) => decode( + (jsonDecode(row.read('data')) as Map) + .cast(), + ), + ) + .toList(growable: false); + } + + Future _one( + String table, + String column, + String id, + T Function(Map) decode, + ) async { + final rows = await _list( + 'SELECT data FROM $table WHERE $column = ?', + decode, + >[Variable(id)], + ); + return rows.firstOrNull; + } + + @override + Future> accounts() => _list( + 'SELECT data FROM bm_contact_accounts ORDER BY id', + ContactAccount.fromJson, + ); + + @override + Future account(String id) => _one( + 'bm_contact_accounts', + 'id', + id, + ContactAccount.fromJson, + ); + + @override + Future> sources({String? accountId}) => + _list( + 'SELECT data FROM bm_contact_sources' + '${accountId == null ? '' : ' WHERE account_id = ?'} ' + 'ORDER BY source_key', + (json) => ReadOnlyContactsProvider.effectiveSource( + ContactSource.fromJson(json), + ), + accountId == null + ? const >[] + : >[Variable(accountId)], + ); + + @override + Future source(String key) => _one( + 'bm_contact_sources', + 'source_key', + key, + (json) => + ReadOnlyContactsProvider.effectiveSource(ContactSource.fromJson(json)), + ); + + @override + Future contact(ContactIdentity identity) => + _one( + 'bm_contact_records', + 'identity_key', + identity.key, + contactRecordFromJson, + ); + + ({String sql, List> variables}) _filter( + String? accountId, + String? sourceKey, + String query, + ) { + final clauses = []; + final variables = >[]; + if (accountId != null) { + clauses.add('account_id = ?'); + variables.add(Variable(accountId)); + } + if (sourceKey != null) { + clauses.add('source_key = ?'); + variables.add(Variable(sourceKey)); + } + final needle = query.trim().toLowerCase(); + if (needle.isNotEmpty) { + clauses.add("lower(search_text) LIKE ? ESCAPE '\\'"); + variables.add( + Variable( + '%${needle.replaceAll(r'\', r'\\').replaceAll('%', r'\%').replaceAll('_', r'\_')}%', + ), + ); + } + return ( + sql: clauses.isEmpty ? '' : ' WHERE ${clauses.join(' AND ')}', + variables: variables, + ); + } + + void _validateBounds(String query, int limit, int offset) { + if (query.length > 1024 || limit < 0 || limit > 1000 || offset < 0) { + throw ArgumentError('Bounded contacts query required'); + } + } + + @override + Future> contacts({ + String? accountId, + String? sourceKey, + String query = '', + int limit = 100, + int offset = 0, + }) { + _validateBounds(query, limit, offset); + final filter = _filter(accountId, sourceKey, query); + return _list( + 'SELECT data FROM bm_contact_records${filter.sql} ' + 'ORDER BY name COLLATE NOCASE, identity_key LIMIT ? OFFSET ?', + contactRecordFromJson, + >[ + ...filter.variables, + Variable(limit), + Variable(offset), + ], + ); + } + + @override + Future> projections({ + String? accountId, + String? sourceKey, + String query = '', + int limit = 100, + int offset = 0, + }) { + _validateBounds(query, limit, offset); + final filter = _filter(accountId, sourceKey, query); + return _list( + 'SELECT projection AS data FROM bm_contact_records${filter.sql} ' + 'ORDER BY name COLLATE NOCASE, identity_key LIMIT ? OFFSET ?', + ContactProjection.fromJson, + >[ + ...filter.variables, + Variable(limit), + Variable(offset), + ], + ); + } + + @override + Future count({ + String? accountId, + String? sourceKey, + String query = '', + }) async { + _validateBounds(query, 0, 0); + _check(); + final filter = _filter(accountId, sourceKey, query); + final row = await database + .customSelect( + 'SELECT count(*) AS total FROM bm_contact_records${filter.sql}', + variables: filter.variables, + ) + .getSingle(); + _check(); + return row.read('total'); + } + + @override + Future> mutations({ + String? accountId, + String? sourceKey, + }) { + final clauses = []; + final variables = >[]; + if (accountId != null) { + clauses.add('account_id = ?'); + variables.add(Variable(accountId)); + } + if (sourceKey != null) { + clauses.add('source_key = ?'); + variables.add(Variable(sourceKey)); + } + return _list( + 'SELECT data FROM bm_contact_mutations' + '${clauses.isEmpty ? '' : ' WHERE ${clauses.join(' AND ')}'} ' + 'ORDER BY id', + ContactMutation.fromJson, + variables, + ); + } + + @override + Future syncState(String sourceKey) => + _one( + 'bm_contact_sync_states', + 'source_key', + sourceKey, + ContactSyncState.fromJson, + ); + + @override + Future> drafts() => _list( + 'SELECT data FROM bm_contact_drafts ORDER BY id', + ContactDraft.fromJson, + ); + + Future _put( + String table, + Map columns, + String keyColumn, + ) async { + _check(write: true); + final keys = columns.keys.toList(growable: false); + await database.customStatement( + 'INSERT INTO $table(${keys.join(',')}) ' + 'VALUES(${List.filled(keys.length, '?').join(',')}) ' + 'ON CONFLICT($keyColumn) DO UPDATE SET ' + '${keys.where((key) => key != keyColumn).map((key) => '$key=excluded.$key').join(',')}', + columns.values.toList(growable: false), + ); + _check(write: true); + } + + @override + Future putAccount(ContactAccount account) => _put( + 'bm_contact_accounts', + {'id': account.id, 'data': jsonEncode(account.toJson())}, + 'id', + ); + + @override + Future putSource(ContactSource source) => + _put('bm_contact_sources', { + 'source_key': source.key, + 'account_id': source.accountId, + 'data': jsonEncode( + ReadOnlyContactsProvider.effectiveSource(source).toJson(), + ), + }, 'source_key'); + + @override + Future putContact(ContactRecord contact, {String? seenScan}) async { + final projection = contact.projection; + await _put('bm_contact_records', { + 'identity_key': contact.identity.key, + 'account_id': contact.identity.accountId, + 'source_key': jsonEncode([ + contact.identity.accountId, + contact.identity.provider.name, + contact.identity.sourceId, + ]), + 'name': projection.displayName, + 'search_text': projection.searchText, + 'data': jsonEncode(contact.toJson()), + 'projection': jsonEncode({ + ...projection.toJson(), + 'notes': '', + 'addresses': [], + 'dates': [], + 'urls': [], + 'photo': projection.photo == null + ? null + : { + ...projection.photo!.toJson(), + 'embedded': null, + }, + }), + 'seen_scan': seenScan, + }, 'identity_key'); + } + + @override + Future deleteContact(ContactIdentity identity) => _delete( + 'DELETE FROM bm_contact_records WHERE identity_key = ?', + [identity.key], + ); + + @override + Future pruneUnseen( + String sourceKey, + String scanId, + Set protectedIdentities, + ) async { + _check(write: true); + final rows = await database + .customSelect( + 'SELECT identity_key FROM bm_contact_records ' + 'WHERE source_key = ? AND (seen_scan IS NULL OR seen_scan <> ?)', + variables: >[ + Variable(sourceKey), + Variable(scanId), + ], + ) + .get(); + for (final row in rows) { + final id = row.read('identity_key'); + if (!protectedIdentities.contains(id)) { + await _delete( + 'DELETE FROM bm_contact_records WHERE identity_key = ?', + [id], + ); + } + } + } + + @override + Future putMutation(ContactMutation mutation) => + _put('bm_contact_mutations', { + 'id': mutation.id, + 'account_id': mutation.accountId, + 'source_key': mutation.sourceKey, + 'data': jsonEncode(mutation.toJson()), + }, 'id'); + + @override + Future deleteMutation(String id) => + _delete('DELETE FROM bm_contact_mutations WHERE id = ?', [id]); + + @override + Future putSyncState(ContactSyncState state) => + _put('bm_contact_sync_states', { + 'source_key': state.sourceKey, + 'data': jsonEncode(state.toJson()), + }, 'source_key'); + + @override + Future putDraft(ContactDraft draft) => + _put('bm_contact_drafts', { + 'id': draft.id, + 'source_key': draft.sourceKey, + 'data': jsonEncode(draft.toJson()), + }, 'id'); + + @override + Future deleteDraft(String id) => + _delete('DELETE FROM bm_contact_drafts WHERE id = ?', [id]); + + @override + Future removeAccount(String id) => + _delete('DELETE FROM bm_contact_accounts WHERE id = ?', [id]); + + @override + Future removeSource(String key) async { + await _delete( + 'DELETE FROM bm_contact_mutations WHERE source_key = ?', + [key], + ); + await _delete( + 'DELETE FROM bm_contact_sources WHERE source_key = ?', + [key], + ); + } + + Future _delete(String sql, List variables) async { + _check(write: true); + await database.customStatement(sql, variables); + _check(write: true); + } +} diff --git a/lib/src/contacts/read_only_contacts_provider.dart b/lib/src/contacts/read_only_contacts_provider.dart new file mode 100644 index 00000000..9b290985 --- /dev/null +++ b/lib/src/contacts/read_only_contacts_provider.dart @@ -0,0 +1,133 @@ +import 'dart:typed_data'; + +import 'package:busystack_contacts/busystack_contacts.dart'; + +/// BusyMax policy, independent of the actual OAuth grant or DAV privileges. +/// The delegate remains private; all directory bindings pass through here. +final class ReadOnlyContactsProvider implements ContactsProvider { + ReadOnlyContactsProvider(this._delegate); + + final ContactsProvider _delegate; + + static ContactSource effectiveSource(ContactSource source) { + final capabilities = source.capabilities; + if (!capabilities.create && + !capabilities.update && + !capabilities.delete && + !capabilities.photoWrite && + !source.canRename && + !source.canRemove) { + return source; + } + return ContactSource.fromJson({ + ...source.toJson(), + 'capabilities': { + ...capabilities.toJson(), + 'create': false, + 'update': false, + 'delete': false, + 'photoWrite': false, + }, + 'rename': false, + 'remove': false, + }); + } + + Never _reject() => throw const ContactsException( + ContactsFailure.permission, + 'busymax-contacts-read-only', + ); + + @override + String get accountId => _delegate.accountId; + @override + ContactProviderKind get kind => _delegate.kind; + + @override + Future> discover( + ContactsCancellationToken cancellation, + ) async => + (await _delegate.discover(cancellation)) + .map(effectiveSource) + .toList(growable: false); + + @override + Future page( + ContactSource source, { + String? cursor, + String? page, + required ContactsCancellationToken cancellation, + }) => _delegate.page( + source, + cursor: cursor, + page: page, + cancellation: cancellation, + ); + + @override + Future get( + ContactSource source, + ContactIdentity identity, + ContactsCancellationToken cancellation, + ) => _delegate.get(source, identity, cancellation); + + @override + Future photo( + ContactSource source, + ContactRecord record, + ContactsCancellationToken cancellation, + ) => _delegate.photo(source, record, cancellation); + + @override + Future findCreated( + ContactSource source, + ContactMutation mutation, + ContactsCancellationToken cancellation, + ) => _delegate.findCreated(source, mutation, cancellation); + + @override + Future create( + ContactSource source, + ContactPatch patch, + String mutationId, + ContactsCancellationToken cancellation, { + String? fullVcard, + }) async => _reject(); + + @override + Future update( + ContactSource source, + ContactRecord base, + ContactPatch patch, + ContactsCancellationToken cancellation, + ) async => _reject(); + + @override + Future delete( + ContactSource source, + ContactRecord base, + ContactsCancellationToken cancellation, + ) async => _reject(); + + @override + Future setPhoto( + ContactSource source, + ContactRecord base, + Uint8List? bytes, + String mediaType, + ContactsCancellationToken cancellation, + ) async => _reject(); + + @override + Future action( + ContactProviderAction action, + ContactsCancellationToken cancellation, + ) async => _reject(); + + @override + ContactProviderAction decodeAction(Map json) => + _delegate.decodeAction(json); + + @override + void close() => _delegate.close(); +} diff --git a/lib/src/core/auth/account_token_broker.dart b/lib/src/core/auth/account_token_broker.dart index bf2bb22d..6bcf47ca 100644 --- a/lib/src/core/auth/account_token_broker.dart +++ b/lib/src/core/auth/account_token_broker.dart @@ -1,6 +1,7 @@ import '../../google_tasks/oauth/oauth_service.dart'; import '../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../providers/busy_provider.dart'; +import 'authorization_attempt.dart'; /// Provider-neutral token access used by API clients. abstract interface class AccountTokenBroker { @@ -13,7 +14,40 @@ abstract interface class AccountTokenBroker { Future recoverUnauthorized(BusyProvider provider, String accountId); } -final class DesktopAccountTokenBroker implements AccountTokenBroker { +enum MicrosoftGraphAuthorizationKind { ordinary, sharedCalendar, category } + +/// Claims-capable Graph authorization used by the shared request pipeline. +/// Kept separate so existing non-Graph token consumers retain their contract. +abstract interface class MicrosoftGraphAuthorizationBroker { + Future microsoftGraphAuthorizationHeader( + String accountId, + MicrosoftGraphAuthorizationKind kind, { + String? claims, + }); +} + +/// Optional contact permissions are deliberately separate from calendar/task +/// token access so ordinary account sign-in never requests them implicitly. +abstract interface class ContactsAuthorizationBroker { + Future authorizeContacts( + BusyProvider provider, + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }); + + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + String? claims, + }); +} + +final class DesktopAccountTokenBroker + implements + AccountTokenBroker, + ContactsAuthorizationBroker, + MicrosoftGraphAuthorizationBroker { const DesktopAccountTokenBroker({ required this.google, required this.microsoft, @@ -22,6 +56,40 @@ final class DesktopAccountTokenBroker implements AccountTokenBroker { final OAuthService google; final MicrosoftOAuthService microsoft; + @override + Future authorizeContacts( + BusyProvider provider, + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => switch (provider) { + BusyProvider.google => google.authorizeGoogleContacts( + accountId, + cancellation: cancellation, + persistContacts: persistContacts, + ), + BusyProvider.microsoft => microsoft.authorizeMicrosoftContacts( + accountId, + cancellation: cancellation, + persistContacts: persistContacts, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + + @override + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + String? claims, + }) => switch (provider) { + BusyProvider.google => google.googleContactsAuthorizationHeader(accountId), + BusyProvider.microsoft => microsoft.microsoftContactsAuthorizationHeader( + accountId, + claims: claims, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + @override Future microsoftSharedCalendarAuthorizationHeader(String accountId) => microsoft.sharedCalendarAuthorizationHeaderForAccount(accountId); @@ -30,6 +98,26 @@ final class DesktopAccountTokenBroker implements AccountTokenBroker { Future microsoftCategoryAuthorizationHeader(String accountId) => microsoft.categoryAuthorizationHeaderForAccount(accountId); + @override + Future microsoftGraphAuthorizationHeader( + String accountId, + MicrosoftGraphAuthorizationKind kind, { + String? claims, + }) => switch (kind) { + MicrosoftGraphAuthorizationKind.ordinary => + microsoft.authorizationHeaderForAccount(accountId, claims: claims), + MicrosoftGraphAuthorizationKind.sharedCalendar => + microsoft.sharedCalendarAuthorizationHeaderForAccount( + accountId, + claims: claims, + ), + MicrosoftGraphAuthorizationKind.category => + microsoft.categoryAuthorizationHeaderForAccount( + accountId, + claims: claims, + ), + }; + @override Future authorizationHeader(BusyProvider provider, String accountId) => switch (provider) { diff --git a/lib/src/core/auth/authorization_attempt.dart b/lib/src/core/auth/authorization_attempt.dart index 19d4eb6a..cb44e438 100644 --- a/lib/src/core/auth/authorization_attempt.dart +++ b/lib/src/core/auth/authorization_attempt.dart @@ -1,5 +1,7 @@ import 'dart:async'; + import 'package:uuid/uuid.dart'; + import 'oauth_models.dart'; /// A UI-owned invocation signal. It never cancels persistence recovery itself. diff --git a/lib/src/core/auth/authorization_persistence.dart b/lib/src/core/auth/authorization_persistence.dart index 241cfaa4..b2f53aec 100644 --- a/lib/src/core/auth/authorization_persistence.dart +++ b/lib/src/core/auth/authorization_persistence.dart @@ -32,6 +32,7 @@ final class AuthorizationPersistence { final SecretStore secrets; final CrossEngineAccountGate gate; final AccountSyncCoordinator _coordinator = AccountSyncCoordinator(); + final AccountSyncCoordinator _removals = AccountSyncCoordinator(); Future run(String id, Future Function() operation) => _coordinator.run(id, () => gate.run('authorization:$id', operation)); @@ -68,17 +69,39 @@ final class AuthorizationPersistence { return record; } - /// Recovery precedes the boundary. The callback owns it once, including - /// revocation and local removal; it receives a validated snapshot, no reader. + /// Removal owns a separate lifecycle queue. Capture identity under the + /// authorization boundary, release it to cancel and drain enrollment, then + /// reacquire and validate before revocation or local deletion. Enrollment + /// may need that boundary to roll back its staged credential. Future runRemoval( String id, - Future Function(AuthorizationRemovalSnapshot snapshot) operation, - ) async { + Future Function(AuthorizationRemovalSnapshot snapshot) operation, { + Future Function()? prepare, + }) => _removals.run(id, () async { await recover(); + final expected = await run(id, () async { + final account = await (database.select( + database.accounts, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + return (account, await generation(id)); + }); + await prepare?.call(); return run(id, () async { final account = await (database.select( database.accounts, )..where((row) => row.id.equals(id))).getSingleOrNull(); + if (await generation(id) != expected.$2 || + account?.provider != expected.$1?.provider || + account?.providerAccountId != expected.$1?.providerAccountId || + account?.tenantId != expected.$1?.tenantId || + account?.authority != expected.$1?.authority || + account?.credentialKind != expected.$1?.credentialKind || + (account == null) != (expected.$1 == null)) { + throw const OAuthException( + 'OAuthStaleAuthorization', + 'The account changed while removal was being prepared.', + ); + } final provider = account?.provider; if (provider == 'google' || provider == 'microsoft') { await secrets.migrateLegacyOAuthCredential( @@ -89,6 +112,7 @@ final class AuthorizationPersistence { final snapshot = AuthorizationRemovalSnapshot._( id, await _readCurrentCredential(id), + expected.$2, ); try { return await operation(snapshot); @@ -96,8 +120,29 @@ final class AuthorizationPersistence { snapshot._active = false; } }); + }); + + /// Recovery authority exists only after a complete reversible rollback. + /// A retained journal or a durable generation change cannot restore contacts. + Future canRestoreRemoval(AuthorizationRemovalSnapshot snapshot) async { + if (!snapshot._active) return false; + final id = snapshot.accountId; + return await generation(id) == snapshot.generation && + await removalRecoveryAvailable(id); } + /// Preparation failure has not revoked authorization, but a retained + /// credential journal still denies authority to restore contacts. + Future removalRecoveryAvailable(String id) async => + await (database.select( + database.accounts, + )..where((row) => row.id.equals(id))).getSingleOrNull() != + null && + await (database.select( + database.authorizationCommits, + )..where((row) => row.accountId.equals(id))).getSingleOrNull() == + null; + Future recover() async { final journals = await database.select(database.authorizationCommits).get(); for (final pending in journals) { @@ -496,8 +541,13 @@ final class AuthorizationPersistence { /// Issued only inside runRemoval. It cannot be used as an unlocked reader or /// after the operation releases the account boundary. final class AuthorizationRemovalSnapshot { - AuthorizationRemovalSnapshot._(this.accountId, this._credential); + AuthorizationRemovalSnapshot._( + this.accountId, + this._credential, + this.generation, + ); final String accountId; + final int generation; final SecretRecord? _credential; bool _active = true; SecretRecord? get credential { diff --git a/lib/src/core/auth/oauth_models.dart b/lib/src/core/auth/oauth_models.dart index 21c75dfa..c5b0e5c3 100644 --- a/lib/src/core/auth/oauth_models.dart +++ b/lib/src/core/auth/oauth_models.dart @@ -249,14 +249,10 @@ OAuthAuthorizationException authorizationOutcomeFailure({ : OAuthFailureKind.temporary, }; final message = switch (kind) { - OAuthFailureKind.configuration => - 'The provider rejected this registration or its permission configuration. Check setup before authorizing again.', - OAuthFailureKind.authorizationCode => - 'The authorization code could not be used. Start a fresh authorization attempt.', - OAuthFailureKind.permission => - 'Authorization was declined or permission is required. Your existing connection is preserved.', - OAuthFailureKind.throttled => - 'Authorization is rate limited. Wait for the provider cooldown before trying again.', + OAuthFailureKind.configuration => 'The provider rejected this registration or its permission configuration. Check setup before authorizing again.', + OAuthFailureKind.authorizationCode => 'The authorization code could not be used. Start a fresh authorization attempt.', + OAuthFailureKind.permission => 'Authorization was declined or permission is required. Your existing connection is preserved.', + OAuthFailureKind.throttled => 'Authorization is rate limited. Wait for the provider cooldown before trying again.', _ => 'The provider could not complete authorization. Try again later.', }; return OAuthAuthorizationException( diff --git a/lib/src/core/auth/oauth_registration.dart b/lib/src/core/auth/oauth_registration.dart index 9540d3af..14d65c44 100644 --- a/lib/src/core/auth/oauth_registration.dart +++ b/lib/src/core/auth/oauth_registration.dart @@ -181,9 +181,9 @@ final class MicrosoftPublicRegistration extends OAuthRegistration { ); } -bool isUuid(String value) => RegExp( - r'^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$', -).hasMatch(value); +bool isUuid(String value) => + RegExp(r'^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$') + .hasMatch(value); /// Opaque, single-use reference to configuration held by the auth layer. final class RegistrationHandle { @@ -228,5 +228,6 @@ final class AuthorizationRequest { }; } -typedef AuthorizationCommit = - Future Function(Future Function() persistAccount); +typedef AuthorizationCommit = Future Function( + Future Function() persistAccount, +); diff --git a/lib/src/core/auth/registration_staging.dart b/lib/src/core/auth/registration_staging.dart index 7fa685c2..208b4e8c 100644 --- a/lib/src/core/auth/registration_staging.dart +++ b/lib/src/core/auth/registration_staging.dart @@ -315,9 +315,8 @@ GoogleDesktopRegistration parseGoogleDesktopConfiguration( final secret = installed['client_secret']; final project = installed['project_id']; if (clientId is! String || - !RegExp( - r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$', - ).hasMatch(clientId) || + !RegExp(r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$') + .hasMatch(clientId) || project is! String || !RegExp(r'^[a-z][a-z0-9-]{4,62}[a-z0-9]$').hasMatch(project) || (secret != null && diff --git a/lib/src/core/http/bounded_http.dart b/lib/src/core/http/bounded_http.dart index cfd4f418..06f75de4 100644 --- a/lib/src/core/http/bounded_http.dart +++ b/lib/src/core/http/bounded_http.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:http/http.dart' as http; + import '../auth/oauth_models.dart'; /// Aborts transport and stream consumption, and bounds the decoded body. diff --git a/lib/src/core/http/request_dispatch_exception.dart b/lib/src/core/http/request_dispatch_exception.dart index 42aa3f5f..12a43702 100644 --- a/lib/src/core/http/request_dispatch_exception.dart +++ b/lib/src/core/http/request_dispatch_exception.dart @@ -1,4 +1,5 @@ import 'dart:collection'; + import '../auth/oauth_models.dart'; /// A request failure that is known to have happened before the HTTP client diff --git a/lib/src/core/http/shared_graph_transport.dart b/lib/src/core/http/shared_graph_transport.dart new file mode 100644 index 00000000..ae7a415b --- /dev/null +++ b/lib/src/core/http/shared_graph_transport.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; + +import 'package:busystack_graph/busystack_graph.dart'; +import 'package:http/http.dart' as http; + +import 'request_dispatch_exception.dart'; + +typedef BusyMaxGraphAuthorizationHeader = Future Function( + String? claims, +); + +/// Thin BusyMax error/authorization adapter over the shared Graph pipeline. +/// Calendar, task and attachment endpoint policy stays in their callers. +final class BusyMaxGraphTransport { + BusyMaxGraphTransport({ + required http.Client httpClient, + required BusyMaxGraphAuthorizationHeader authorizationHeader, + Future Function()? recoverUnauthorized, + int maximumResponseBytes = 32 * 1024 * 1024, + DateTime Function()? nowUtc, + }) : _nowUtc = nowUtc ?? DateTime.now, + graph = GraphClient( + httpClient: httpClient, + maximumResponseBytes: maximumResponseBytes, + authenticationRecovery: recoverUnauthorized == null + ? null + : () async { + try { + await recoverUnauthorized(); + } on Object catch (error) { + throw GraphTokenProviderException( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + ); + } + }, + tokenProvider: ({claims}) async { + String header; + try { + header = await authorizationHeader(claims); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + GraphTokenProviderException( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + ), + stackTrace, + ); + } + final match = RegExp( + r'^Bearer ([^\s\r\n]+)$', + caseSensitive: false, + ).firstMatch(header); + if (match == null) { + throw StateError('Invalid Graph authorization header.'); + } + return GraphAuthorization(match.group(1)!); + }, + ); + + final GraphClient graph; + final DateTime Function() _nowUtc; + + Future request( + String method, + Uri uri, { + Map? body, + List? bytes, + Map headers = const {}, + }) async { + try { + final response = await graph.request( + method, + uri.toString(), + json: body, + bytes: bytes, + headers: headers, + ); + return http.Response.bytes( + response.bodyBytes, + response.statusCode, + headers: response.headers, + ); + } on GraphTokenProviderException catch (error) { + throw error.cause; + } on GraphException catch (error) { + if (error.kind == GraphFailure.authentication && + error.statusCode == null) { + throw KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ); + } + final status = error.statusCode; + if (status == null) { + if (error.kind == GraphFailure.unknownOutcome || + error.kind == GraphFailure.connectivity || + error.kind == GraphFailure.timeout || + error.kind == GraphFailure.tls) { + throw http.ClientException('Microsoft Graph response unavailable.'); + } + rethrow; + } + return http.Response( + jsonEncode({ + 'error': {'code': error.code, 'message': error.code}, + }), + status, + headers: { + if (error.requestId != null) 'request-id': error.requestId!, + if (error.clientRequestId != null) + 'client-request-id': error.clientRequestId!, + if (error.retryAfterProvided && error.retryAt != null) + 'retry-after': '${_retryAfterSeconds(error.retryAt!)}', + }, + ); + } + } + + int _retryAfterSeconds(DateTime retryAt) { + final remaining = retryAt.difference(_nowUtc().toUtc()).inMicroseconds; + if (remaining <= 0) return 0; + // Round up fractional seconds without extending an exact server delay. + return (remaining + Duration.microsecondsPerSecond - 1) ~/ + Duration.microsecondsPerSecond; + } + + Future> download(Uri uri, {required int maximumBytes}) async { + final bytes = []; + try { + await for (final chunk in graph.download(uri.toString())) { + if (bytes.length + chunk.length > maximumBytes) { + throw const GraphException( + GraphFailure.protocol, + 'download-too-large', + ); + } + bytes.addAll(chunk); + } + } on GraphTokenProviderException catch (error) { + throw error.cause; + } on GraphException catch (error) { + if (error.kind == GraphFailure.authentication && + error.statusCode == null) { + throw KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ); + } + if (error.statusCode == null && + (error.kind == GraphFailure.connectivity || + error.kind == GraphFailure.timeout || + error.kind == GraphFailure.tls)) { + throw http.ClientException('Microsoft Graph response unavailable.'); + } + rethrow; + } + return bytes; + } + + Future uploadPreauthorized( + Uri uri, { + required List bytes, + required Map headers, + }) => graph.upload( + GraphUploadDestination(uri), + source: Stream>.value(bytes), + length: bytes.length, + headers: headers, + ); + + void close() => graph.close(); +} diff --git a/lib/src/core/secrets/secret_store.dart b/lib/src/core/secrets/secret_store.dart index 22fdb6e4..ecfa28b0 100644 --- a/lib/src/core/secrets/secret_store.dart +++ b/lib/src/core/secrets/secret_store.dart @@ -272,9 +272,8 @@ SecretRecord _decodeBoundOAuth(Map json) { final clientId = _requiredSecretString(json, 'clientId'); if (json['representation'] == 'google_desktop_v1' && legacy.provider == BusyProvider.google) { - if (!RegExp( - r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$', - ).hasMatch(clientId)) { + if (!RegExp(r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$') + .hasMatch(clientId)) { throw const SecretStoreCorruptException('Invalid Google client binding.'); } return GoogleDesktopCredential( @@ -630,10 +629,12 @@ extension OAuthSecretStoreAccess on SecretStore { if (credential == null) { return null; } - if (credential case OAuthSecretRecord( - provider: final provider, - tokenSet: final tokenSet, - ) when provider == expectedProvider) { + if (credential + case OAuthSecretRecord( + provider: final provider, + tokenSet: final tokenSet, + ) + when provider == expectedProvider) { return tokenSet; } throw SecretStoreCredentialMismatchException( diff --git a/lib/src/core/time/linux_gweather_location_source.dart b/lib/src/core/time/linux_gweather_location_source.dart index d0949f8a..6acedd51 100644 --- a/lib/src/core/time/linux_gweather_location_source.dart +++ b/lib/src/core/time/linux_gweather_location_source.dart @@ -106,16 +106,14 @@ final class _GWeatherLocation extends Opaque {} typedef _GetWorldNative = Pointer<_GWeatherLocation> Function(); typedef _GetWorldDart = Pointer<_GWeatherLocation> Function(); -typedef _NextChildNative = - Pointer<_GWeatherLocation> Function( - Pointer<_GWeatherLocation>, - Pointer<_GWeatherLocation>, - ); -typedef _NextChildDart = - Pointer<_GWeatherLocation> Function( - Pointer<_GWeatherLocation>, - Pointer<_GWeatherLocation>, - ); +typedef _NextChildNative = Pointer<_GWeatherLocation> Function( + Pointer<_GWeatherLocation>, + Pointer<_GWeatherLocation>, +); +typedef _NextChildDart = Pointer<_GWeatherLocation> Function( + Pointer<_GWeatherLocation>, + Pointer<_GWeatherLocation>, +); typedef _GetLevelNative = Int32 Function(Pointer<_GWeatherLocation>); typedef _GetLevelDart = int Function(Pointer<_GWeatherLocation>); typedef _GetStringNative = Pointer Function(Pointer<_GWeatherLocation>); diff --git a/lib/src/core/time/provider_date_time.dart b/lib/src/core/time/provider_date_time.dart index de8d96ec..5c7d1321 100644 --- a/lib/src/core/time/provider_date_time.dart +++ b/lib/src/core/time/provider_date_time.dart @@ -1,5 +1,6 @@ import 'package:timezone/data/latest_all.dart' as time_zone_data; import 'package:timezone/timezone.dart' as time_zone; + import 'windows_time_zone_ids.dart'; var _timeZonesInitialized = false; diff --git a/lib/src/dav/auth/dav_account_dialogs.dart b/lib/src/dav/auth/dav_account_dialogs.dart index 1d338bf5..73a164bc 100644 --- a/lib/src/dav/auth/dav_account_dialogs.dart +++ b/lib/src/dav/auth/dav_account_dialogs.dart @@ -151,9 +151,8 @@ final class _AppleCredentialDialogState extends State<_AppleCredentialDialog> { final email = _email.text.trim(); final password = _password.text.trim(); if (email.isEmpty || password.isEmpty) return; - Navigator.of( - context, - ).pop(AppleICloudCredentialInput(email: email, password: password)); + Navigator.of(context) + .pop(AppleICloudCredentialInput(email: email, password: password)); } } diff --git a/lib/src/dav/auth/dav_account_onboarding_service.dart b/lib/src/dav/auth/dav_account_onboarding_service.dart index 1f01ae46..6c00e693 100644 --- a/lib/src/dav/auth/dav_account_onboarding_service.dart +++ b/lib/src/dav/auth/dav_account_onboarding_service.dart @@ -12,20 +12,18 @@ import '../discovery/dav_discovery_repository.dart'; import '../http/dav_http_transport.dart'; import 'nextcloud_login_flow_v2.dart'; -typedef DavOnboardingDiscovery = - Future Function({ - required String accountId, - required BusyProvider provider, - required Uri accountAuthority, - required DavBasicCredential credential, - DavCancellationToken? cancellationToken, - }); +typedef DavOnboardingDiscovery = Future Function({ + required String accountId, + required BusyProvider provider, + required Uri accountAuthority, + required DavBasicCredential credential, + DavCancellationToken? cancellationToken, +}); -typedef NextcloudCredentialRevoker = - Future Function({ - required String accountId, - required NextcloudSecretRecord credential, - }); +typedef NextcloudCredentialRevoker = Future Function({ + required String accountId, + required NextcloudSecretRecord credential, +}); final class DavAccountConnectionResult { const DavAccountConnectionResult({ diff --git a/lib/src/dav/dav_errors.dart b/lib/src/dav/dav_errors.dart index 0cf56174..1f197ee1 100644 --- a/lib/src/dav/dav_errors.dart +++ b/lib/src/dav/dav_errors.dart @@ -1,296 +1,11 @@ -import 'dart:io'; - -enum DavErrorKind { - cancelled, - timeout, - network, - tls, - authentication, - authorization, - redirectRejected, - redirectLoop, - responseTooLarge, - malformedXml, - malformedStatus, - protocol, - rateLimited, - invalidSyncToken, - preconditionFailed, - uidConflict, - invalidCalendarData, - unsupportedComponent, - maximumResourceSize, - limitExceeded, - notFound, - conflict, - server, -} - -/// DAV failure categories used by retry policy and user-facing error handling. -enum DavErrorCategory { - davAuthRejected, - davCredentialsRevoked, - davTlsFailure, - davDiscoveryFailed, - davUnsupportedServer, - davPermissionDenied, - davResourceConflict, - davUidConflict, - davMalformedResource, - davUnsupportedComponent, - davQuotaOrSizeLimit, - davRateLimited, - davTransientNetwork, - davServerUnavailable, - davSyncTokenInvalid, - davCollectionRemoved, - davReadOnly, - davProtocolViolation, - davOperationCancelled, -} - -final class DavErrorDisposition { - const DavErrorDisposition({ - required this.category, - required this.retryable, - required this.requiresUserAction, - required this.cachedDataUsable, - }); - - final DavErrorCategory category; - final bool retryable; - final bool requiresUserAction; - final bool cachedDataUsable; -} - -/// A typed DAV failure whose diagnostic representation excludes request URLs, -/// response bodies, credentials, and user calendar content. -final class DavException implements Exception { - const DavException({ - required this.kind, - required this.code, - required this.safeMessage, - this.statusCode, - this.correlationId, - this.retryAfter, - this.categoryOverride, - }); - - final DavErrorKind kind; - final String code; - final String safeMessage; - final int? statusCode; - final String? correlationId; - final Duration? retryAfter; - final DavErrorCategory? categoryOverride; - - DavErrorDisposition get disposition => classifyDavError( - kind: kind, - code: code, - statusCode: statusCode, - categoryOverride: categoryOverride, - ); - - DavErrorCategory get category => disposition.category; - bool get retryable => disposition.retryable; - bool get requiresUserAction => disposition.requiresUserAction; - bool get cachedDataUsable => disposition.cachedDataUsable; - - @override - String toString() => - 'DavException(kind: ${kind.name}, code: $code, ' - 'statusCode: $statusCode, correlationId: $correlationId)'; -} - -bool isDavCollectionPendingChangesError(Object error) => - error is DavException && error.code == 'DavCollectionHasPendingChanges'; - -DavErrorDisposition classifyDavError({ - required DavErrorKind kind, - required String code, - int? statusCode, - DavErrorCategory? categoryOverride, -}) { - final category = - categoryOverride ?? - _categoryFor(kind: kind, code: code, statusCode: statusCode); - final retryable = switch (category) { - DavErrorCategory.davRateLimited || - DavErrorCategory.davTransientNetwork || - DavErrorCategory.davServerUnavailable => true, - DavErrorCategory.davDiscoveryFailed => - kind == DavErrorKind.timeout || - kind == DavErrorKind.network || - kind == DavErrorKind.server || - kind == DavErrorKind.rateLimited, - _ => false, - }; - final requiresUserAction = switch (category) { - DavErrorCategory.davAuthRejected || - DavErrorCategory.davCredentialsRevoked || - DavErrorCategory.davTlsFailure || - DavErrorCategory.davUnsupportedServer || - DavErrorCategory.davPermissionDenied || - DavErrorCategory.davResourceConflict || - DavErrorCategory.davUidConflict || - DavErrorCategory.davReadOnly => true, - DavErrorCategory.davDiscoveryFailed => !retryable, - _ => false, - }; - final cachedDataUsable = switch (category) { - DavErrorCategory.davMalformedResource || - DavErrorCategory.davUnsupportedComponent || - DavErrorCategory.davCollectionRemoved => false, - _ => true, - }; - return DavErrorDisposition( - category: category, - retryable: retryable, - requiresUserAction: requiresUserAction, - cachedDataUsable: cachedDataUsable, - ); -} - -DavErrorCategory _categoryFor({ - required DavErrorKind kind, - required String code, - required int? statusCode, -}) { - final normalizedCode = code.toLowerCase(); - if (normalizedCode.contains('credentialsrevoked')) { - return DavErrorCategory.davCredentialsRevoked; - } - if (normalizedCode.contains('authrejected')) { - return DavErrorCategory.davAuthRejected; - } - if (normalizedCode.contains('unsupportedserver') || - normalizedCode.contains('unsupportedprofile')) { - return DavErrorCategory.davUnsupportedServer; - } - if (normalizedCode.contains('discoveryfailed')) { - return DavErrorCategory.davDiscoveryFailed; - } - if (normalizedCode.contains('permissiondenied')) { - return DavErrorCategory.davPermissionDenied; - } - if (normalizedCode.contains('readonly')) { - return DavErrorCategory.davReadOnly; - } - if (normalizedCode.contains('uidconflict')) { - return DavErrorCategory.davUidConflict; - } - if (normalizedCode.contains('resourceconflict') || - normalizedCode.contains('resourcelocked') || - normalizedCode.startsWith('davconflict')) { - return DavErrorCategory.davResourceConflict; - } - if (normalizedCode.contains('malformedresource')) { - return DavErrorCategory.davMalformedResource; - } - if (normalizedCode.contains('unsupportedcomponent')) { - return DavErrorCategory.davUnsupportedComponent; - } - if (normalizedCode.contains('quotaorsizelimit') || - normalizedCode.contains('maximumresourcesize')) { - return DavErrorCategory.davQuotaOrSizeLimit; - } - if (normalizedCode.contains('ratelimited')) { - return DavErrorCategory.davRateLimited; - } - if (normalizedCode.contains('tlsfailure')) { - return DavErrorCategory.davTlsFailure; - } - if (normalizedCode.contains('networkfailure') || - normalizedCode.contains('timeout')) { - return DavErrorCategory.davTransientNetwork; - } - if (normalizedCode.contains('serverunavailable')) { - return DavErrorCategory.davServerUnavailable; - } - if (normalizedCode.contains('synctokeninvalid')) { - return DavErrorCategory.davSyncTokenInvalid; - } - if (normalizedCode.contains('collectionremoved') || - normalizedCode.contains('collectionnotfound')) { - return DavErrorCategory.davCollectionRemoved; - } - - if (statusCode != null) { - if (statusCode == HttpStatus.unauthorized) { - return DavErrorCategory.davAuthRejected; - } - if (statusCode == HttpStatus.forbidden) { - return DavErrorCategory.davPermissionDenied; - } - if (statusCode == HttpStatus.notFound || statusCode == HttpStatus.gone) { - return DavErrorCategory.davCollectionRemoved; - } - if (statusCode == HttpStatus.conflict || - statusCode == HttpStatus.preconditionFailed || - statusCode == HttpStatus.locked) { - return DavErrorCategory.davResourceConflict; - } - if (statusCode == HttpStatus.tooManyRequests) { - return DavErrorCategory.davRateLimited; - } - if (statusCode == HttpStatus.insufficientStorage) { - return DavErrorCategory.davQuotaOrSizeLimit; - } - if (statusCode >= 500) { - return DavErrorCategory.davServerUnavailable; - } - } - - return switch (kind) { - DavErrorKind.cancelled => DavErrorCategory.davOperationCancelled, - DavErrorKind.timeout || - DavErrorKind.network => DavErrorCategory.davTransientNetwork, - DavErrorKind.tls => DavErrorCategory.davTlsFailure, - DavErrorKind.authentication => DavErrorCategory.davAuthRejected, - DavErrorKind.authorization => DavErrorCategory.davPermissionDenied, - DavErrorKind.rateLimited => DavErrorCategory.davRateLimited, - DavErrorKind.invalidSyncToken => DavErrorCategory.davSyncTokenInvalid, - DavErrorKind.preconditionFailed || - DavErrorKind.conflict => DavErrorCategory.davResourceConflict, - DavErrorKind.uidConflict => DavErrorCategory.davUidConflict, - DavErrorKind.invalidCalendarData => DavErrorCategory.davMalformedResource, - DavErrorKind.unsupportedComponent => - DavErrorCategory.davUnsupportedComponent, - DavErrorKind.maximumResourceSize || - DavErrorKind.limitExceeded || - DavErrorKind.responseTooLarge => DavErrorCategory.davQuotaOrSizeLimit, - DavErrorKind.notFound => DavErrorCategory.davCollectionRemoved, - DavErrorKind.server => DavErrorCategory.davServerUnavailable, - DavErrorKind.redirectRejected || - DavErrorKind.redirectLoop || - DavErrorKind.malformedXml || - DavErrorKind.malformedStatus || - DavErrorKind.protocol => DavErrorCategory.davProtocolViolation, - }; -} - -/// Parses a server retry hint without including the original header in an -/// exception or log message. -Duration? parseDavRetryAfter( - String? value, { - DateTime? nowUtc, - Duration maximum = const Duration(hours: 24), -}) { - final trimmed = value?.trim(); - if (trimmed == null || trimmed.isEmpty) return null; - Duration? result; - final seconds = int.tryParse(trimmed); - if (seconds != null) { - if (seconds < 0) return null; - result = Duration(seconds: seconds); - } else { - try { - final deadline = HttpDate.parse(trimmed).toUtc(); - result = deadline.difference((nowUtc ?? DateTime.now().toUtc()).toUtc()); - if (result.isNegative) result = Duration.zero; - } on Object { - return null; - } - } - return result > maximum ? maximum : result; -} +// BusyMax keeps its error presentation and provider policies app-owned, but +// uses the shared DAV failure taxonomy at every protocol boundary. +export 'package:busystack_dav/busystack_dav.dart' + show + DavErrorKind, + DavErrorCategory, + DavErrorDisposition, + DavException, + isDavCollectionPendingChangesError, + classifyDavError, + parseDavRetryAfter; diff --git a/lib/src/dav/dav_href.dart b/lib/src/dav/dav_href.dart index 320777b9..b2826512 100644 --- a/lib/src/dav/dav_href.dart +++ b/lib/src/dav/dav_href.dart @@ -1,3 +1,5 @@ +import 'package:busystack_dav/busystack_dav.dart' as shared; + import '../providers/busy_provider.dart'; import 'dav_errors.dart'; import 'dav_provider_profile.dart'; @@ -8,28 +10,13 @@ Uri resolveDavHref({ required DavProviderProfile profile, required Uri accountAuthority, String? correlationId, -}) { - final source = href.trim(); - if (source.isEmpty) { - throw _invalidHref(correlationId); - } - late final Uri resolved; - try { - resolved = responseRequestUri.resolve(source); - } on FormatException { - throw _invalidHref(correlationId); - } - if (resolved.userInfo.isNotEmpty || - resolved.hasFragment || - resolved.hasQuery || - !profile.isTrustedCredentialDestination( - resolved, - accountAuthority: accountAuthority, - )) { - throw _invalidHref(correlationId); - } - return resolved; -} +}) => shared.resolveDavHref( + href: href, + responseRequestUri: responseRequestUri, + profile: sharedDavProviderProfile(profile), + accountAuthority: accountAuthority, + correlationId: correlationId, +); /// Returns the stable account-relative DAV identity without decoding or /// re-encoding percent-escaped reserved path octets. iCloud shard hosts are @@ -43,12 +30,5 @@ String normalizedDavHrefKey(BusyProvider provider, Uri requestUri) { safeMessage: 'A DAV resource did not have an absolute path.', ); } - return requestUri.path; + return shared.normalizedDavHrefKey(requestUri); } - -DavException _invalidHref(String? correlationId) => DavException( - kind: DavErrorKind.redirectRejected, - code: 'DavHrefDestinationRejected', - safeMessage: 'The DAV server returned an unsafe resource location.', - correlationId: correlationId, -); diff --git a/lib/src/dav/dav_provider_profile.dart b/lib/src/dav/dav_provider_profile.dart index 1c8854b8..66c0df7d 100644 --- a/lib/src/dav/dav_provider_profile.dart +++ b/lib/src/dav/dav_provider_profile.dart @@ -1,3 +1,5 @@ +import 'package:busystack_dav/busystack_dav.dart' as shared; + import '../providers/busy_provider.dart'; const davProviderProfileVersion = 4; @@ -45,6 +47,24 @@ final class DavProviderProfile { } } +/// Narrows BusyMax's provider-capability profile to the credential-destination +/// policy consumed by the shared DAV mechanics. +shared.DavProviderProfile sharedDavProviderProfile( + DavProviderProfile profile, +) => switch (profile.provider) { + BusyProvider.appleICloud => const shared.DavProviderProfile.iCloudCalDav(), + BusyProvider.nextcloud => shared.DavProviderProfile.nextcloud( + allowInsecureLoopback: profile.allowInsecureLoopbackForTesting, + ), + BusyProvider.google || + BusyProvider.microsoft || + BusyProvider.webCal => throw ArgumentError.value( + profile.provider, + 'profile', + 'The provider does not use the DAV transport.', + ), +}; + bool _isInsecureLoopbackUri(Uri uri) => uri.scheme.toLowerCase() == 'http' && (uri.host == '127.0.0.1' || uri.host == '::1' || uri.host == 'localhost') && diff --git a/lib/src/dav/discovery/dav_discovery_service.dart b/lib/src/dav/discovery/dav_discovery_service.dart index c9700e64..f0031dbc 100644 --- a/lib/src/dav/discovery/dav_discovery_service.dart +++ b/lib/src/dav/discovery/dav_discovery_service.dart @@ -45,6 +45,7 @@ final class DavDiscoveryService { accountId: _accountId, correlationId: correlationId, retryClass: DavRetryClass.safeRead, + redirectPolicy: DavRedirectPolicy.discovery, ), credential: _credential, cancellationToken: cancellationToken, @@ -369,6 +370,7 @@ final class DavDiscoveryService { correlationId: correlationId, body: body, headers: {'depth': depth}, + redirectPolicy: DavRedirectPolicy.discovery, ), credential: _credential, cancellationToken: cancellationToken, diff --git a/lib/src/dav/http/dav_http_transport.dart b/lib/src/dav/http/dav_http_transport.dart index ea1ddaa6..ae54e5fc 100644 --- a/lib/src/dav/http/dav_http_transport.dart +++ b/lib/src/dav/http/dav_http_transport.dart @@ -1,66 +1,22 @@ -import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; +import 'package:busystack_dav/busystack_dav.dart' as shared; import 'package:http/http.dart' as http; import '../../core/http/terminating_http_client.dart'; -import '../../dav/dav_errors.dart'; -import '../dav_provider_profile.dart'; import '../../providers/busy_provider.dart'; +import '../dav_provider_profile.dart'; -enum DavRetryClass { safeRead, conditionalMutation, never } - -final class DavCancellationToken { - bool _cancelled = false; - final Completer _cancelledSignal = Completer(); - final StreamController _cancellations = - StreamController.broadcast(sync: true); - - bool get isCancelled => _cancelled; - Future get whenCancelled => _cancelledSignal.future; - Stream get cancellations => _cancellations.stream; - - void cancel() { - if (_cancelled) return; - _cancelled = true; - _cancelledSignal.complete(); - _cancellations.add(null); - unawaited(_cancellations.close()); - } - - void throwIfCancelled({String? correlationId}) { - if (_cancelled) { - throw DavException( - kind: DavErrorKind.cancelled, - code: 'DavOperationCancelled', - safeMessage: 'The DAV operation was cancelled.', - correlationId: correlationId, - ); - } - } -} - -final class DavBasicCredential { - DavBasicCredential({required String username, required String password}) - : username = username.trim(), - password = password.trim() { - if (this.username.isEmpty || this.password.isEmpty) { - throw ArgumentError('DAV credentials must not be empty.'); - } - } - - final String username; - final String password; +typedef DavCancellationToken = shared.DavCancellationToken; +typedef DavBasicCredential = shared.DavBasicCredential; +typedef DavResponse = shared.DavResponse; +typedef DavDelay = shared.DavDelay; - String get authorizationValue => - 'Basic ${base64Encode(utf8.encode('$username:$password'))}'; +enum DavRedirectPolicy { none, discovery } - @override - String toString() => 'DavBasicCredential([REDACTED])'; -} +enum DavRetryClass { safeRead, conditionalMutation, never } final class DavRequest { DavRequest({ @@ -72,6 +28,8 @@ final class DavRequest { this.headers = const {}, this.bodyBytes, this.retryClass = DavRetryClass.never, + this.redirectPolicy = DavRedirectPolicy.none, + this.mutationMayHaveCommitted = true, }) { if (uri.userInfo.isNotEmpty) { throw ArgumentError.value( @@ -96,6 +54,7 @@ final class DavRequest { String? collectionId, Map headers = const {}, DavRetryClass retryClass = DavRetryClass.safeRead, + DavRedirectPolicy redirectPolicy = DavRedirectPolicy.none, }) => DavRequest( method: method, uri: uri, @@ -105,6 +64,7 @@ final class DavRequest { headers: {'content-type': 'application/xml; charset=utf-8', ...headers}, bodyBytes: Uint8List.fromList(utf8.encode(body)), retryClass: retryClass, + redirectPolicy: redirectPolicy, ); factory DavRequest.icalendar({ @@ -134,26 +94,8 @@ final class DavRequest { final Map headers; final Uint8List? bodyBytes; final DavRetryClass retryClass; -} - -final class DavResponse { - const DavResponse({ - required this.statusCode, - required this.headers, - required this.bodyBytes, - required this.requestUri, - required this.correlationId, - }); - - final int statusCode; - final Map headers; - final Uint8List bodyBytes; - final Uri requestUri; - final String correlationId; - - String get bodyText => utf8.decode(bodyBytes, allowMalformed: false); - - String? get etag => headers['etag']; + final DavRedirectPolicy redirectPolicy; + final bool mutationMayHaveCommitted; } final class DavTransportLimits { @@ -178,8 +120,9 @@ final class DavTransportLimits { final int maximumConcurrentPerCollection; } -typedef DavDelay = Future Function(Duration duration); - +/// BusyMax product policy remains app-owned while all bounded HTTP, retry, +/// redirect, cancellation, concurrency, and mutation-outcome mechanics run +/// through the public shared DAV transport. final class DavHttpTransport { DavHttpTransport({ required http.Client client, @@ -189,27 +132,35 @@ final class DavHttpTransport { DavDelay? delay, Random? random, DateTime Function()? nowUtc, - }) : _client = client, - _profile = profile, - _accountAuthority = accountAuthority, - _limits = limits, - _delay = delay ?? Future.delayed, - _random = random ?? Random.secure(), - _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); + }) : _profile = profile, + _transport = shared.DavHttpTransport( + client: client is TerminatingHttpClient + ? _TerminatingClientBridge(client) + : client, + profile: sharedDavProviderProfile(profile), + accountAuthority: accountAuthority, + limits: shared.DavTransportLimits( + connectTimeout: limits.connectTimeout, + responseTimeout: limits.responseTimeout, + operationTimeout: limits.operationTimeout, + maximumResponseBytes: limits.maximumResponseBytes, + maximumRedirects: limits.maximumRedirects, + maximumReadAttempts: limits.maximumReadAttempts, + maximumConcurrentPerAccount: limits.maximumConcurrentPerAccount, + maximumConcurrentPerCollection: + limits.maximumConcurrentPerCollection, + ), + delay: delay, + random: random, + nowUtc: nowUtc, + ); - final http.Client _client; final DavProviderProfile _profile; - final Uri _accountAuthority; - final DavTransportLimits _limits; - final DavDelay _delay; - final Random _random; - final DateTime Function() _nowUtc; - final _accountSemaphores = {}; - final _collectionSemaphores = {}; + final shared.DavHttpTransport _transport; Set _serverFeatures = const {}; - /// Features come from successful OPTIONS/discovery, never from a guessed - /// user agent. Only Nextcloud's trusted DAV requests receive its extension. + /// Nextcloud's negotiated calendar cache extension remains provider policy, + /// not a generic behavior of the shared transport. void setServerFeatures(Iterable features) { _serverFeatures = Set.unmodifiable(features); } @@ -219,564 +170,60 @@ final class DavHttpTransport { required DavBasicCredential credential, DavCancellationToken? cancellationToken, }) { - final token = cancellationToken ?? DavCancellationToken(); - return _withConcurrencyLimit(request, token, () async { - final context = _DavOperationContext( - externalToken: token, - timeout: _limits.operationTimeout, - correlationId: request.correlationId, - ); - try { - return await _sendWithRetry(request, credential, context); - } finally { - context.dispose(); - } - }); - } - - Future _withConcurrencyLimit( - DavRequest request, - DavCancellationToken token, - Future Function() action, - ) async { - final account = _accountSemaphores.putIfAbsent( - request.accountId, - () => _AsyncSemaphore(_limits.maximumConcurrentPerAccount), - ); - await account.acquire(token, correlationId: request.correlationId); - _AsyncSemaphore? collection; - var collectionAcquired = false; - try { - final collectionId = request.collectionId; - if (collectionId != null) { - collection = _collectionSemaphores.putIfAbsent( - '${request.accountId}|$collectionId', - () => _AsyncSemaphore(_limits.maximumConcurrentPerCollection), - ); - await collection.acquire(token, correlationId: request.correlationId); - collectionAcquired = true; - } - token.throwIfCancelled(correlationId: request.correlationId); - return await action(); - } finally { - if (collectionAcquired) collection!.release(); - account.release(); - } - } - - Future _sendWithRetry( - DavRequest request, - DavBasicCredential credential, - _DavOperationContext context, - ) async { - final attempts = request.retryClass == DavRetryClass.safeRead - ? _limits.maximumReadAttempts - : 1; - Object? lastError; - for (var attempt = 1; attempt <= attempts; attempt += 1) { - context.throwIfCancelled(); - try { - final response = await _sendFollowingRedirects( - request, - credential, - context, - ); - if (!_isRetryableStatus(response.statusCode) || attempt == attempts) { - return response; - } - final honorsRetryAfter = - response.statusCode == HttpStatus.tooManyRequests || - response.statusCode == HttpStatus.serviceUnavailable; - await context.waitFor( - _delay( - _retryDelay( - honorsRetryAfter ? response.headers['retry-after'] : null, - attempt, - ), - ), - ); - } on DavException catch (error) { - lastError = error; - if (context.isCancelled) context.throwIfCancelled(); - if (attempt == attempts || !_isRetryableException(error)) { - rethrow; - } - await context.waitFor(_delay(_retryDelay(null, attempt))); - } on Object catch (error) { - lastError = error; - if (context.isCancelled) context.throwIfCancelled(); - if (attempt == attempts) { - throw _networkException(error, request.correlationId); - } - await context.waitFor(_delay(_retryDelay(null, attempt))); - } - } - throw _networkException(lastError, request.correlationId); - } - - Future _sendFollowingRedirects( - DavRequest request, - DavBasicCredential credential, - _DavOperationContext context, - ) async { - var currentUri = request.uri; - var method = request.method.toUpperCase(); - var body = request.bodyBytes; - final visited = {}; - - for ( - var redirectCount = 0; - redirectCount <= _limits.maximumRedirects; - redirectCount += 1 - ) { - context.throwIfCancelled(); - if (!visited.add(currentUri.toString())) { - throw DavException( - kind: DavErrorKind.redirectLoop, - code: 'DavRedirectLoop', - safeMessage: 'The DAV server returned a redirect loop.', - correlationId: request.correlationId, - ); - } - if (!_profile.isTrustedCredentialDestination( - currentUri, - accountAuthority: _accountAuthority, - )) { - throw DavException( - kind: DavErrorKind.redirectRejected, - code: 'DavRedirectDestinationRejected', - safeMessage: 'The DAV server redirected to an untrusted destination.', - correlationId: request.correlationId, - ); - } - - final abort = Completer(); - final outbound = - http.AbortableRequest(method, currentUri, abortTrigger: abort.future) - ..followRedirects = false - ..headers.addAll(request.headers) - ..headers['authorization'] = credential.authorizationValue - ..headers['x-busymax-correlation-id'] = request.correlationId; + final headers = { + ...request.headers, + // Preserve BusyMax correlation diagnostics while the shared transport + // also emits its suite-level correlation header. + 'x-busymax-correlation-id': request.correlationId, if (_profile.provider == BusyProvider.nextcloud && - _serverFeatures.contains('nc-calendar-webcal-cache')) { - outbound.headers['X-NC-CalDAV-Webcal-Caching'] = 'On'; - } - if (body != null) { - outbound.bodyBytes = body; - } - - late final http.StreamedResponse streamed; - late final Uint8List responseBytes; - try { - streamed = await _sendAttempt( - outbound, - abort, - context, - request.correlationId, - ); - responseBytes = await _readBoundedBody( - streamed, - abort, - context, - request.correlationId, - ); - } finally { - // Also closes an isolated native attempt after its body is consumed. - if (!abort.isCompleted) abort.complete(); - } - final headers = { - for (final entry in streamed.headers.entries) - entry.key.toLowerCase(): entry.value, - }; - - if (!_isRedirect(streamed.statusCode)) { - return DavResponse( - statusCode: streamed.statusCode, - headers: headers, - bodyBytes: responseBytes, - requestUri: currentUri, - correlationId: request.correlationId, - ); - } - final location = headers['location']; - if (location == null || location.trim().isEmpty) { - throw DavException( - kind: DavErrorKind.protocol, - code: 'DavRedirectMissingLocation', - safeMessage: 'The DAV server returned an invalid redirect.', - statusCode: streamed.statusCode, - correlationId: request.correlationId, - ); - } - if (redirectCount == _limits.maximumRedirects) { - throw DavException( - kind: DavErrorKind.redirectLoop, - code: 'DavRedirectLimitExceeded', - safeMessage: 'The DAV server returned too many redirects.', - correlationId: request.correlationId, - ); - } - final destination = currentUri.resolve(location.trim()); - if (streamed.statusCode == HttpStatus.seeOther && - method != 'GET' && - method != 'HEAD') { - throw DavException( - kind: DavErrorKind.redirectRejected, - code: 'DavMutationSeeOtherRejected', - safeMessage: 'The DAV server returned an unsafe mutation redirect.', - statusCode: streamed.statusCode, - correlationId: request.correlationId, - ); - } - currentUri = destination; - } - throw StateError('Unreachable redirect loop termination.'); - } - - Future _readBoundedBody( - http.StreamedResponse response, - Completer abort, - _DavOperationContext context, - String correlationId, - ) async { - final builder = BytesBuilder(copy: false); - var length = 0; - final completed = Completer(); - Timer? responseTimer; - StreamSubscription>? subscription; - - void abortRequest() { - if (!abort.isCompleted) abort.complete(); - } - - void completeError(Object error, [StackTrace? stackTrace]) { - if (completed.isCompleted) return; - abortRequest(); - if (stackTrace == null) { - completed.completeError(error); - } else { - completed.completeError(error, stackTrace); - } - unawaited(subscription?.cancel()); - } - - void armResponseTimer() { - responseTimer?.cancel(); - responseTimer = Timer(_limits.responseTimeout, () { - completeError( - DavException( - kind: DavErrorKind.timeout, - code: 'DavResponseTimeout', - safeMessage: 'The DAV response body timed out.', - statusCode: response.statusCode, - correlationId: correlationId, - ), - ); - }); - } - - armResponseTimer(); - subscription = response.stream.listen( - (chunk) { - if (completed.isCompleted) return; - armResponseTimer(); - length += chunk.length; - if (length > _limits.maximumResponseBytes) { - completeError( - DavException( - kind: DavErrorKind.responseTooLarge, - code: 'DavResponseTooLarge', - safeMessage: - 'The DAV response exceeded the configured size limit.', - statusCode: response.statusCode, - correlationId: correlationId, - ), - ); - return; - } - builder.add(chunk); - }, - onError: (Object error, StackTrace stackTrace) { - if (context.isCancelled) { - completeError(context.cancellationException, stackTrace); - } else { - completeError(error, stackTrace); - } - }, - onDone: () { - if (!completed.isCompleted) completed.complete(); - }, - cancelOnError: true, - ); - unawaited( - context.whenCancelled.then((_) { - completeError(context.cancellationException); - }), - ); - try { - await completed.future; - } finally { - responseTimer?.cancel(); - await subscription.cancel(); - } - return builder.takeBytes(); - } - - Future _sendAttempt( - http.AbortableRequest request, - Completer abort, - _DavOperationContext context, - String correlationId, - ) async { - final connectTimeout = Completer(); - final timer = Timer(_limits.connectTimeout, () { - if (!connectTimeout.isCompleted) { - connectTimeout.completeError( - DavException( - kind: DavErrorKind.timeout, - code: 'DavConnectTimeout', - safeMessage: 'The DAV server did not accept a connection in time.', - correlationId: correlationId, - ), - ); - } - }); - final client = _client; - final sendFuture = client is TerminatingHttpClient - ? client.sendTerminating( - request, - terminate: abort.future, - connectionTimeout: _limits.connectTimeout, - ) - : client.send(request); - final cancellationFuture = context.whenCancelled - .then( - (_) => throw context.cancellationException, - ); - try { - return await Future.any([ - sendFuture, - connectTimeout.future, - cancellationFuture, - ]); - } on Object { - if (!abort.isCompleted) abort.complete(); - // Production native requests receive an operation-local force-close - // above. Observe late completion without extending the caller's - // configured deadline or leaking an asynchronous error. - unawaited(sendFuture.then((_) {}, onError: (_, _) {})); - rethrow; - } finally { - timer.cancel(); - } - } - - bool _isRetryableStatus(int statusCode) => - statusCode == HttpStatus.tooManyRequests || - statusCode == HttpStatus.serviceUnavailable || - statusCode == HttpStatus.badGateway || - statusCode == HttpStatus.gatewayTimeout || - statusCode == HttpStatus.internalServerError; - - bool _isRetryableException(DavException error) => - error.kind == DavErrorKind.timeout || - error.kind == DavErrorKind.network || - error.kind == DavErrorKind.tls; - - Duration _retryDelay(String? retryAfter, int attempt) { - final value = retryAfter?.trim() ?? ''; - final seconds = int.tryParse(value); - if (seconds != null && seconds >= 0) { - return Duration(seconds: min(seconds, 60)); - } - if (value.isNotEmpty) { - try { - final deadline = HttpDate.parse(value).toUtc(); - final remaining = deadline.difference(_nowUtc().toUtc()); - if (remaining <= Duration.zero) return Duration.zero; - return remaining > const Duration(seconds: 60) - ? const Duration(seconds: 60) - : remaining; - } on FormatException { - // Fall through to bounded exponential backoff with jitter. - } - } - final capMilliseconds = min(8000, 250 * (1 << (attempt - 1))); - return Duration( - milliseconds: - capMilliseconds ~/ 2 + _random.nextInt(capMilliseconds ~/ 2 + 1), - ); - } - - DavException _networkException(Object? error, String correlationId) { - final tls = error is HandshakeException || error is TlsException; - return DavException( - kind: tls ? DavErrorKind.tls : DavErrorKind.network, - code: tls ? 'DavTlsFailure' : 'DavNetworkFailure', - safeMessage: tls - ? 'The DAV server TLS connection could not be verified.' - : 'The DAV server could not be reached.', - correlationId: correlationId, - ); - } -} - -bool _isRedirect(int statusCode) => - statusCode == HttpStatus.movedPermanently || - statusCode == HttpStatus.found || - statusCode == HttpStatus.seeOther || - statusCode == HttpStatus.temporaryRedirect || - statusCode == HttpStatus.permanentRedirect; - -final class _AsyncSemaphore { - _AsyncSemaphore(this.maximum) : _available = maximum { - if (maximum < 1) { - throw ArgumentError.value(maximum, 'maximum', 'Must be positive.'); - } - } - - final int maximum; - int _available; - final _waiters = <_SemaphoreWaiter>[]; - - Future acquire( - DavCancellationToken token, { - required String correlationId, - }) { - token.throwIfCancelled(correlationId: correlationId); - if (_available > 0) { - _available -= 1; - return Future.value(); - } - final waiter = _SemaphoreWaiter(); - _waiters.add(waiter); - void cancelWaiter() { - if (!waiter.isQueued) return; - if (!_waiters.remove(waiter)) return; - waiter.cancel( - DavException( - kind: DavErrorKind.cancelled, - code: 'DavOperationCancelled', - safeMessage: 'The DAV operation was cancelled.', - correlationId: correlationId, - ), - ); - } - - waiter.cancellationSubscription = token.cancellations.listen( - (_) => cancelWaiter(), + _serverFeatures.contains('nc-calendar-webcal-cache')) + 'X-NC-CalDAV-Webcal-Caching': 'On', + }; + return _transport.send( + shared.DavRequest( + method: request.method, + uri: request.uri, + accountId: request.accountId, + correlationId: request.correlationId, + collectionId: request.collectionId, + headers: headers, + bodyBytes: request.bodyBytes, + retryClass: switch (request.retryClass) { + DavRetryClass.safeRead => shared.DavRetryClass.safeRead, + DavRetryClass.conditionalMutation => + shared.DavRetryClass.conditionalMutation, + DavRetryClass.never => shared.DavRetryClass.never, + }, + redirectPolicy: switch (request.redirectPolicy) { + DavRedirectPolicy.none => shared.DavRedirectPolicy.none, + DavRedirectPolicy.discovery => shared.DavRedirectPolicy.discovery, + }, + mutationMayHaveCommitted: request.mutationMayHaveCommitted, + ), + credential: credential, + cancellationToken: cancellationToken, ); - if (token.isCancelled) cancelWaiter(); - return waiter.future; - } - - void release() { - while (_waiters.isNotEmpty) { - final waiter = _waiters.removeAt(0); - if (waiter.grant()) return; - } - if (_available >= maximum) { - throw StateError('DAV semaphore released more often than acquired.'); - } - _available += 1; } } -final class _SemaphoreWaiter { - final Completer _completer = Completer(); - var _queued = true; - StreamSubscription? cancellationSubscription; - - bool get isQueued => _queued; - Future get future => _completer.future; - - bool grant() { - if (!_queued) return false; - _queued = false; - unawaited(cancellationSubscription?.cancel()); - _completer.complete(); - return true; - } +final class _TerminatingClientBridge extends http.BaseClient + implements shared.TerminatingHttpClient { + _TerminatingClientBridge(this._delegate); - void cancel(DavException error) { - if (!_queued) return; - _queued = false; - unawaited(cancellationSubscription?.cancel()); - _completer.completeError(error); - } -} + final TerminatingHttpClient _delegate; -final class _DavOperationContext { - _DavOperationContext({ - required DavCancellationToken externalToken, - required Duration timeout, - required this.correlationId, - }) { - _timer = Timer(timeout, _timeout); - _externalCancellationSubscription = externalToken.cancellations.listen( - (_) => _cancel(), - ); - if (externalToken.isCancelled) _cancel(); - } - - final String correlationId; - final Completer _cancelled = Completer(); - late Timer _timer; - StreamSubscription? _externalCancellationSubscription; - DavException? _exception; - var _disposed = false; - - bool get isCancelled => _exception != null; - Future get whenCancelled => _cancelled.future; - DavException get cancellationException => - _exception ?? - DavException( - kind: DavErrorKind.cancelled, - code: 'DavOperationCancelled', - safeMessage: 'The DAV operation was cancelled.', - correlationId: correlationId, - ); - - void _timeout() { - if (_disposed || _exception != null) return; - _exception = DavException( - kind: DavErrorKind.timeout, - code: 'DavOperationTimeout', - safeMessage: 'The DAV operation timed out.', - correlationId: correlationId, - ); - _cancelled.complete(); - } - - void _cancel() { - if (_disposed || _exception != null) return; - _exception = DavException( - kind: DavErrorKind.cancelled, - code: 'DavOperationCancelled', - safeMessage: 'The DAV operation was cancelled.', - correlationId: correlationId, - ); - _cancelled.complete(); - } - - void throwIfCancelled() { - final error = _exception; - if (error != null) throw error; - } - - Future waitFor(Future future) { - throwIfCancelled(); - return Future.any([ - future, - whenCancelled.then((_) => throw cancellationException), - ]); - } + @override + Future send(http.BaseRequest request) => + _delegate.send(request); - void dispose() { - if (_disposed) return; - _disposed = true; - _timer.cancel(); - unawaited(_externalCancellationSubscription?.cancel()); - _externalCancellationSubscription = null; - } + @override + Future sendTerminating( + http.BaseRequest request, { + required Future terminate, + required Duration connectionTimeout, + }) => _delegate.sendTerminating( + request, + terminate: terminate, + connectionTimeout: connectionTimeout, + ); } diff --git a/lib/src/dav/ical/ical_recurrence.dart b/lib/src/dav/ical/ical_recurrence.dart index d190786f..dd83e4e2 100644 --- a/lib/src/dav/ical/ical_recurrence.dart +++ b/lib/src/dav/ical/ical_recurrence.dart @@ -1008,9 +1008,8 @@ final class _OccurrenceDuration { final recurrenceId = override.recurrenceId!; final effectiveStart = override.start ?? override.due; if (effectiveStart != null) { - final shift = _wallDateTime( - effectiveStart.localValue, - ).difference(_wallDateTime(recurrenceId.localValue)); + final shift = _wallDateTime(effectiveStart.localValue) + .difference(_wallDateTime(recurrenceId.localValue)); if (!shift.isNegative && shift.compareTo(maximumForwardShift) > 0) { maximumForwardShift = shift; } else if (shift.isNegative) { @@ -1115,9 +1114,8 @@ IcalTemporalValue? _rangeAdjustedStart( final recurrenceId = rangeOverride?.recurrenceId; final rangeStart = rangeOverride?.start ?? rangeOverride?.due; if (recurrenceId == null || rangeStart == null) return null; - final delta = _wallDateTime( - rangeStart.localValue, - ).difference(_wallDateTime(recurrenceId.localValue)); + final delta = _wallDateTime(rangeStart.localValue) + .difference(_wallDateTime(recurrenceId.localValue)); return _withWallValue( rangeStart, _wallDateTime(originalStart.localValue).add(delta), @@ -1148,9 +1146,8 @@ IcalTemporalValue? _applyDuration( if (duration.calendarDays != 0) { value = _withWallValue( value, - _wallDateTime( - value.localValue, - ).add(Duration(days: duration.calendarDays)), + _wallDateTime(value.localValue) + .add(Duration(days: duration.calendarDays)), ); } if (duration.exact == Duration.zero) return value; @@ -1246,9 +1243,8 @@ List<_ByDay> _parseByDay(String? source, int maximumValues) { } _ByDay _parseOneByDay(String source) { - final match = RegExp( - r'^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$', - ).firstMatch(source); + final match = RegExp(r'^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$') + .firstMatch(source); if (match == null) throw _invalidRule(); final ordinal = match.group(1) == null ? null : int.parse(match.group(1)!); if (ordinal == 0 || (ordinal != null && ordinal.abs() > 53)) { diff --git a/lib/src/dav/ical/ical_semantics.dart b/lib/src/dav/ical/ical_semantics.dart index fcd76631..e893dc0a 100644 --- a/lib/src/dav/ical/ical_semantics.dart +++ b/lib/src/dav/ical/ical_semantics.dart @@ -303,8 +303,7 @@ final class IcalSemanticDocument { throw const DavException( kind: DavErrorKind.invalidCalendarData, code: 'IcalCalendarObjectInvariantFailed', - safeMessage: - 'A CalDAV object must contain one component type and one UID recurrence set.', + safeMessage: 'A CalDAV object must contain one component type and one UID recurrence set.', ); } return IcalSemanticDocument._( @@ -481,9 +480,8 @@ DateTime _parseDate(String source) { } DateTime _parseDateTime(String source) { - final match = RegExp( - r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$', - ).firstMatch(source); + final match = RegExp(r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$') + .firstMatch(source); if (match == null) throw _invalidTemporal(); return _checkedDateTime( int.parse(match.group(1)!), diff --git a/lib/src/dav/ical/ical_timezone.dart b/lib/src/dav/ical/ical_timezone.dart index 4ac1f514..255db733 100644 --- a/lib/src/dav/ical/ical_timezone.dart +++ b/lib/src/dav/ical/ical_timezone.dart @@ -555,9 +555,8 @@ int _daysInMonth(int year, int month) => DateTime.utc(year, month + 1).subtract(const Duration(days: 1)).day; DateTime _parseRuleDateTime(String source) { - final match = RegExp( - r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$', - ).firstMatch(source); + final match = RegExp(r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$') + .firstMatch(source); if (match == null) throw _invalidTimeZone(); final parts = [ for (var index = 1; index <= 6; index += 1) int.parse(match.group(index)!), diff --git a/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart b/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart index 2f1938df..239a9ba6 100644 --- a/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart +++ b/lib/src/dav/mutation/dav_calendar_collection_mutation_service.dart @@ -368,6 +368,7 @@ bool _calendarCreationMayHaveCommitted(DavException error) => switch (error.kind) { DavErrorKind.timeout || DavErrorKind.network || + DavErrorKind.unknownOutcome || DavErrorKind.server => true, _ => false, }; diff --git a/lib/src/dav/mutation/dav_conditional_mutation_service.dart b/lib/src/dav/mutation/dav_conditional_mutation_service.dart index 3f75c620..bcddc82c 100644 --- a/lib/src/dav/mutation/dav_conditional_mutation_service.dart +++ b/lib/src/dav/mutation/dav_conditional_mutation_service.dart @@ -511,8 +511,7 @@ final class DavConditionalMutationService { : throw const DavException( kind: DavErrorKind.unsupportedComponent, code: 'DavSilentImportUnsupported', - safeMessage: - 'This transport cannot suppress scheduling for an import.', + safeMessage: 'This transport cannot suppress scheduling for an import.', )) : _remoteClient; var memberName = object.initialMemberName; @@ -1161,7 +1160,9 @@ DavException _partialMoveFailure(DavException error) { } bool _isUnknownOutcome(DavException error) => - error.kind == DavErrorKind.network || error.kind == DavErrorKind.timeout; + error.kind == DavErrorKind.network || + error.kind == DavErrorKind.timeout || + error.kind == DavErrorKind.unknownOutcome; DavException _readOnlyError(String correlationId) => DavException( kind: DavErrorKind.authorization, diff --git a/lib/src/dav/mutation/dav_mutation_patch.dart b/lib/src/dav/mutation/dav_mutation_patch.dart index 77845e0c..c7aab447 100644 --- a/lib/src/dav/mutation/dav_mutation_patch.dart +++ b/lib/src/dav/mutation/dav_mutation_patch.dart @@ -167,9 +167,9 @@ final class DavPatchOperation { DavPatchOperationType.replaceRepeatedRaw => DavPatchOperation.replaceRepeatedRaw( propertyName, - _mapList( - json['values'], - ).map(DavRawPropertyValue.fromJson).toList(growable: false), + _mapList(json['values']) + .map(DavRawPropertyValue.fromJson) + .toList(growable: false), ), DavPatchOperationType.setTaskProgress => DavPatchOperation.setTaskProgress( @@ -279,9 +279,9 @@ final class DavMutationPatch { uid: _requiredString(targetJson, 'uid'), recurrenceIdKey: targetJson['recurrenceIdKey'] as String?, ), - operations: _mapList( - json['operations'], - ).map(DavPatchOperation.fromJson).toList(growable: false), + operations: _mapList(json['operations']) + .map(DavPatchOperation.fromJson) + .toList(growable: false), ); } on DavException { rethrow; diff --git a/lib/src/dav/mutation/dav_pending_operations.dart b/lib/src/dav/mutation/dav_pending_operations.dart index 262d5c05..c1588eba 100644 --- a/lib/src/dav/mutation/dav_pending_operations.dart +++ b/lib/src/dav/mutation/dav_pending_operations.dart @@ -1,6 +1,8 @@ import 'dart:convert'; + import '../../features/maps/data/location_resolution_repository.dart'; import '../../features/maps/domain/location_result.dart'; + import 'dart:math'; import 'package:drift/drift.dart'; @@ -305,9 +307,8 @@ final class DavPendingOperationQueue { existing.baselineEtag == context.object.etag && existing.baselineRawIcs != null; if (!safelyEditable) throw _operationAlreadyPending(); - return _decodePatch( - existing, - ).applyTo(existing.baselineRawIcs!, nowUtc: _nowUtc().toUtc()); + return _decodePatch(existing) + .applyTo(existing.baselineRawIcs!, nowUtc: _nowUtc().toUtc()); } /// Reading an export is safe even while an immutable operation is replaying. @@ -892,9 +893,8 @@ final class DavPendingOperationQueue { ..where((row) => row.id.equals(existing.id))) .write(PendingOpsCompanion(dependsOnOpId: Value(dependency))); } - intendedSourceRaw = _decodePatch( - existing, - ).applyTo(existing.baselineRawIcs!, nowUtc: _nowUtc().toUtc()); + intendedSourceRaw = _decodePatch(existing) + .applyTo(existing.baselineRawIcs!, nowUtc: _nowUtc().toUtc()); dependency = existing.id; } final semantic = IcalSemanticDocument.parse(intendedSourceRaw); @@ -1108,8 +1108,10 @@ typedef DavMutationServiceFactory = required DavCollection collection, }); -typedef DavPendingOperationFailureHandler = - Future Function(PendingOp operation, DavException error); +typedef DavPendingOperationFailureHandler = Future Function( + PendingOp operation, + DavException error, +); final class DavReplaySummary { const DavReplaySummary({ @@ -1213,8 +1215,7 @@ final class DavPendingOperationsReplayer { final error = const DavException( kind: DavErrorKind.protocol, code: 'DavEventCopyNotConfirmed', - safeMessage: - 'The destination event was not confirmed, so the original was kept.', + safeMessage: 'The destination event was not confirmed, so the original was kept.', ); await _markFailed(op, error); await _reportPermanentFailure(op, error); @@ -1329,9 +1330,8 @@ final class DavPendingOperationsReplayer { policy.validateMoveTo( await NextcloudSchedulingPolicy.load(_database, destination), baseline, - _decodeOptionalMovePatch( - op, - )?.applyTo(baseline, nowUtc: _nowUtc()) ?? + _decodeOptionalMovePatch(op) + ?.applyTo(baseline, nowUtc: _nowUtc()) ?? baseline, ); } @@ -1995,9 +1995,8 @@ final class DavPendingOperationsReplayer { return _decodeCreate(op.requestJson).rawIcs; } if (op.operationType == 'dav.update') { - return _decodePatch( - op, - ).applyTo(_required(op.baselineRawIcs), nowUtc: _nowUtc().toUtc()); + return _decodePatch(op) + .applyTo(_required(op.baselineRawIcs), nowUtc: _nowUtc().toUtc()); } if (op.operationType == 'dav.move') { return _moveCandidateRaw(op, _nowUtc().toUtc()); diff --git a/lib/src/dav/mutation/dav_projection_mutations.dart b/lib/src/dav/mutation/dav_projection_mutations.dart index 0d767bb3..37bed62b 100644 --- a/lib/src/dav/mutation/dav_projection_mutations.dart +++ b/lib/src/dav/mutation/dav_projection_mutations.dart @@ -740,9 +740,9 @@ DavNewObject buildDavTaskObject( dueParameters: due?.parameters ?? const [], ); final target = IcalComponentKey(componentType: 'VTODO', uid: initial.uid); - final initialComponent = IcalSemanticDocument.parse( - initial.rawIcs, - ).components.single; + final initialComponent = IcalSemanticDocument.parse(initial.rawIcs) + .components + .single; final operations = [ if (_taskTemporal(fields, prefix: 'Start') case final start?) DavPatchOperation.setRaw( @@ -1806,9 +1806,9 @@ DavPatchOperation _taskCategoriesOperation( final add = desired.where((value) => !current.contains(value)).toList(); final retained = <({List values, List parameters})>[]; for (final property in properties) { - final values = _categoryValues( - property, - ).where((value) => !remove.contains(value)).toList(); + final values = _categoryValues(property) + .where((value) => !remove.contains(value)) + .toList(); if (values.isNotEmpty) { retained.add((values: values, parameters: property.parameters)); } @@ -1827,9 +1827,11 @@ DavPatchOperation _taskCategoriesOperation( ]); } -List _categoryValues(IcalProperty property) => _splitEscapedValues( - property.rawValue, -).map(decodeIcalText).where((value) => value.isNotEmpty).toList(); +List _categoryValues(IcalProperty property) => + _splitEscapedValues(property.rawValue) + .map(decodeIcalText) + .where((value) => value.isNotEmpty) + .toList(); List _splitEscapedValues(String source) { final values = []; diff --git a/lib/src/dav/mutation/dav_task_list_mutation_service.dart b/lib/src/dav/mutation/dav_task_list_mutation_service.dart index b16ec2ac..e1af742a 100644 --- a/lib/src/dav/mutation/dav_task_list_mutation_service.dart +++ b/lib/src/dav/mutation/dav_task_list_mutation_service.dart @@ -433,7 +433,10 @@ const _taskCollectionProbeXml = ''; bool _mayHaveCommitted(DavException error) => switch (error.kind) { - DavErrorKind.timeout || DavErrorKind.network || DavErrorKind.server => true, + DavErrorKind.timeout || + DavErrorKind.network || + DavErrorKind.unknownOutcome || + DavErrorKind.server => true, _ => false, }; diff --git a/lib/src/dav/nextcloud/nextcloud_collection_controller.dart b/lib/src/dav/nextcloud/nextcloud_collection_controller.dart index 797b46a2..c33dcc95 100644 --- a/lib/src/dav/nextcloud/nextcloud_collection_controller.dart +++ b/lib/src/dav/nextcloud/nextcloud_collection_controller.dart @@ -33,9 +33,8 @@ final class NextcloudCollectionController extends ChangeNotifier { values.clear(); for (final name in nextcloudWritableProperties) { values[name] = name.localName == 'schedule-calendar-transp' - ? (nextcloudPropertyNames( - loaded.collection.properties[name], - ).contains('{$caldavNamespace}transparent') + ? (nextcloudPropertyNames(loaded.collection.properties[name]) + .contains('{$caldavNamespace}transparent') ? 'transparent' : 'opaque') : loaded.collection.properties[name]?.text.trim() ?? ''; diff --git a/lib/src/dav/nextcloud/nextcloud_collection_service.dart b/lib/src/dav/nextcloud/nextcloud_collection_service.dart index 5a619efa..58b7f736 100644 --- a/lib/src/dav/nextcloud/nextcloud_collection_service.dart +++ b/lib/src/dav/nextcloud/nextcloud_collection_service.dart @@ -352,11 +352,11 @@ bool _matchesProperty( DavPropertyName name, String value, ) => name.localName == 'schedule-calendar-transp' - ? nextcloudPropertyNames( - state.properties[name], - ).contains('{$caldavNamespace}$value') + ? nextcloudPropertyNames(state.properties[name]) + .contains('{$caldavNamespace}$value') : state.properties[name]?.text.trim() == value.trim(); bool _uncertain(DavException error) => const { + DavErrorKind.unknownOutcome, DavErrorKind.timeout, DavErrorKind.network, DavErrorKind.server, diff --git a/lib/src/dav/nextcloud/nextcloud_native_import.dart b/lib/src/dav/nextcloud/nextcloud_native_import.dart index 1871011f..136446ea 100644 --- a/lib/src/dav/nextcloud/nextcloud_native_import.dart +++ b/lib/src/dav/nextcloud/nextcloud_native_import.dart @@ -93,8 +93,7 @@ final class NextcloudNativeImportPreview { throw const DavException( kind: DavErrorKind.invalidCalendarData, code: 'IcalInvalidRecurrenceSet', - safeMessage: - 'A recurrence set must have one master and distinct exceptions.', + safeMessage: 'A recurrence set must have one master and distinct exceptions.', ); } resources.add(NativeImportResource(uid, type, raw)); @@ -221,9 +220,9 @@ final class NextcloudNativeImportService { )) .get(); if (!known.any( - (r) => IcalSemanticDocument.parse( - r.rawIcsBody, - ).components.any((c) => c.componentType == 'VTODO'), + (r) => + IcalSemanticDocument.parse(r.rawIcsBody).components + .any((c) => c.componentType == 'VTODO'), )) { return NativeImportItemResult( resource.uid, diff --git a/lib/src/dav/nextcloud/nextcloud_scheduling_policy.dart b/lib/src/dav/nextcloud/nextcloud_scheduling_policy.dart index 24be5d39..e9cd9aec 100644 --- a/lib/src/dav/nextcloud/nextcloud_scheduling_policy.dart +++ b/lib/src/dav/nextcloud/nextcloud_scheduling_policy.dart @@ -327,8 +327,7 @@ String normalizeCalendarAddress(String address) { DavException schedulingDenied(String code) => DavException( kind: DavErrorKind.authorization, code: code, - safeMessage: - 'The selected calendar or scheduling identity does not permit this meeting operation.', + safeMessage: 'The selected calendar or scheduling identity does not permit this meeting operation.', ); Map _map(String? source) { try { diff --git a/lib/src/dav/nextcloud/nextcloud_scheduling_service.dart b/lib/src/dav/nextcloud/nextcloud_scheduling_service.dart index 68d69947..e72aa1d5 100644 --- a/lib/src/dav/nextcloud/nextcloud_scheduling_service.dart +++ b/lib/src/dav/nextcloud/nextcloud_scheduling_service.dart @@ -388,9 +388,9 @@ final class NextcloudSchedulingService { String collectionId, NextcloudInboxMessage message, ) async { - final current = (await inbox( - collectionId, - )).where((m) => m.href == message.href).firstOrNull; + final current = (await inbox(collectionId)) + .where((m) => m.href == message.href) + .firstOrNull; if (current == null) return; if (current.etag != message.etag) { throw nextcloudOperationError(412, 'DavInboxMessageChanged'); diff --git a/lib/src/dav/nextcloud/nextcloud_sharing_service.dart b/lib/src/dav/nextcloud/nextcloud_sharing_service.dart index 41bdbbb1..da1b0ab2 100644 --- a/lib/src/dav/nextcloud/nextcloud_sharing_service.dart +++ b/lib/src/dav/nextcloud/nextcloud_sharing_service.dart @@ -287,6 +287,7 @@ final class NextcloudSharingService { } } on DavException catch (error) { if (!{ + DavErrorKind.unknownOutcome, DavErrorKind.network, DavErrorKind.timeout, DavErrorKind.server, @@ -343,6 +344,7 @@ final class NextcloudSharingService { } } on DavException catch (error) { if (!{ + DavErrorKind.unknownOutcome, DavErrorKind.network, DavErrorKind.timeout, DavErrorKind.server, diff --git a/lib/src/dav/nextcloud/nextcloud_trash_service.dart b/lib/src/dav/nextcloud/nextcloud_trash_service.dart index a0bb53d4..b8b5a341 100644 --- a/lib/src/dav/nextcloud/nextcloud_trash_service.dart +++ b/lib/src/dav/nextcloud/nextcloud_trash_service.dart @@ -221,9 +221,8 @@ final class NextcloudTrashService { if ((row.statusCode ?? 200) >= 400 || types == null) { throw nextcloudOperationError(502, 'DavTrashReadIncomplete'); } - if (!nextcloudPropertyNames( - types, - ).contains('{$nextcloudNamespace}deleted-calendar')) { + if (!nextcloudPropertyNames(types) + .contains('{$nextcloudNamespace}deleted-calendar')) { continue; } final href = context.resolve(row.href, home); @@ -325,6 +324,7 @@ final class NextcloudTrashService { } } on DavException catch (error) { if (!{ + DavErrorKind.unknownOutcome, DavErrorKind.timeout, DavErrorKind.network, DavErrorKind.server, diff --git a/lib/src/dav/storage/dav_object_repository.dart b/lib/src/dav/storage/dav_object_repository.dart index 86ee5619..9fe90774 100644 --- a/lib/src/dav/storage/dav_object_repository.dart +++ b/lib/src/dav/storage/dav_object_repository.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import '../../features/maps/data/location_resolution_repository.dart'; import 'package:crypto/crypto.dart'; @@ -1217,22 +1218,21 @@ final class DavObjectRepository { ); } final projections = - IcalEventProjector( - recurrenceExpander: _recurrenceExpander, - ).projectSemantic( - semantic, - rangeStartUtc: commit.projectionRangeStartUtc, - rangeEndUtc: commit.projectionRangeEndUtc, - transport: 'caldav', - ); + IcalEventProjector(recurrenceExpander: _recurrenceExpander) + .projectSemantic( + semantic, + rangeStartUtc: commit.projectionRangeStartUtc, + rangeEndUtc: commit.projectionRangeEndUtc, + transport: 'caldav', + ); final now = commit.completedAtUtc.toUtc().millisecondsSinceEpoch; for (final projected in projections) { final attendees = scheduling == null ? projected.attendeesJson : jsonEncode([ - for (final raw - in (jsonDecode(projected.attendeesJson) as List) - .whereType()) + for (final raw in (jsonDecode( + projected.attendeesJson, + ) as List).whereType()) nextcloudParticipantProjection( Map.from(raw), scheduling, @@ -1603,9 +1603,8 @@ final class DavObjectRepository { final baseline = operation.baselineRawIcs; final patch = operation.mutationPatchJson; if (baseline != null && patch != null) { - return DavMutationPatch.fromJsonString( - patch, - ).applyTo(baseline, nowUtc: nowUtc); + return DavMutationPatch.fromJsonString(patch) + .applyTo(baseline, nowUtc: nowUtc); } } else if (operation.operationType == 'dav.move') { final baseline = operation.baselineRawIcs; @@ -1613,9 +1612,8 @@ final class DavObjectRepository { final patch = operation.mutationPatchJson; return patch == null ? baseline - : DavMutationPatch.fromJsonString( - patch, - ).applyTo(baseline, nowUtc: nowUtc); + : DavMutationPatch.fromJsonString(patch) + .applyTo(baseline, nowUtc: nowUtc); } } } on DavException { diff --git a/lib/src/dav/xml/dav_xml.dart b/lib/src/dav/xml/dav_xml.dart index a99332a3..3c00df92 100644 --- a/lib/src/dav/xml/dav_xml.dart +++ b/lib/src/dav/xml/dav_xml.dart @@ -1,428 +1,17 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:xml/xml.dart'; - -import '../dav_errors.dart'; - -const davNamespace = 'DAV:'; -const caldavNamespace = 'urn:ietf:params:xml:ns:caldav'; -const calendarServerNamespace = 'http://calendarserver.org/ns/'; -const appleIcalNamespace = 'http://apple.com/ns/ical/'; -const owncloudNamespace = 'http://owncloud.org/ns'; -const nextcloudNamespace = 'http://nextcloud.com/ns'; - -final class DavXmlLimits { - const DavXmlLimits({ - this.maximumBytes = 8 * 1024 * 1024, - this.maximumDepth = 64, - this.maximumElements = 100000, - this.maximumTextBytes = 4 * 1024 * 1024, - }); - - final int maximumBytes; - final int maximumDepth; - final int maximumElements; - final int maximumTextBytes; -} - -final class DavPropertyName { - const DavPropertyName(this.namespaceUri, this.localName); - - final String? namespaceUri; - final String localName; - - @override - bool operator ==(Object other) => - other is DavPropertyName && - other.namespaceUri == namespaceUri && - other.localName == localName; - - @override - int get hashCode => Object.hash(namespaceUri, localName); - - @override - String toString() => '{$namespaceUri}$localName'; -} - -final class DavProperty { - const DavProperty({required this.name, required this.element}); - - final DavPropertyName name; - final XmlElement element; - - String get text => element.innerText; - - Iterable get childElements => element.childElements; -} - -final class DavPropstat { - const DavPropstat({ - required this.statusCode, - required this.reasonPhrase, - required this.properties, - required this.errorConditions, - }); - - final int statusCode; - final String reasonPhrase; - final List properties; - final Set errorConditions; - - bool get isSuccessful => statusCode >= 200 && statusCode < 300; - - DavProperty? property(String namespaceUri, String localName) { - for (final property in properties) { - if (property.name == DavPropertyName(namespaceUri, localName)) { - return property; - } - } - return null; - } -} - -final class DavMultistatusResponse { - const DavMultistatusResponse({ - required this.href, - required this.propstats, - required this.statusCode, - required this.reasonPhrase, - required this.errorConditions, - }); - - final String href; - final List propstats; - final int? statusCode; - final String? reasonPhrase; - final Set errorConditions; - - bool get isMissing => - statusCode == 404 || - (statusCode == null && - propstats.isNotEmpty && - propstats.every((propstat) => propstat.statusCode == 404)); - - DavProperty? successfulProperty(String namespaceUri, String localName) { - for (final propstat in propstats.where((entry) => entry.isSuccessful)) { - final property = propstat.property(namespaceUri, localName); - if (property != null) { - return property; - } - } - return null; - } -} - -final class DavMultistatus { - const DavMultistatus({ - required this.responses, - required this.syncToken, - required this.errorConditions, - }); - - final List responses; - final String? syncToken; - final Set errorConditions; - - bool hasCondition(String namespaceUri, String localName) => - errorConditions.contains(DavPropertyName(namespaceUri, localName)) || - responses.any( - (response) => - response.errorConditions.contains( - DavPropertyName(namespaceUri, localName), - ) || - response.propstats.any( - (propstat) => propstat.errorConditions.contains( - DavPropertyName(namespaceUri, localName), - ), - ), - ); -} - -final class DavXmlParser { - const DavXmlParser({this.limits = const DavXmlLimits()}); - - final DavXmlLimits limits; - - /// Scheduling responses use a different root than multistatus, but require - /// the same DTD/entity rejection and bounded document parsing. - XmlDocument parseDocument(Uint8List bytes, {String? correlationId}) => - _parseDocument(bytes, correlationId); - - Set parseDavError(Uint8List bytes, {String? correlationId}) { - final document = _parseDocument(bytes, correlationId); - final root = document.rootElement; - if (!_matches(root, davNamespace, 'error')) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlExpectedError', - 'The DAV server returned an invalid error document.', - correlationId, - ); - } - return Set.unmodifiable({ - for (final child in root.childElements) - DavPropertyName(child.name.namespaceUri, child.name.local), - }); - } - - DavMultistatus parseMultistatus(Uint8List bytes, {String? correlationId}) { - final document = _parseDocument(bytes, correlationId); - final root = document.rootElement; - if (!_matches(root, davNamespace, 'multistatus')) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlExpectedMultistatus', - 'The DAV server response was not a DAV multistatus document.', - correlationId, - ); - } - - final responses = []; - for (final response in _direct(root, davNamespace, 'response')) { - responses.add(_parseResponse(response, correlationId)); - } - final syncToken = _firstDirect( - root, - davNamespace, - 'sync-token', - )?.innerText.trim(); - return DavMultistatus( - responses: List.unmodifiable(responses), - syncToken: syncToken == null || syncToken.isEmpty ? null : syncToken, - errorConditions: _parseErrorConditions(root), - ); - } - - XmlDocument _parseDocument(Uint8List bytes, String? correlationId) { - if (bytes.length > limits.maximumBytes) { - throw _error( - DavErrorKind.responseTooLarge, - 'DavXmlResponseTooLarge', - 'The DAV XML response exceeded the configured size limit.', - correlationId, - ); - } - final source = _decodeUtf8(bytes, correlationId); - if (RegExp( - r'[]; - for (final propstat in _direct(element, davNamespace, 'propstat')) { - propstats.add(_parsePropstat(propstat, correlationId)); - } - final statusText = _firstDirect(element, davNamespace, 'status')?.innerText; - final status = statusText == null - ? null - : _parseStatus(statusText, correlationId); - if (status == null && propstats.isEmpty) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlResponseMissingStatus', - 'A DAV multistatus response contained no status information.', - correlationId, - ); - } - return DavMultistatusResponse( - href: href, - propstats: List.unmodifiable(propstats), - statusCode: status?.code, - reasonPhrase: status?.reason, - errorConditions: _parseErrorConditions(element), - ); - } - - DavPropstat _parsePropstat(XmlElement element, String? correlationId) { - final statusText = _firstDirect(element, davNamespace, 'status')?.innerText; - if (statusText == null) { - throw _error( - DavErrorKind.malformedStatus, - 'DavXmlPropstatMissingStatus', - 'A DAV property status did not contain an HTTP status line.', - correlationId, - ); - } - final status = _parseStatus(statusText, correlationId); - final prop = _firstDirect(element, davNamespace, 'prop'); - if (prop == null) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlPropstatMissingProp', - 'A DAV property status did not contain a property container.', - correlationId, - ); - } - final properties = [ - for (final child in prop.childElements) - DavProperty( - name: DavPropertyName(child.name.namespaceUri, child.name.local), - element: child, - ), - ]; - return DavPropstat( - statusCode: status.code, - reasonPhrase: status.reason, - properties: List.unmodifiable(properties), - errorConditions: _parseErrorConditions(element), - ); - } - - ({int code, String reason}) _parseStatus( - String source, - String? correlationId, - ) { - final match = RegExp( - r'^HTTP/1[.][01] ([1-5][0-9]{2})(?: ([^\r\n]*))?$', - ).firstMatch(source.trim()); - if (match == null) { - throw _error( - DavErrorKind.malformedStatus, - 'DavMalformedHttpStatusLine', - 'The DAV server returned a malformed HTTP status line.', - correlationId, - ); - } - return ( - code: int.parse(match.group(1)!), - reason: match.group(2)?.trim() ?? '', - ); - } - - Set _parseErrorConditions(XmlElement container) { - final result = {}; - for (final error in _direct(container, davNamespace, 'error')) { - for (final child in error.childElements) { - result.add(DavPropertyName(child.name.namespaceUri, child.name.local)); - } - } - return Set.unmodifiable(result); - } - - void _enforceTreeLimits(XmlDocument document, String? correlationId) { - var elementCount = 0; - var textBytes = 0; - void visit(XmlNode node, int depth) { - if (depth > limits.maximumDepth) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlDepthLimitExceeded', - 'The DAV XML response exceeded the nesting limit.', - correlationId, - ); - } - if (node is XmlElement) { - elementCount += 1; - if (elementCount > limits.maximumElements) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlElementLimitExceeded', - 'The DAV XML response contained too many elements.', - correlationId, - ); - } - } else if (node is XmlText) { - textBytes += utf8.encode(node.value).length; - if (textBytes > limits.maximumTextBytes) { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlTextLimitExceeded', - 'The DAV XML response contained too much text.', - correlationId, - ); - } - } - for (final child in node.children) { - visit(child, depth + 1); - } - } - - visit(document, 0); - } - - String _decodeUtf8(Uint8List bytes, String? correlationId) { - try { - return utf8.decode(bytes, allowMalformed: false); - } on FormatException { - throw _error( - DavErrorKind.malformedXml, - 'DavXmlInvalidUtf8', - 'The DAV server returned XML that is not valid UTF-8.', - correlationId, - ); - } - } - - DavException _error( - DavErrorKind kind, - String code, - String safeMessage, - String? correlationId, - ) => DavException( - kind: kind, - code: code, - safeMessage: safeMessage, - correlationId: correlationId, - ); -} - -Iterable _direct( - XmlElement parent, - String namespaceUri, - String localName, -) => parent.childElements.where( - (element) => _matches(element, namespaceUri, localName), -); - -XmlElement? _firstDirect( - XmlElement parent, - String namespaceUri, - String localName, -) { - for (final element in parent.childElements) { - if (_matches(element, namespaceUri, localName)) { - return element; - } - } - return null; -} - -bool _matches(XmlElement element, String namespaceUri, String localName) => - element.name.namespaceUri == namespaceUri && - element.name.local == localName; +// The shared parser owns bounded XML decoding, DTD/entity rejection, DAV +// status parsing, and namespace-aware multistatus/property mechanics. +export 'package:busystack_dav/busystack_dav.dart' + show + davNamespace, + caldavNamespace, + calendarServerNamespace, + appleIcalNamespace, + owncloudNamespace, + nextcloudNamespace, + DavXmlLimits, + DavPropertyName, + DavProperty, + DavPropstat, + DavMultistatusResponse, + DavMultistatus, + DavXmlParser; diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index 5d8e4bae..9113c640 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -28632,54 +28632,52 @@ abstract class _$AppDatabase extends GeneratedDatabase { ]); } -typedef $$AccountsTableCreateCompanionBuilder = - AccountsCompanion Function({ - required String id, - required String provider, - required String authority, - required String providerAccountId, - required String credentialKind, - Value providerProfileVersion, - Value displayName, - Value email, - Value tenantId, - Value accountAvatarUrl, - Value providerMetadataJson, - Value authState, - Value calendarsEnabled, - Value tasksEnabled, - Value grantedScopes, - required String createdAtUtc, - required String updatedAtUtc, - Value lastSuccessfulSyncAtUtc, - Value lastFullSyncAtUtc, - Value taskImportIncomplete, - Value rowid, - }); -typedef $$AccountsTableUpdateCompanionBuilder = - AccountsCompanion Function({ - Value id, - Value provider, - Value authority, - Value providerAccountId, - Value credentialKind, - Value providerProfileVersion, - Value displayName, - Value email, - Value tenantId, - Value accountAvatarUrl, - Value providerMetadataJson, - Value authState, - Value calendarsEnabled, - Value tasksEnabled, - Value grantedScopes, - Value createdAtUtc, - Value updatedAtUtc, - Value lastSuccessfulSyncAtUtc, - Value lastFullSyncAtUtc, - Value taskImportIncomplete, - Value rowid, - }); +typedef $$AccountsTableCreateCompanionBuilder = AccountsCompanion Function({ + required String id, + required String provider, + required String authority, + required String providerAccountId, + required String credentialKind, + Value providerProfileVersion, + Value displayName, + Value email, + Value tenantId, + Value accountAvatarUrl, + Value providerMetadataJson, + Value authState, + Value calendarsEnabled, + Value tasksEnabled, + Value grantedScopes, + required String createdAtUtc, + required String updatedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastFullSyncAtUtc, + Value taskImportIncomplete, + Value rowid, +}); +typedef $$AccountsTableUpdateCompanionBuilder = AccountsCompanion Function({ + Value id, + Value provider, + Value authority, + Value providerAccountId, + Value credentialKind, + Value providerProfileVersion, + Value displayName, + Value email, + Value tenantId, + Value accountAvatarUrl, + Value providerMetadataJson, + Value authState, + Value calendarsEnabled, + Value tasksEnabled, + Value grantedScopes, + Value createdAtUtc, + Value updatedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastFullSyncAtUtc, + Value taskImportIncomplete, + Value rowid, +}); final class $$AccountsTableReferences extends BaseReferences<_$AppDatabase, $AccountsTable, Account> { @@ -31137,19 +31135,17 @@ class $$OAuthTransitionAccountsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$OAuthTransitionAccountsTableReferences - ._accountIdTable(db), - referencedColumn: - $$OAuthTransitionAccountsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$OAuthTransitionAccountsTableReferences + ._accountIdTable(db), + referencedColumn: + $$OAuthTransitionAccountsTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -31445,19 +31441,15 @@ class $$AccountAuthorizationsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$AccountAuthorizationsTableReferences - ._accountIdTable(db), - referencedColumn: - $$AccountAuthorizationsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$AccountAuthorizationsTableReferences + ._accountIdTable(db), + referencedColumn: $$AccountAuthorizationsTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -32200,19 +32192,15 @@ class $$DomainSyncSchedulesTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$DomainSyncSchedulesTableReferences - ._accountIdTable(db), - referencedColumn: - $$DomainSyncSchedulesTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$DomainSyncSchedulesTableReferences + ._accountIdTable(db), + referencedColumn: $$DomainSyncSchedulesTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -32732,19 +32720,15 @@ class $$DavAccountServicesTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$DavAccountServicesTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavAccountServicesTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$DavAccountServicesTableReferences + ._accountIdTable(db), + referencedColumn: $$DavAccountServicesTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -34193,19 +34177,15 @@ class $$DavCollectionsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$DavCollectionsTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavCollectionsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$DavCollectionsTableReferences + ._accountIdTable(db), + referencedColumn: $$DavCollectionsTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -34390,56 +34370,54 @@ typedef $$DavCollectionsTableProcessedTableManager = bool syncCursorsRefs, }) >; -typedef $$DavObjectsTableCreateCompanionBuilder = - DavObjectsCompanion Function({ - required String id, - required String accountId, - required String collectionId, - required String hrefKey, - required String requestUri, - Value etag, - Value contentType, - Value dominantComponentType, - Value componentMask, - Value primaryUid, - required String rawIcsBody, - required String rawBodyHash, - Value semanticHash, - Value serverDeleted, - Value baselineGeneration, - required String firstSeenAtUtc, - required String lastFetchedAtUtc, - required String lastChangedAtUtc, - Value lastParseStatus, - Value lastParseErrorCode, - Value parserVersion, - Value rowid, - }); -typedef $$DavObjectsTableUpdateCompanionBuilder = - DavObjectsCompanion Function({ - Value id, - Value accountId, - Value collectionId, - Value hrefKey, - Value requestUri, - Value etag, - Value contentType, - Value dominantComponentType, - Value componentMask, - Value primaryUid, - Value rawIcsBody, - Value rawBodyHash, - Value semanticHash, - Value serverDeleted, - Value baselineGeneration, - Value firstSeenAtUtc, - Value lastFetchedAtUtc, - Value lastChangedAtUtc, - Value lastParseStatus, - Value lastParseErrorCode, - Value parserVersion, - Value rowid, - }); +typedef $$DavObjectsTableCreateCompanionBuilder = DavObjectsCompanion Function({ + required String id, + required String accountId, + required String collectionId, + required String hrefKey, + required String requestUri, + Value etag, + Value contentType, + Value dominantComponentType, + Value componentMask, + Value primaryUid, + required String rawIcsBody, + required String rawBodyHash, + Value semanticHash, + Value serverDeleted, + Value baselineGeneration, + required String firstSeenAtUtc, + required String lastFetchedAtUtc, + required String lastChangedAtUtc, + Value lastParseStatus, + Value lastParseErrorCode, + Value parserVersion, + Value rowid, +}); +typedef $$DavObjectsTableUpdateCompanionBuilder = DavObjectsCompanion Function({ + Value id, + Value accountId, + Value collectionId, + Value hrefKey, + Value requestUri, + Value etag, + Value contentType, + Value dominantComponentType, + Value componentMask, + Value primaryUid, + Value rawIcsBody, + Value rawBodyHash, + Value semanticHash, + Value serverDeleted, + Value baselineGeneration, + Value firstSeenAtUtc, + Value lastFetchedAtUtc, + Value lastChangedAtUtc, + Value lastParseStatus, + Value lastParseErrorCode, + Value parserVersion, + Value rowid, +}); final class $$DavObjectsTableReferences extends BaseReferences<_$AppDatabase, $DavObjectsTable, DavObject> { @@ -35458,32 +35436,26 @@ class $$DavObjectsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$DavObjectsTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavObjectsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$DavObjectsTableReferences + ._accountIdTable(db), + referencedColumn: $$DavObjectsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (collectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.collectionId, - referencedTable: $$DavObjectsTableReferences - ._collectionIdTable(db), - referencedColumn: - $$DavObjectsTableReferences - ._collectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.collectionId, + referencedTable: $$DavObjectsTableReferences + ._collectionIdTable(db), + referencedColumn: $$DavObjectsTableReferences + ._collectionIdTable(db) + .id, + ) as T; } return state; @@ -36179,19 +36151,17 @@ class $$DavObjectComponentsTableTableManager > >(state) { if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$DavObjectComponentsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$DavObjectComponentsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: + $$DavObjectComponentsTableReferences + ._davObjectIdTable(db), + referencedColumn: + $$DavObjectComponentsTableReferences + ._davObjectIdTable(db) + .id, + ) as T; } return state; @@ -36960,49 +36930,43 @@ class $$DavConflictSnapshotsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$DavConflictSnapshotsTableReferences - ._accountIdTable(db), - referencedColumn: - $$DavConflictSnapshotsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$DavConflictSnapshotsTableReferences + ._accountIdTable(db), + referencedColumn: + $$DavConflictSnapshotsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$DavConflictSnapshotsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$DavConflictSnapshotsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$DavConflictSnapshotsTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$DavConflictSnapshotsTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$DavConflictSnapshotsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$DavConflictSnapshotsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: + $$DavConflictSnapshotsTableReferences + ._davObjectIdTable(db), + referencedColumn: + $$DavConflictSnapshotsTableReferences + ._davObjectIdTable(db) + .id, + ) as T; } return state; @@ -37057,56 +37021,54 @@ typedef $$DavConflictSnapshotsTableProcessedTableManager = bool pendingOpsRefs, }) >; -typedef $$TaskListsTableCreateCompanionBuilder = - TaskListsCompanion Function({ - required String accountId, - required String id, - Value davCollectionId, - Value kind, - Value etag, - required String title, - Value updatedUtc, - Value selfLink, - required String rawJson, - Value providerListKind, - Value isOwner, - Value isShared, - Value deltaLink, - Value providerMetadataJson, - Value serverMissing, - Value localDirty, - Value pendingDelete, - Value remindersEnabled, - Value lastSyncedAtUtc, - required String createdLocalAtUtc, - required String updatedLocalAtUtc, - Value rowid, - }); -typedef $$TaskListsTableUpdateCompanionBuilder = - TaskListsCompanion Function({ - Value accountId, - Value id, - Value davCollectionId, - Value kind, - Value etag, - Value title, - Value updatedUtc, - Value selfLink, - Value rawJson, - Value providerListKind, - Value isOwner, - Value isShared, - Value deltaLink, - Value providerMetadataJson, - Value serverMissing, - Value localDirty, - Value pendingDelete, - Value remindersEnabled, - Value lastSyncedAtUtc, - Value createdLocalAtUtc, - Value updatedLocalAtUtc, - Value rowid, - }); +typedef $$TaskListsTableCreateCompanionBuilder = TaskListsCompanion Function({ + required String accountId, + required String id, + Value davCollectionId, + Value kind, + Value etag, + required String title, + Value updatedUtc, + Value selfLink, + required String rawJson, + Value providerListKind, + Value isOwner, + Value isShared, + Value deltaLink, + Value providerMetadataJson, + Value serverMissing, + Value localDirty, + Value pendingDelete, + Value remindersEnabled, + Value lastSyncedAtUtc, + required String createdLocalAtUtc, + required String updatedLocalAtUtc, + Value rowid, +}); +typedef $$TaskListsTableUpdateCompanionBuilder = TaskListsCompanion Function({ + Value accountId, + Value id, + Value davCollectionId, + Value kind, + Value etag, + Value title, + Value updatedUtc, + Value selfLink, + Value rawJson, + Value providerListKind, + Value isOwner, + Value isShared, + Value deltaLink, + Value providerMetadataJson, + Value serverMissing, + Value localDirty, + Value pendingDelete, + Value remindersEnabled, + Value lastSyncedAtUtc, + Value createdLocalAtUtc, + Value updatedLocalAtUtc, + Value rowid, +}); final class $$TaskListsTableReferences extends BaseReferences<_$AppDatabase, $TaskListsTable, TaskList> { @@ -37735,30 +37697,26 @@ class $$TaskListsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$TaskListsTableReferences - ._accountIdTable(db), - referencedColumn: $$TaskListsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$TaskListsTableReferences + ._accountIdTable(db), + referencedColumn: $$TaskListsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: $$TaskListsTableReferences - ._davCollectionIdTable(db), - referencedColumn: $$TaskListsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$TaskListsTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$TaskListsTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } return state; @@ -37786,148 +37744,146 @@ typedef $$TaskListsTableProcessedTableManager = TaskList, PrefetchHooks Function({bool accountId, bool davCollectionId}) >; -typedef $$TasksTableCreateCompanionBuilder = - TasksCompanion Function({ - required String accountId, - required String taskListId, - required String id, - Value davCollectionId, - Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value icalPriority, - Value percentComplete, - Value taskLocation, - Value locationLatitude, - Value locationLongitude, - Value taskUrl, - Value taskClassification, - Value taskPinned, - Value taskHideSubtasks, - Value taskHideCompletedSubtasks, - Value taskAlarmsJson, - Value parentUid, - Value sortOrder, - Value providerExtensionProjectionJson, - Value projectionVersion, - Value kind, - Value etag, - required String title, - Value updatedUtc, - Value selfLink, - Value parent, - Value position, - Value notes, - Value status, - Value dueUtc, - Value completedUtc, - Value providerStatus, - Value bodyContent, - Value bodyContentType, - Value microsoftDueDateTime, - Value microsoftDueTimeZone, - Value microsoftStartDateTime, - Value microsoftStartTimeZone, - Value microsoftReminderDateTime, - Value microsoftReminderTimeZone, - Value microsoftIsReminderOn, - Value microsoftCompletedDateTime, - Value microsoftCompletedTimeZone, - Value microsoftChecklistItemsJson, - Value recurrenceJson, - Value importance, - Value categoriesJson, - Value hasAttachments, - Value providerMetadataJson, - Value deleted, - Value hidden, - Value linksJson, - Value webViewLink, - Value assignmentInfoJson, - required String rawJson, - Value serverMissing, - Value localDirty, - Value pendingDelete, - Value pendingMove, - Value localCreated, - Value syncBaseUpdatedUtc, - Value lastSyncedAtUtc, - required String createdLocalAtUtc, - required String updatedLocalAtUtc, - Value rowid, - }); -typedef $$TasksTableUpdateCompanionBuilder = - TasksCompanion Function({ - Value accountId, - Value taskListId, - Value id, - Value davCollectionId, - Value davObjectId, - Value davComponentId, - Value icalUid, - Value recurrenceIdKey, - Value icalPriority, - Value percentComplete, - Value taskLocation, - Value locationLatitude, - Value locationLongitude, - Value taskUrl, - Value taskClassification, - Value taskPinned, - Value taskHideSubtasks, - Value taskHideCompletedSubtasks, - Value taskAlarmsJson, - Value parentUid, - Value sortOrder, - Value providerExtensionProjectionJson, - Value projectionVersion, - Value kind, - Value etag, - Value title, - Value updatedUtc, - Value selfLink, - Value parent, - Value position, - Value notes, - Value status, - Value dueUtc, - Value completedUtc, - Value providerStatus, - Value bodyContent, - Value bodyContentType, - Value microsoftDueDateTime, - Value microsoftDueTimeZone, - Value microsoftStartDateTime, - Value microsoftStartTimeZone, - Value microsoftReminderDateTime, - Value microsoftReminderTimeZone, - Value microsoftIsReminderOn, - Value microsoftCompletedDateTime, - Value microsoftCompletedTimeZone, - Value microsoftChecklistItemsJson, - Value recurrenceJson, - Value importance, - Value categoriesJson, - Value hasAttachments, - Value providerMetadataJson, - Value deleted, - Value hidden, - Value linksJson, - Value webViewLink, - Value assignmentInfoJson, - Value rawJson, - Value serverMissing, - Value localDirty, - Value pendingDelete, - Value pendingMove, - Value localCreated, - Value syncBaseUpdatedUtc, - Value lastSyncedAtUtc, - Value createdLocalAtUtc, - Value updatedLocalAtUtc, - Value rowid, - }); +typedef $$TasksTableCreateCompanionBuilder = TasksCompanion Function({ + required String accountId, + required String taskListId, + required String id, + Value davCollectionId, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value icalPriority, + Value percentComplete, + Value taskLocation, + Value locationLatitude, + Value locationLongitude, + Value taskUrl, + Value taskClassification, + Value taskPinned, + Value taskHideSubtasks, + Value taskHideCompletedSubtasks, + Value taskAlarmsJson, + Value parentUid, + Value sortOrder, + Value providerExtensionProjectionJson, + Value projectionVersion, + Value kind, + Value etag, + required String title, + Value updatedUtc, + Value selfLink, + Value parent, + Value position, + Value notes, + Value status, + Value dueUtc, + Value completedUtc, + Value providerStatus, + Value bodyContent, + Value bodyContentType, + Value microsoftDueDateTime, + Value microsoftDueTimeZone, + Value microsoftStartDateTime, + Value microsoftStartTimeZone, + Value microsoftReminderDateTime, + Value microsoftReminderTimeZone, + Value microsoftIsReminderOn, + Value microsoftCompletedDateTime, + Value microsoftCompletedTimeZone, + Value microsoftChecklistItemsJson, + Value recurrenceJson, + Value importance, + Value categoriesJson, + Value hasAttachments, + Value providerMetadataJson, + Value deleted, + Value hidden, + Value linksJson, + Value webViewLink, + Value assignmentInfoJson, + required String rawJson, + Value serverMissing, + Value localDirty, + Value pendingDelete, + Value pendingMove, + Value localCreated, + Value syncBaseUpdatedUtc, + Value lastSyncedAtUtc, + required String createdLocalAtUtc, + required String updatedLocalAtUtc, + Value rowid, +}); +typedef $$TasksTableUpdateCompanionBuilder = TasksCompanion Function({ + Value accountId, + Value taskListId, + Value id, + Value davCollectionId, + Value davObjectId, + Value davComponentId, + Value icalUid, + Value recurrenceIdKey, + Value icalPriority, + Value percentComplete, + Value taskLocation, + Value locationLatitude, + Value locationLongitude, + Value taskUrl, + Value taskClassification, + Value taskPinned, + Value taskHideSubtasks, + Value taskHideCompletedSubtasks, + Value taskAlarmsJson, + Value parentUid, + Value sortOrder, + Value providerExtensionProjectionJson, + Value projectionVersion, + Value kind, + Value etag, + Value title, + Value updatedUtc, + Value selfLink, + Value parent, + Value position, + Value notes, + Value status, + Value dueUtc, + Value completedUtc, + Value providerStatus, + Value bodyContent, + Value bodyContentType, + Value microsoftDueDateTime, + Value microsoftDueTimeZone, + Value microsoftStartDateTime, + Value microsoftStartTimeZone, + Value microsoftReminderDateTime, + Value microsoftReminderTimeZone, + Value microsoftIsReminderOn, + Value microsoftCompletedDateTime, + Value microsoftCompletedTimeZone, + Value microsoftChecklistItemsJson, + Value recurrenceJson, + Value importance, + Value categoriesJson, + Value hasAttachments, + Value providerMetadataJson, + Value deleted, + Value hidden, + Value linksJson, + Value webViewLink, + Value assignmentInfoJson, + Value rawJson, + Value serverMissing, + Value localDirty, + Value pendingDelete, + Value pendingMove, + Value localCreated, + Value syncBaseUpdatedUtc, + Value lastSyncedAtUtc, + Value createdLocalAtUtc, + Value updatedLocalAtUtc, + Value rowid, +}); final class $$TasksTableReferences extends BaseReferences<_$AppDatabase, $TasksTable, Task> { @@ -39579,56 +39535,48 @@ class $$TasksTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$TasksTableReferences - ._accountIdTable(db), - referencedColumn: $$TasksTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$TasksTableReferences + ._accountIdTable(db), + referencedColumn: $$TasksTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: $$TasksTableReferences - ._davCollectionIdTable(db), - referencedColumn: $$TasksTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$TasksTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$TasksTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: $$TasksTableReferences - ._davObjectIdTable(db), - referencedColumn: $$TasksTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: $$TasksTableReferences + ._davObjectIdTable(db), + referencedColumn: $$TasksTableReferences + ._davObjectIdTable(db) + .id, + ) as T; } if (davComponentId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davComponentId, - referencedTable: $$TasksTableReferences - ._davComponentIdTable(db), - referencedColumn: $$TasksTableReferences - ._davComponentIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davComponentId, + referencedTable: $$TasksTableReferences + ._davComponentIdTable(db), + referencedColumn: $$TasksTableReferences + ._davComponentIdTable(db) + .id, + ) as T; } return state; @@ -39661,94 +39609,92 @@ typedef $$TasksTableProcessedTableManager = bool davComponentId, }) >; -typedef $$PendingOpsTableCreateCompanionBuilder = - PendingOpsCompanion Function({ - required String id, - required String accountId, - Value provider, - required String entityType, - required String operation, - Value operationType, - Value taskListId, - Value taskId, - Value calendarSourceId, - Value providerCalendarId, - Value eventId, - Value davCollectionId, - Value davCollectionHref, - Value davObjectId, - Value davMemberHref, - Value baselineEtag, - Value baselineRawIcs, - Value mutationPatchJson, - Value mutationPatchSchemaVersion, - Value targetComponentKey, - Value mutationScope, - Value destinationCollectionId, - Value destinationCollectionHref, - Value destinationMemberHref, - Value conflictState, - Value conflictSnapshotId, - Value retryClassification, - Value localTempId, - Value dependsOnOpId, - required String requestJson, - Value baselineUpdatedUtc, - Value baselineRawJson, - Value attemptCount, - Value nextAttemptAtUtc, - Value lastErrorCode, - Value lastErrorMessage, - Value state, - Value lastError, - required String createdAtUtc, - required String updatedAtUtc, - Value rowid, - }); -typedef $$PendingOpsTableUpdateCompanionBuilder = - PendingOpsCompanion Function({ - Value id, - Value accountId, - Value provider, - Value entityType, - Value operation, - Value operationType, - Value taskListId, - Value taskId, - Value calendarSourceId, - Value providerCalendarId, - Value eventId, - Value davCollectionId, - Value davCollectionHref, - Value davObjectId, - Value davMemberHref, - Value baselineEtag, - Value baselineRawIcs, - Value mutationPatchJson, - Value mutationPatchSchemaVersion, - Value targetComponentKey, - Value mutationScope, - Value destinationCollectionId, - Value destinationCollectionHref, - Value destinationMemberHref, - Value conflictState, - Value conflictSnapshotId, - Value retryClassification, - Value localTempId, - Value dependsOnOpId, - Value requestJson, - Value baselineUpdatedUtc, - Value baselineRawJson, - Value attemptCount, - Value nextAttemptAtUtc, - Value lastErrorCode, - Value lastErrorMessage, - Value state, - Value lastError, - Value createdAtUtc, - Value updatedAtUtc, - Value rowid, - }); +typedef $$PendingOpsTableCreateCompanionBuilder = PendingOpsCompanion Function({ + required String id, + required String accountId, + Value provider, + required String entityType, + required String operation, + Value operationType, + Value taskListId, + Value taskId, + Value calendarSourceId, + Value providerCalendarId, + Value eventId, + Value davCollectionId, + Value davCollectionHref, + Value davObjectId, + Value davMemberHref, + Value baselineEtag, + Value baselineRawIcs, + Value mutationPatchJson, + Value mutationPatchSchemaVersion, + Value targetComponentKey, + Value mutationScope, + Value destinationCollectionId, + Value destinationCollectionHref, + Value destinationMemberHref, + Value conflictState, + Value conflictSnapshotId, + Value retryClassification, + Value localTempId, + Value dependsOnOpId, + required String requestJson, + Value baselineUpdatedUtc, + Value baselineRawJson, + Value attemptCount, + Value nextAttemptAtUtc, + Value lastErrorCode, + Value lastErrorMessage, + Value state, + Value lastError, + required String createdAtUtc, + required String updatedAtUtc, + Value rowid, +}); +typedef $$PendingOpsTableUpdateCompanionBuilder = PendingOpsCompanion Function({ + Value id, + Value accountId, + Value provider, + Value entityType, + Value operation, + Value operationType, + Value taskListId, + Value taskId, + Value calendarSourceId, + Value providerCalendarId, + Value eventId, + Value davCollectionId, + Value davCollectionHref, + Value davObjectId, + Value davMemberHref, + Value baselineEtag, + Value baselineRawIcs, + Value mutationPatchJson, + Value mutationPatchSchemaVersion, + Value targetComponentKey, + Value mutationScope, + Value destinationCollectionId, + Value destinationCollectionHref, + Value destinationMemberHref, + Value conflictState, + Value conflictSnapshotId, + Value retryClassification, + Value localTempId, + Value dependsOnOpId, + Value requestJson, + Value baselineUpdatedUtc, + Value baselineRawJson, + Value attemptCount, + Value nextAttemptAtUtc, + Value lastErrorCode, + Value lastErrorMessage, + Value state, + Value lastError, + Value createdAtUtc, + Value updatedAtUtc, + Value rowid, +}); final class $$PendingOpsTableReferences extends BaseReferences<_$AppDatabase, $PendingOpsTable, PendingOp> { @@ -40976,75 +40922,59 @@ class $$PendingOpsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$PendingOpsTableReferences - ._accountIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$PendingOpsTableReferences + ._accountIdTable(db), + referencedColumn: $$PendingOpsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: $$PendingOpsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$PendingOpsTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$PendingOpsTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: $$PendingOpsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: $$PendingOpsTableReferences + ._davObjectIdTable(db), + referencedColumn: $$PendingOpsTableReferences + ._davObjectIdTable(db) + .id, + ) as T; } if (destinationCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: - table.destinationCollectionId, - referencedTable: $$PendingOpsTableReferences - ._destinationCollectionIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._destinationCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.destinationCollectionId, + referencedTable: $$PendingOpsTableReferences + ._destinationCollectionIdTable(db), + referencedColumn: $$PendingOpsTableReferences + ._destinationCollectionIdTable(db) + .id, + ) as T; } if (conflictSnapshotId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.conflictSnapshotId, - referencedTable: $$PendingOpsTableReferences - ._conflictSnapshotIdTable(db), - referencedColumn: - $$PendingOpsTableReferences - ._conflictSnapshotIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.conflictSnapshotId, + referencedTable: $$PendingOpsTableReferences + ._conflictSnapshotIdTable(db), + referencedColumn: $$PendingOpsTableReferences + ._conflictSnapshotIdTable(db) + .id, + ) as T; } return state; @@ -41078,38 +41008,36 @@ typedef $$PendingOpsTableProcessedTableManager = bool conflictSnapshotId, }) >; -typedef $$SyncRunsTableCreateCompanionBuilder = - SyncRunsCompanion Function({ - required String id, - required String accountId, - Value provider, - required String mode, - required String startedAtUtc, - Value finishedAtUtc, - required String status, - Value taskListsSeen, - Value tasksSeen, - Value pendingOpsApplied, - Value errorCode, - Value errorMessage, - Value rowid, - }); -typedef $$SyncRunsTableUpdateCompanionBuilder = - SyncRunsCompanion Function({ - Value id, - Value accountId, - Value provider, - Value mode, - Value startedAtUtc, - Value finishedAtUtc, - Value status, - Value taskListsSeen, - Value tasksSeen, - Value pendingOpsApplied, - Value errorCode, - Value errorMessage, - Value rowid, - }); +typedef $$SyncRunsTableCreateCompanionBuilder = SyncRunsCompanion Function({ + required String id, + required String accountId, + Value provider, + required String mode, + required String startedAtUtc, + Value finishedAtUtc, + required String status, + Value taskListsSeen, + Value tasksSeen, + Value pendingOpsApplied, + Value errorCode, + Value errorMessage, + Value rowid, +}); +typedef $$SyncRunsTableUpdateCompanionBuilder = SyncRunsCompanion Function({ + Value id, + Value accountId, + Value provider, + Value mode, + Value startedAtUtc, + Value finishedAtUtc, + Value status, + Value taskListsSeen, + Value tasksSeen, + Value pendingOpsApplied, + Value errorCode, + Value errorMessage, + Value rowid, +}); final class $$SyncRunsTableReferences extends BaseReferences<_$AppDatabase, $SyncRunsTable, SyncRun> { @@ -41500,17 +41428,15 @@ class $$SyncRunsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: $$SyncRunsTableReferences - ._accountIdTable(db), - referencedColumn: $$SyncRunsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$SyncRunsTableReferences + ._accountIdTable(db), + referencedColumn: $$SyncRunsTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -42571,34 +42497,26 @@ class $$CalendarSourcesTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$CalendarSourcesTableReferences - ._accountIdTable(db), - referencedColumn: - $$CalendarSourcesTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$CalendarSourcesTableReferences + ._accountIdTable(db), + referencedColumn: $$CalendarSourcesTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$CalendarSourcesTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$CalendarSourcesTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$CalendarSourcesTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$CalendarSourcesTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } return state; @@ -44440,79 +44358,59 @@ class $$CalendarEventsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$CalendarEventsTableReferences - ._accountIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$CalendarEventsTableReferences + ._accountIdTable(db), + referencedColumn: $$CalendarEventsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (calendarSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarSourceId, - referencedTable: - $$CalendarEventsTableReferences - ._calendarSourceIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._calendarSourceIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: $$CalendarEventsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: $$CalendarEventsTableReferences + ._calendarSourceIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$CalendarEventsTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$CalendarEventsTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$CalendarEventsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: $$CalendarEventsTableReferences + ._davObjectIdTable(db), + referencedColumn: $$CalendarEventsTableReferences + ._davObjectIdTable(db) + .id, + ) as T; } if (davComponentId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davComponentId, - referencedTable: - $$CalendarEventsTableReferences - ._davComponentIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davComponentIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davComponentId, + referencedTable: $$CalendarEventsTableReferences + ._davComponentIdTable(db), + referencedColumn: $$CalendarEventsTableReferences + ._davComponentIdTable(db) + .id, + ) as T; } return state; @@ -44871,19 +44769,15 @@ class $$IcalImportReceiptsTableTableManager > >(state) { if (calendarSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarSourceId, - referencedTable: - $$IcalImportReceiptsTableReferences - ._calendarSourceIdTable(db), - referencedColumn: - $$IcalImportReceiptsTableReferences - ._calendarSourceIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db) + .id, + ) as T; } return state; @@ -45772,34 +45666,30 @@ class $$WebCalSubscriptionsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$WebCalSubscriptionsTableReferences - ._accountIdTable(db), - referencedColumn: - $$WebCalSubscriptionsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$WebCalSubscriptionsTableReferences + ._accountIdTable(db), + referencedColumn: + $$WebCalSubscriptionsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (calendarSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarSourceId, - referencedTable: - $$WebCalSubscriptionsTableReferences - ._calendarSourceIdTable(db), - referencedColumn: - $$WebCalSubscriptionsTableReferences - ._calendarSourceIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$WebCalSubscriptionsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$WebCalSubscriptionsTableReferences + ._calendarSourceIdTable(db) + .id, + ) as T; } return state; @@ -46211,19 +46101,16 @@ class $$CalendarEventAttendeesTableTableManager > >(state) { if (calendarEventId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarEventId, - referencedTable: - $$CalendarEventAttendeesTableReferences - ._calendarEventIdTable(db), - referencedColumn: - $$CalendarEventAttendeesTableReferences - ._calendarEventIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.calendarEventId, + referencedTable: $$CalendarEventAttendeesTableReferences + ._calendarEventIdTable(db), + referencedColumn: + $$CalendarEventAttendeesTableReferences + ._calendarEventIdTable(db) + .id, + ) as T; } return state; @@ -46616,19 +46503,16 @@ class $$CalendarEventRemindersTableTableManager > >(state) { if (calendarEventId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarEventId, - referencedTable: - $$CalendarEventRemindersTableReferences - ._calendarEventIdTable(db), - referencedColumn: - $$CalendarEventRemindersTableReferences - ._calendarEventIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.calendarEventId, + referencedTable: $$CalendarEventRemindersTableReferences + ._calendarEventIdTable(db), + referencedColumn: + $$CalendarEventRemindersTableReferences + ._calendarEventIdTable(db) + .id, + ) as T; } return state; @@ -47359,49 +47243,37 @@ class $$SyncCursorsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$SyncCursorsTableReferences - ._accountIdTable(db), - referencedColumn: - $$SyncCursorsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$SyncCursorsTableReferences + ._accountIdTable(db), + referencedColumn: $$SyncCursorsTableReferences + ._accountIdTable(db) + .id, + ) as T; } if (projectionSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.projectionSourceId, - referencedTable: - $$SyncCursorsTableReferences - ._projectionSourceIdTable(db), - referencedColumn: - $$SyncCursorsTableReferences - ._projectionSourceIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.projectionSourceId, + referencedTable: $$SyncCursorsTableReferences + ._projectionSourceIdTable(db), + referencedColumn: $$SyncCursorsTableReferences + ._projectionSourceIdTable(db) + .id, + ) as T; } if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$SyncCursorsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$SyncCursorsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: $$SyncCursorsTableReferences + ._davCollectionIdTable(db), + referencedColumn: $$SyncCursorsTableReferences + ._davCollectionIdTable(db) + .id, + ) as T; } return state; @@ -48015,19 +47887,15 @@ class $$ScheduleItemOverridesTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$ScheduleItemOverridesTableReferences - ._accountIdTable(db), - referencedColumn: - $$ScheduleItemOverridesTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$ScheduleItemOverridesTableReferences + ._accountIdTable(db), + referencedColumn: $$ScheduleItemOverridesTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -48612,19 +48480,17 @@ class $$NotificationScheduleTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$NotificationScheduleTableReferences - ._accountIdTable(db), - referencedColumn: - $$NotificationScheduleTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$NotificationScheduleTableReferences + ._accountIdTable(db), + referencedColumn: + $$NotificationScheduleTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; @@ -49013,19 +48879,17 @@ class $$AndroidNotificationMappingsTableTableManager > >(state) { if (scheduleId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.scheduleId, - referencedTable: - $$AndroidNotificationMappingsTableReferences - ._scheduleIdTable(db), - referencedColumn: - $$AndroidNotificationMappingsTableReferences - ._scheduleIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.scheduleId, + referencedTable: + $$AndroidNotificationMappingsTableReferences + ._scheduleIdTable(db), + referencedColumn: + $$AndroidNotificationMappingsTableReferences + ._scheduleIdTable(db) + .id, + ) as T; } return state; @@ -49730,19 +49594,15 @@ class $$LocationResolutionsTableTableManager > >(state) { if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$LocationResolutionsTableReferences - ._accountIdTable(db), - referencedColumn: - $$LocationResolutionsTableReferences - ._accountIdTable(db) - .id, - ) - as T; + state = state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: $$LocationResolutionsTableReferences + ._accountIdTable(db), + referencedColumn: $$LocationResolutionsTableReferences + ._accountIdTable(db) + .id, + ) as T; } return state; diff --git a/lib/src/db/location_migration.dart b/lib/src/db/location_migration.dart index d310e938..5a07c416 100644 --- a/lib/src/db/location_migration.dart +++ b/lib/src/db/location_migration.dart @@ -90,9 +90,9 @@ GeographicPoint? _icalPoint( ) { if (text == null) return null; try { - final components = IcalSemanticDocument.parse( - text, - ).components.where((c) => c.uid == uid).toList(); + final components = IcalSemanticDocument.parse(text).components + .where((c) => c.uid == uid) + .toList(); IcalSemanticComponent? master, exact, inherited; for (final c in components) { if (c.recurrenceIdKey == null) master = c; diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index 9d76735a..f3063e38 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -3,7 +3,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; import 'location_migration.dart'; -const latestSchemaVersion = 19; +const latestSchemaVersion = 20; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -21,6 +21,7 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { return MigrationStrategy( onCreate: (migrator) async { await migrator.createAll(); + await _createContactsSchema(database); await _createIndexes(database); await createLocationLifecycleTriggers(database); await _verifyForeignKeys(database); @@ -100,6 +101,9 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { "INSERT INTO ${database.oAuthTransitionAccounts.actualTableName} (account_id) SELECT id FROM accounts WHERE provider IN ('google', 'microsoft')", ); } + if (from < 20) { + await _createContactsSchema(database); + } await _createIndexes(database); await createLocationLifecycleTriggers(database); await _verifyForeignKeys(database); @@ -110,6 +114,97 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { ); } +/// Contacts remain an app-owned subsystem while sharing BusyMax's existing +/// database connection and transaction/isolate lifecycle. These namespaced +/// tables deliberately do not widen the calendar/task account constraints: +/// OAuth accounts may be linked and contacts-only DAV accounts stand alone. +Future _createContactsSchema(AppDatabase database) async { + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_accounts( + id TEXT NOT NULL PRIMARY KEY, + data TEXT NOT NULL + ) + '''); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_account_links( + contact_account_id TEXT NOT NULL PRIMARY KEY + REFERENCES bm_contact_accounts(id) ON DELETE CASCADE, + busymax_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL, + reuse_authorization INTEGER NOT NULL DEFAULT 0 + CHECK(reuse_authorization IN (0, 1)) + ) + '''); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_sources( + source_key TEXT NOT NULL PRIMARY KEY, + account_id TEXT NOT NULL + REFERENCES bm_contact_accounts(id) ON DELETE CASCADE, + data TEXT NOT NULL + ) + '''); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_records( + identity_key TEXT NOT NULL PRIMARY KEY, + account_id TEXT NOT NULL + REFERENCES bm_contact_accounts(id) ON DELETE CASCADE, + source_key TEXT NOT NULL + REFERENCES bm_contact_sources(source_key) ON DELETE CASCADE, + name TEXT NOT NULL, + search_text TEXT NOT NULL, + data TEXT NOT NULL, + projection TEXT NOT NULL, + seen_scan TEXT + ) + '''); + await database.customStatement( + 'CREATE INDEX IF NOT EXISTS bm_contact_records_name_idx ' + 'ON bm_contact_records(name COLLATE NOCASE, identity_key)', + ); + await database.customStatement( + 'CREATE INDEX IF NOT EXISTS bm_contact_records_account_idx ' + 'ON bm_contact_records(account_id, name COLLATE NOCASE, identity_key)', + ); + await database.customStatement( + 'CREATE INDEX IF NOT EXISTS bm_contact_records_source_idx ' + 'ON bm_contact_records(source_key, name COLLATE NOCASE, identity_key)', + ); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_mutations( + id TEXT NOT NULL PRIMARY KEY, + account_id TEXT NOT NULL + REFERENCES bm_contact_accounts(id) ON DELETE CASCADE, + source_key TEXT NOT NULL, + data TEXT NOT NULL + ) + '''); + await database.customStatement( + 'CREATE INDEX IF NOT EXISTS bm_contact_mutations_account_idx ' + 'ON bm_contact_mutations(account_id, source_key)', + ); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_sync_states( + source_key TEXT NOT NULL PRIMARY KEY + REFERENCES bm_contact_sources(source_key) ON DELETE CASCADE, + data TEXT NOT NULL + ) + '''); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_drafts( + id TEXT NOT NULL PRIMARY KEY, + source_key TEXT NOT NULL + REFERENCES bm_contact_sources(source_key) ON DELETE CASCADE, + data TEXT NOT NULL + ) + '''); + await database.customStatement(''' + CREATE TABLE IF NOT EXISTS bm_contact_source_preferences( + source_key TEXT NOT NULL PRIMARY KEY + REFERENCES bm_contact_sources(source_key) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)) + ) + '''); +} + Future _migrateToV13(Migrator migrator, AppDatabase database) async { await _verifyAccountIdentity(database); await migrator.alterTable(TableMigration(database.accounts)); diff --git a/lib/src/demo/demo_profile.dart b/lib/src/demo/demo_profile.dart index 1e07ed81..e6a2295e 100644 --- a/lib/src/demo/demo_profile.dart +++ b/lib/src/demo/demo_profile.dart @@ -13,9 +13,13 @@ import '../features/notifications/notification_scheduler.dart'; import '../features/sync/account_sync_operations.dart'; import '../features/sync/all_accounts_sync_scheduler.dart'; import '../google_tasks/api/google_tasks_api_surface.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; + import '../google_tasks/oauth/oauth_service.dart'; + import 'package:busymax/src/core/secrets/secret_store.dart'; + import 'demo_seed.dart'; AppSettings busyMaxDemoSettings(BusyMaxDemoTheme theme) { diff --git a/lib/src/features/accounts/data/accounts_repository.dart b/lib/src/features/accounts/data/accounts_repository.dart index 6677d44a..14facd14 100644 --- a/lib/src/features/accounts/data/accounts_repository.dart +++ b/lib/src/features/accounts/data/accounts_repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import '../../../db/app_database.dart'; import '../domain/account_connection_state.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/providers/account_authority.dart'; @@ -45,6 +46,7 @@ class AccountEntity { this.email, this.tenantId, this.providerMetadataJson, + this.grantedScopes = '', this.calendarsEnabled = true, this.tasksEnabled = true, this.lastSuccessfulSyncAtUtc, @@ -70,14 +72,14 @@ class AccountEntity { email: row.email, tenantId: row.tenantId, providerMetadataJson: row.providerMetadataJson, + grantedScopes: row.grantedScopes, calendarsEnabled: row.calendarsEnabled, tasksEnabled: row.tasksEnabled, lastSuccessfulSyncAtUtc: DateTime.tryParse( row.lastSuccessfulSyncAtUtc ?? '', )?.toUtc(), - lastFullSyncAtUtc: DateTime.tryParse( - row.lastFullSyncAtUtc ?? '', - )?.toUtc(), + lastFullSyncAtUtc: DateTime.tryParse(row.lastFullSyncAtUtc ?? '') + ?.toUtc(), authState: row.authState, ); } @@ -92,6 +94,7 @@ class AccountEntity { final String? email; final String? tenantId; final String? providerMetadataJson; + final String grantedScopes; final bool calendarsEnabled; final bool tasksEnabled; final DateTime? lastSuccessfulSyncAtUtc; diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index adb2f881..43ebf08b 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -1,5 +1,6 @@ import '../../../core/auth/authorization_attempt.dart'; import '../../../core/http/request_dispatch_exception.dart'; + import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -16,11 +17,16 @@ import '../../../db/app_database.dart'; import '../../../features/accounts/data/accounts_repository.dart'; import '../../../google_tasks/api/google_tasks_api_surface.dart'; import '../../../google_tasks/oauth/oauth_loopback_flow.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; + import '../../../google_tasks/oauth/oauth_service.dart'; + import 'package:busymax/src/core/secrets/secret_store.dart'; + import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../sync/sync_auth_error.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; enum AuthSessionStatus { @@ -115,6 +121,12 @@ class AuthRepository { MicrosoftOAuthGateway? microsoftOAuth, DateTime Function()? nowUtc, AuthorizationPersistence? authorizationPersistence, + this.onAccountRemoving, + this.persistLinkedAccountRemoval, + this.onAccountRemovalFailed, + this.onAccountRemoved, + this.onAccountRemovalFinished, + this.onAccountRemovalStarting, }) : _authorizationPersistence = authorizationPersistence, _oAuth = oAuth, _accountsRepository = @@ -126,6 +138,13 @@ class AuthRepository { final OAuthGateway _oAuth; final AccountsRepository _accountsRepository; final MicrosoftOAuthGateway? _microsoftOAuth; + final Future Function(String accountId)? onAccountRemoving; + final Future Function(String accountId)? persistLinkedAccountRemoval; + final Future Function(String accountId)? onAccountRemovalFailed; + final void Function()? onAccountRemoved; + final void Function(String accountId)? onAccountRemovalFinished; + final void Function(String accountId)? onAccountRemovalStarting; + final Map> _removing = {}; final RedactingLogger _logger = RedactingLogger(Logger('AuthRepository')); Future loadSession() async { @@ -265,21 +284,66 @@ class AuthRepository { required String accountId, bool revokeAuthorization = false, }) { + final existing = _removing[accountId]; + if (existing != null) return existing; + try { + onAccountRemovalStarting?.call(accountId); + } on Object catch (error, stack) { + return Future.error(error, stack); + } + late final Future operation; + operation = + Future.sync( + () => _runRemoval(accountId, revokeAuthorization), + ).whenComplete(() { + if (identical(_removing[accountId], operation)) { + _removing.remove(accountId); + } + onAccountRemovalFinished?.call(accountId); + }); + _removing[accountId] = operation; + return operation; + } + + Future _runRemoval( + String accountId, + bool revokeAuthorization, + ) async { final persistence = _authorizationPersistence; if (persistence == null) { + await onAccountRemoving?.call(accountId); return _removeAccount( accountId: accountId, revokeAuthorization: revokeAuthorization, ); } - return persistence.runRemoval( - accountId, - (snapshot) => _removeAccount( - accountId: accountId, - revokeAuthorization: revokeAuthorization, - snapshot: snapshot, - ), - ); + var entered = false; + try { + return await persistence.runRemoval(accountId, (snapshot) { + entered = true; + return _removeAccount( + accountId: accountId, + revokeAuthorization: revokeAuthorization, + snapshot: snapshot, + ); + }, prepare: () async => onAccountRemoving?.call(accountId)); + } on Object catch (error, stack) { + if (!entered) { + try { + await persistence.run(accountId, () async { + if (await persistence.removalRecoveryAvailable(accountId)) { + await onAccountRemovalFailed?.call(accountId); + } + }); + } on Object catch (recovery, recoveryStack) { + _logger.warning( + 'Account removal preparation recovery failed: ' + '$recovery\n$recoveryStack', + ); + } + } + Error.throwWithStackTrace(error, stack); + } } Future _removeAccount({ @@ -321,25 +385,43 @@ class AuthRepository { } } - if (_authorizationPersistence case final persistence?) { - try { - await persistence.removeCoherently( - accountId, - clearAuthorization, - () => _accountsRepository.deleteAccount(accountId), - ); - } on Object { - if (revocationStatus == - AccountAuthorizationRevocationStatus.succeeded) { - throw const AccountRemovalPersistenceException(); + try { + if (_authorizationPersistence case final persistence?) { + try { + await persistence.removeCoherently( + accountId, + clearAuthorization, + () async { + await persistLinkedAccountRemoval?.call(accountId); + await _accountsRepository.deleteAccount(accountId); + }, + ); + } on Object { + if (revocationStatus == + AccountAuthorizationRevocationStatus.succeeded) { + throw const AccountRemovalPersistenceException(); + } + rethrow; } - rethrow; + } else { + await clearAuthorization(); + await persistLinkedAccountRemoval?.call(accountId); + await _accountsRepository.deleteAccount(accountId); } - } else { - await clearAuthorization(); - await _accountsRepository.deleteAccount(accountId); + } on Object { + if (revocationStatus != AccountAuthorizationRevocationStatus.succeeded && + (_authorizationPersistence == null || + snapshot != null && + await _authorizationPersistence.canRestoreRemoval( + snapshot, + ))) { + await onAccountRemovalFailed?.call(accountId); + } + rethrow; } + onAccountRemoved?.call(); + return AccountRemovalResult( authorizationRevocationStatus: revocationStatus, ); diff --git a/lib/src/features/auth/presentation/registration_setup_dialog.dart b/lib/src/features/auth/presentation/registration_setup_dialog.dart index f1179f62..ff9a11df 100644 --- a/lib/src/features/auth/presentation/registration_setup_dialog.dart +++ b/lib/src/features/auth/presentation/registration_setup_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yaru/yaru.dart'; import 'package:url_launcher/url_launcher.dart'; + import '../../../app/busymax_shortcuts.dart'; import '../../../app/app_bootstrap.dart'; import '../../../core/auth/oauth_registration.dart'; @@ -372,9 +373,8 @@ class _RegistrationDialogState extends State<_RegistrationDialog> { Widget _error(String message, Key key) => Text( message, key: key, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.error), ); Future openLink(String url, int step) async { if (launching) return; diff --git a/lib/src/features/calendar/data/calendar_collection_creation_service.dart b/lib/src/features/calendar/data/calendar_collection_creation_service.dart index 15ddcf35..caceefe8 100644 --- a/lib/src/features/calendar/data/calendar_collection_creation_service.dart +++ b/lib/src/features/calendar/data/calendar_collection_creation_service.dart @@ -106,9 +106,8 @@ final class CalendarCollectionCreationService String accountId, String title, ) async { - final result = await _davClientForAccount( - accountId, - ).createEventCalendar(title); + final result = await _davClientForAccount(accountId) + .createEventCalendar(title); return CalendarCollectionCreationResult( outcome: result.refreshPending ? CalendarCollectionCreationOutcome.createdRefreshPending diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 0b678b07..20f995c4 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -1,4 +1,5 @@ import '../domain/event_property_policy.dart'; + import 'dart:convert'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -42,11 +43,10 @@ import 'calendar_event_detail.dart'; import '../../../dav/nextcloud/nextcloud_scheduling_policy.dart'; import '../../../dav/nextcloud/nextcloud_meeting_mutations.dart'; -typedef CalendarEventRecoveryFetcher = - Future Function({ - required String calendarId, - required String eventId, - }); +typedef CalendarEventRecoveryFetcher = Future Function({ + required String calendarId, + required String eventId, +}); final class ImportedEventException { const ImportedEventException({ @@ -214,12 +214,10 @@ class CalendarSourceCapabilities { dav['delegated'] != true && dav['ownerHref'] != null && dav['principalHref'] != null && - Uri.parse( - dav['ownerHref']! as String, - ).path.replaceFirst(RegExp(r'/+$'), '') != - Uri.parse( - dav['principalHref']! as String, - ).path.replaceFirst(RegExp(r'/+$'), ''); + Uri.parse(dav['ownerHref']! as String).path + .replaceFirst(RegExp(r'/+$'), '') != + Uri.parse(dav['principalHref']! as String).path + .replaceFirst(RegExp(r'/+$'), ''); final renameMode = switch (source.provider) { BusyProvider.nextcloud when davMetadataWritable => CalendarRenameMode.global, @@ -853,9 +851,8 @@ class CalendarRepository { )..where((row) => row.id.equals(sourceId))).getSingle(); final entity = await _sourceEntity(source); if (entity.provider == BusyProvider.nextcloud) { - final result = await _requiredNextcloudCollections( - source, - ).remove(source.davCollectionId!); + final result = await _requiredNextcloudCollections(source) + .remove(source.davCollectionId!); if (result == NextcloudMutationOutcome.refreshPending) { throw const NextcloudRefreshPending(); } @@ -906,9 +903,8 @@ class CalendarRepository { )..where((row) => row.id.equals(sourceId))).go(); return; } - final otherWork = (await _pendingCalendarWork( - source.id, - )).where((operation) => operation.id != createOp?.id); + final otherWork = (await _pendingCalendarWork(source.id)) + .where((operation) => operation.id != createOp?.id); if (otherWork.isNotEmpty) { throw CalendarMutationNotAllowed( operation: removalMode == CalendarRemovalMode.removeFromList @@ -1057,12 +1053,11 @@ class CalendarRepository { ), ); if (source.provider == BusyProvider.google && source.isDeleted) { - await LocationResolutionRepository( - _database, - ).removeGoogleSeriesSourcesForCalendar( - accountId: accountId, - sourceId: id, - ); + await LocationResolutionRepository(_database) + .removeGoogleSeriesSourcesForCalendar( + accountId: accountId, + sourceId: id, + ); } }); } @@ -1118,12 +1113,11 @@ class CalendarRepository { if (missingSourceIds.isEmpty) return; for (final sourceId in missingSourceIds) { - await LocationResolutionRepository( - _database, - ).removeGoogleSeriesSourcesForCalendar( - accountId: accountId, - sourceId: sourceId, - ); + await LocationResolutionRepository(_database) + .removeGoogleSeriesSourcesForCalendar( + accountId: accountId, + sourceId: sourceId, + ); } final missingEventIds = { @@ -1704,9 +1698,8 @@ class CalendarRepository { _database.calendarEvents, )..where((candidate) => candidate.id.equals(eventId))).getSingleOrNull(); if (row != null) { - await LocationResolutionRepository( - _database, - ).reconcileGoogleSeriesMaster(row); + await LocationResolutionRepository(_database) + .reconcileGoogleSeriesMaster(row); } } @@ -2582,9 +2575,9 @@ class CalendarRepository { final moveOperationId = const Uuid().v4(); final patchOperationId = const Uuid().v4(); final nowUtc = DateTime.now().toUtc().toIso8601String(); - final patchCreatedAtUtc = DateTime.parse( - nowUtc, - ).add(const Duration(microseconds: 1)).toIso8601String(); + final patchCreatedAtUtc = DateTime.parse(nowUtc) + .add(const Duration(microseconds: 1)) + .toIso8601String(); final now = _now().millisecondsSinceEpoch; await _database.transaction(() async { if (seriesMutation && _eventRequestHasMutation(patchRequest)) { @@ -2616,13 +2609,12 @@ class CalendarRepository { if (provider == BusyProvider.google && existing.providerRecurringEventId == null && existing.recurrenceJson != null) { - await LocationResolutionRepository( - _database, - ).removeGoogleSeriesSource( - accountId: existing.accountId, - sourceId: existing.calendarSourceId, - providerSeriesId: existing.providerEventId, - ); + await LocationResolutionRepository(_database) + .removeGoogleSeriesSource( + accountId: existing.accountId, + sourceId: existing.calendarSourceId, + providerSeriesId: existing.providerEventId, + ); } } await _database @@ -2770,18 +2762,17 @@ class CalendarRepository { localProjectionId: existing.id, postMovePatch: patch, ); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: existing.accountId, - collectionId: sourceCollectionId, - provider: BusyProviderCodec.requireStorageValue( - originalSource.provider, - ), - objectId: objectId, - candidateRawIcs: candidate, - projectedAtUtc: _now().toUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: sourceCollectionId, + provider: BusyProviderCodec.requireStorageValue( + originalSource.provider, + ), + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); await (_database.update(_database.calendarEvents)..where( (row) => row.accountId.equals(existing.accountId) & @@ -3858,16 +3849,15 @@ class CalendarRepository { objectId: objectId, patch: patch, ); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: existing.accountId, - collectionId: source.davCollectionId!, - provider: BusyProvider.nextcloud, - objectId: objectId, - candidateRawIcs: patch.applyTo(baseline, nowUtc: _now().toUtc()), - projectedAtUtc: _now().toUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: source.davCollectionId!, + provider: BusyProvider.nextcloud, + objectId: objectId, + candidateRawIcs: patch.applyTo(baseline, nowUtc: _now().toUtc()), + projectedAtUtc: _now().toUtc(), + ); }); await _rebuildEventNotificationsFor({existing.accountId}); return existing.accountId; @@ -4176,16 +4166,15 @@ class CalendarRepository { endTimeZone: endTimeZone, ); } else { - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: existing.accountId, - collectionId: collectionId, - provider: BusyProviderCodec.requireStorageValue(source.provider), - objectId: objectId, - candidateRawIcs: candidate, - projectedAtUtc: _now().toUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: collectionId, + provider: BusyProviderCodec.requireStorageValue(source.provider), + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); } }); if (timingOnly || deferNotifications) return; @@ -4273,9 +4262,8 @@ class CalendarRepository { if (!updated) { throw StateError('The pending DAV create is no longer editable.'); } - final component = IcalDocumentPatcher( - IcalDocument.parse(candidate), - ).requireComponent(target); + final component = IcalDocumentPatcher(IcalDocument.parse(candidate)) + .requireComponent(target); await (_database.update( _database.calendarEvents, )..where((row) => row.id.equals(existing.id))).write( @@ -4297,16 +4285,15 @@ class CalendarRepository { objectId: objectId, patch: patch, ); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: accountId, - collectionId: collectionId, - provider: BusyProvider.nextcloud, - objectId: objectId, - candidateRawIcs: candidate, - projectedAtUtc: _now().toUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: accountId, + collectionId: collectionId, + provider: BusyProvider.nextcloud, + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); } }); return loadEventDetail(eventId); @@ -4404,16 +4391,15 @@ class CalendarRepository { objectId: objectId, patch: patch, ); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: existing.accountId, - collectionId: collectionId, - provider: provider, - objectId: objectId, - candidateRawIcs: candidate, - projectedAtUtc: _now().toUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: collectionId, + provider: provider, + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); return; } @@ -4492,16 +4478,15 @@ class CalendarRepository { objectId: objectId, patch: patch, ); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: existing.accountId, - collectionId: collectionId, - provider: provider, - objectId: objectId, - candidateRawIcs: candidate, - projectedAtUtc: _now().toUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: existing.accountId, + collectionId: collectionId, + provider: provider, + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); }); await _notificationScheduleService().rebuildUpcomingEventNotifications( existing.accountId, @@ -4636,9 +4621,8 @@ class CalendarRepository { }; return { for (final operation in operations) - ..._calendarPendingRequest( - operation, - ).keys.where(projectedFields.contains), + ..._calendarPendingRequest(operation).keys + .where(projectedFields.contains), }; } @@ -5178,12 +5162,10 @@ DavEventMutationInput _seriesDavEventInput({ ); } } - final startDelta = providerCivilDateTime( - desired.start, - ).difference(providerCivilDateTime(projectedStart)); - final endDelta = providerCivilDateTime( - desired.end, - ).difference(providerCivilDateTime(projectedEnd)); + final startDelta = providerCivilDateTime(desired.start) + .difference(providerCivilDateTime(projectedStart)); + final endDelta = providerCivilDateTime(desired.end) + .difference(providerCivilDateTime(projectedEnd)); final startChanged = startDelta != Duration.zero; final endChanged = endDelta != Duration.zero; final allDayChanged = desired.allDay != existing.allDay; diff --git a/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart b/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart index 8813b59f..45ba0997 100644 --- a/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart +++ b/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart @@ -43,9 +43,8 @@ final class MicrosoftSharedCalendarService { accountId, cancellation: cancellation, ); - final source = await clientForAccount( - accountId, - ).getSharedPrimaryCalendar(owner); + final source = await clientForAccount(accountId) + .getSharedPrimaryCalendar(owner); await repository.upsertSource( accountId: accountId, source: source, diff --git a/lib/src/features/calendar/presentation/event_description_editor.dart b/lib/src/features/calendar/presentation/event_description_editor.dart index ee70d9b9..1dbe2313 100644 --- a/lib/src/features/calendar/presentation/event_description_editor.dart +++ b/lib/src/features/calendar/presentation/event_description_editor.dart @@ -4,6 +4,7 @@ import '../../../app/busymax_design.dart'; import '../../../app/busymax_yaru_theme.dart'; import '../../../calendar_providers/calendar_description.dart'; import '../../../l10n/l10n.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; class EventDescriptionValue { @@ -221,12 +222,10 @@ InputDecoration _descriptionDecoration(BuildContext context) { return InputDecoration( labelText: context.l10n.description, alignLabelWithHint: true, - floatingLabelStyle: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - labelStyle: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + floatingLabelStyle: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + labelStyle: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 51c6bd30..79716c92 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -1,5 +1,7 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import '../domain/event_property_policy.dart'; + import 'dart:async'; import 'package:flutter/material.dart'; @@ -9,6 +11,7 @@ import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; import '../../../app/app_bootstrap.dart'; +import '../../../contacts/busymax_contacts_controller.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_window_close.dart'; import '../../../calendar_providers/calendar_colors.dart'; @@ -17,7 +20,9 @@ import '../../../calendar_providers/calendar_provider_capabilities.dart'; import '../../../microsoft_calendar/microsoft_calendar_models.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_projection.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import '../../accounts/data/accounts_repository.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; import '../../recurrence/domain/recurrence_rule.dart'; @@ -288,8 +293,10 @@ bool _hasExternalGuests( ].any((attendee) => !attendee.self && !attendee.organizer); } -typedef EventEditorDeleteCallback = - void Function(String eventId, RecurringEventMutationScope? scope); +typedef EventEditorDeleteCallback = void Function( + String eventId, + RecurringEventMutationScope? scope, +); class EventEditor extends ConsumerStatefulWidget { const EventEditor({ @@ -322,6 +329,9 @@ class _EventEditorState extends ConsumerState { final _shortcutFocusNode = FocusNode(debugLabel: 'Event editor shortcuts'); final _guestController = TextEditingController(); String? _guestError; + List _guestSuggestions = const []; + int _guestSuggestionGeneration = 0; + StreamSubscription? _contactsChanges; var _addingGuest = false; var _addingCategory = false; var _confirmingCancel = false; @@ -348,6 +358,8 @@ class _EventEditorState extends ConsumerState { @override void dispose() { _consentCancellation?.cancel(); + _guestSuggestionGeneration++; + unawaited(_contactsChanges?.cancel()); _shortcutFocusNode.dispose(); _guestController.dispose(); super.dispose(); @@ -949,14 +961,14 @@ class _EventEditorState extends ConsumerState { Widget _accountRow() { final accountIds = - { - for (final source in _selectableSources) source.accountId, - }.toList()..sort((first, second) { - final labelOrder = _accountLabel( - first, - ).toLowerCase().compareTo(_accountLabel(second).toLowerCase()); - return labelOrder != 0 ? labelOrder : first.compareTo(second); - }); + {for (final source in _selectableSources) source.accountId} + .toList() + ..sort((first, second) { + final labelOrder = _accountLabel(first) + .toLowerCase() + .compareTo(_accountLabel(second).toLowerCase()); + return labelOrder != 0 ? labelOrder : first.compareTo(second); + }); final selected = accountIds.contains(_draft.accountId) ? _draft.accountId : accountIds.first; @@ -1135,6 +1147,8 @@ class _EventEditorState extends ConsumerState { if (source.provider == BusyProvider.google) { _addingCategory = false; } + _guestSuggestionGeneration++; + _guestSuggestions = const []; _draft = updated; }); } @@ -1326,10 +1340,23 @@ class _EventEditorState extends ConsumerState { labelText: context.l10n.addGuestEmail, errorText: _guestError, ), + onChanged: _loadGuestSuggestions, onSubmitted: (_) => _addGuest(), ), ), ); + rows.addAll([ + for (final suggestion in _guestSuggestions) + BusyMaxActionRow( + key: ValueKey('guest-contact-${suggestion.email}'), + title: suggestion.displayName.isEmpty + ? suggestion.email + : suggestion.displayName, + subtitle: suggestion.displayName.isEmpty ? null : suggestion.email, + leading: const Icon(Icons.person_outline), + onTap: () => _addSuggestedGuest(suggestion), + ), + ]); } return rows; } @@ -1827,6 +1854,8 @@ class _EventEditorState extends ConsumerState { } void _addGuest() { + _guestSuggestionGeneration++; + _guestSuggestions = const []; final email = _guestController.text.trim(); if (!_looksLikeEmail(email)) { setState(() => _guestError = context.l10n.feedbackInvalidEmail); @@ -1853,6 +1882,102 @@ class _EventEditorState extends ConsumerState { }); } + Future _loadGuestSuggestions(String query) async { + final generation = ++_guestSuggestionGeneration; + final accountId = _draft.accountId; + final sourceId = _draft.sourceId; + if (query.trim().isEmpty) { + setState(() => _guestSuggestions = const []); + return; + } + try { + _contactsChanges ??= ref + .read(busyMaxContactsControllerProvider) + .changes + .listen((_) { + _guestSuggestionGeneration++; + if (mounted) setState(() => _guestSuggestions = const []); + }); + final suggestions = await ref + .read(busyMaxContactsControllerProvider) + .suggestAttendees( + query, + busyMaxAccountId: accountId, + excludedAddresses: { + for (final attendee in _draft.attendees) attendee.email, + }, + ); + if (!mounted || + generation != _guestSuggestionGeneration || + _draft.accountId != accountId || + _draft.sourceId != sourceId || + _guestController.text.trim() != query.trim()) { + return; + } + setState(() => _guestSuggestions = suggestions); + } on Object { + // Manual attendee entry remains available when contacts are unavailable. + if (!mounted || + generation != _guestSuggestionGeneration || + _draft.accountId != accountId || + _draft.sourceId != sourceId || + _guestController.text.trim() != query.trim()) { + return; + } + setState(() => _guestSuggestions = const []); + } + } + + Future _addSuggestedGuest(BusyMaxContactSuggestion suggestion) async { + if (!_draft.canManageAttendees || !_guestSuggestions.contains(suggestion)) { + return; + } + final generation = _guestSuggestionGeneration; + final accountId = _draft.accountId; + final sourceId = _draft.sourceId; + try { + if (!await ref + .read(busyMaxContactsControllerProvider) + .suggestionEligible(suggestion, busyMaxAccountId: accountId)) { + return; + } + } on Object { + return; + } + if (!mounted || + generation != _guestSuggestionGeneration || + _draft.accountId != accountId || + _draft.sourceId != sourceId || + !_draft.canManageAttendees || + !_guestSuggestions.contains(suggestion)) { + return; + } + _guestSuggestionGeneration++; + final duplicate = _draft.attendees.any( + (attendee) => + attendee.email.toLowerCase() == suggestion.email.toLowerCase(), + ); + _guestController.clear(); + setState(() { + _guestSuggestions = const []; + _addingGuest = false; + _guestError = null; + if (!duplicate) { + _draft = _draft.copyWith( + attendees: [ + ..._draft.attendees, + EventAttendeeDraft( + email: suggestion.email, + displayName: suggestion.displayName.isEmpty + ? null + : suggestion.displayName, + ), + ], + ); + } + }); + } + void _addCategory(String value) { final category = value.trim(); if (category.isEmpty || @@ -2015,9 +2140,8 @@ TextStyle? _eventEditorProminentActionStyle( Color? color, FontWeight fontWeight = FontWeight.w600, }) { - return Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: color, fontWeight: fontWeight); + return Theme.of(context).textTheme.labelLarge + ?.copyWith(color: color, fontWeight: fontWeight); } String? _dateString(DateTime? value) { diff --git a/lib/src/features/diagnostics/presentation/diagnostics_screen.dart b/lib/src/features/diagnostics/presentation/diagnostics_screen.dart index 362b8200..d232ed80 100644 --- a/lib/src/features/diagnostics/presentation/diagnostics_screen.dart +++ b/lib/src/features/diagnostics/presentation/diagnostics_screen.dart @@ -51,9 +51,8 @@ class DiagnosticsPanel extends ConsumerWidget { header: true, child: Text( l10n.implementedMethods, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontWeight: FontWeight.w600), ), ), const SizedBox(height: 8), @@ -76,9 +75,8 @@ class DiagnosticsPanel extends ConsumerWidget { header: true, child: Text( l10n.blockedPendingOperations, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(fontWeight: FontWeight.w600), ), ), const SizedBox(height: 8), @@ -202,9 +200,8 @@ class _BlockedPendingOpTile extends StatelessWidget { if (!context.mounted) { return; } - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.retryCompleted))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(context.l10n.retryCompleted))); } on Object catch (error) { if (!context.mounted) { return; diff --git a/lib/src/features/maps/domain/location_result.dart b/lib/src/features/maps/domain/location_result.dart index 8dc1b74b..306270d1 100644 --- a/lib/src/features/maps/domain/location_result.dart +++ b/lib/src/features/maps/domain/location_result.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; + import 'geographic_point.dart'; /// Coordinate metadata retained for imports, copies, and existing records. diff --git a/lib/src/features/notifications/desktop_notification_backend.dart b/lib/src/features/notifications/desktop_notification_backend.dart index a02cab0e..d09855be 100644 --- a/lib/src/features/notifications/desktop_notification_backend.dart +++ b/lib/src/features/notifications/desktop_notification_backend.dart @@ -20,8 +20,10 @@ final class DesktopNotificationReadiness { bool get canNotify => state == DesktopNotificationReadinessState.available; } -typedef DesktopNotificationActionHandler = - Future Function(String action, Map? payload); +typedef DesktopNotificationActionHandler = Future Function( + String action, + Map? payload, +); enum BusyMaxNotificationCategory { general, diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index a8ed5492..40ae9405 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -35,8 +35,9 @@ final class ReminderDeliveryResult { final DateTime? retryAtUtc; } -typedef ReminderNotificationActionHandler = - Future Function(ReminderNotificationAction action); +typedef ReminderNotificationActionHandler = Future Function( + ReminderNotificationAction action, +); class DesktopNotificationService { DesktopNotificationService({ diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 2cfd2418..346adde1 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import '../../microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:drift/drift.dart'; diff --git a/lib/src/features/notifications/notification_scheduler.dart b/lib/src/features/notifications/notification_scheduler.dart index fff41ebc..a7537d3f 100644 --- a/lib/src/features/notifications/notification_scheduler.dart +++ b/lib/src/features/notifications/notification_scheduler.dart @@ -11,11 +11,10 @@ import 'notification_schedule_service.dart'; const defaultReminderSnoozeDuration = Duration(minutes: 10); -typedef ScheduledReminderActionHandler = - Future Function( - NotificationScheduleData row, - ReminderNotificationAction action, - ); +typedef ScheduledReminderActionHandler = Future Function( + NotificationScheduleData row, + ReminderNotificationAction action, +); class NotificationScheduler { NotificationScheduler({ diff --git a/lib/src/features/recurrence/domain/recurrence_rule.dart b/lib/src/features/recurrence/domain/recurrence_rule.dart index 250558fb..6bfcc0a5 100644 --- a/lib/src/features/recurrence/domain/recurrence_rule.dart +++ b/lib/src/features/recurrence/domain/recurrence_rule.dart @@ -491,9 +491,8 @@ _NormalizedEditorRule? _normalizeMonthlyOrYearly({ } if (byDay.length == 1 && bySetPositions.isEmpty) { - final ordinal = RegExp( - r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$', - ).firstMatch(byDay.single); + final ordinal = RegExp(r'^(-?[1-5])(MO|TU|WE|TH|FR|SA|SU)$') + .firstMatch(byDay.single); if (ordinal != null) { if (byMonth.length > 1) return null; final position = int.parse(ordinal.group(1)!); diff --git a/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart b/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart index e459dd72..1d442eb6 100644 --- a/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart +++ b/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart @@ -5,6 +5,7 @@ import '../../../google_calendar/google_calendar_api_client.dart'; import '../../../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../../../providers/busy_provider.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; + import 'package:timezone/timezone.dart' as tz; import 'tz_continuations_2025c.dart'; @@ -226,9 +227,10 @@ String cloudSeriesToICalendar({ 'END:VEVENT', ]); } - final referencedZones = RegExp( - r';TZID=([^:]+):', - ).allMatches(eventLines.join('\n')).map((match) => match.group(1)!).toSet(); + final referencedZones = RegExp(r';TZID=([^:]+):') + .allMatches(eventLines.join('\n')) + .map((match) => match.group(1)!) + .toSet(); final lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', diff --git a/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart b/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart index 76798b7d..005ce4cb 100644 --- a/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart +++ b/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart @@ -288,8 +288,10 @@ class _LinuxEventAttachmentsDialogState contentType: file.mimeType ?? 'application/octet-stream', bytes: await file.readAsBytes(), ); + if (!mounted) return; _refresh(); } on Object catch (error) { + if (!mounted) return; _refresh(); _error(error); } finally { diff --git a/lib/src/features/schedule/presentation/mini_calendar.dart b/lib/src/features/schedule/presentation/mini_calendar.dart index f9d6a489..8fe42350 100644 --- a/lib/src/features/schedule/presentation/mini_calendar.dart +++ b/lib/src/features/schedule/presentation/mini_calendar.dart @@ -262,9 +262,9 @@ class MiniCalendarGrid extends StatelessWidget { overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ), @@ -736,10 +736,8 @@ class _MiniCalendarHeaderLabel extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final labelStyle = Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - ); + final labelStyle = Theme.of(context).textTheme.labelMedium + ?.copyWith(color: colorScheme.onSurface, fontWeight: FontWeight.w600); final labelWidget = Text( label, textAlign: TextAlign.center, diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 70b5e2f8..a38338ca 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -977,9 +977,8 @@ class _AgendaItemSubtitle extends StatelessWidget { values, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BusyMaxSurfaceColors.of(context).mutedForeground, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: BusyMaxSurfaceColors.of(context).mutedForeground), ); } } diff --git a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart index a698fd62..7d209dc6 100644 --- a/lib/src/features/schedule/presentation/schedule_anchored_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_anchored_popover.dart @@ -7,12 +7,11 @@ import 'package:flutter/services.dart'; import '../../../app/busymax_design.dart'; import '../../../app/busymax_dialogs.dart'; -typedef ScheduleAnchoredPopoverBuilder = - Widget Function( - BuildContext context, - BusyMaxPopoverArrowSide arrowSide, - double arrowAlignment, - ); +typedef ScheduleAnchoredPopoverBuilder = Widget Function( + BuildContext context, + BusyMaxPopoverArrowSide arrowSide, + double arrowAlignment, +); /// Coordinates anchored popovers with schedule-owned native header actions. class ScheduleAnchoredPopoverController { diff --git a/lib/src/features/schedule/presentation/schedule_day_week_view.dart b/lib/src/features/schedule/presentation/schedule_day_week_view.dart index f8e4f452..dcf36217 100644 --- a/lib/src/features/schedule/presentation/schedule_day_week_view.dart +++ b/lib/src/features/schedule/presentation/schedule_day_week_view.dart @@ -1,5 +1,6 @@ import 'package:busymax/src/ui/common/schedule/clock_hours_painter.dart'; import 'package:busymax/src/l10n/time_format_scope.dart'; + import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -173,9 +174,8 @@ class _ScheduleDayWeekViewState extends State { child: Text( context.l10n.allDay, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), fullDayEventsBarDecoration: BoxDecoration( @@ -295,9 +295,8 @@ class _ScheduleDayWeekViewState extends State { textPainterBuilder: (time, defaultColor) => TextPainter( text: TextSpan( text: _formatHour(context, time), - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: defaultColor), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: defaultColor), ), textDirection: Directionality.of(context), textAlign: TextAlign.center, diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index a9001f7f..17af939e 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -135,9 +135,9 @@ class _ScheduleItemDetailsPopoverCard extends StatelessWidget { SizedBox( width: double.infinity, child: BusyMaxPushButton.suggested( - onPressed: () => Navigator.of( - context, - ).pop(ScheduleItemDetailsAction.joinMeeting), + onPressed: () => + Navigator.of(context) + .pop(ScheduleItemDetailsAction.joinMeeting), child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, @@ -155,9 +155,9 @@ class _ScheduleItemDetailsPopoverCard extends StatelessWidget { event.providerEventId != null) ...[ const SizedBox(height: BusyMaxSpacing.xs), BusyMaxPushButton.standard( - onPressed: () => Navigator.of( - context, - ).pop(ScheduleItemDetailsAction.attachments), + onPressed: () => + Navigator.of(context) + .pop(ScheduleItemDetailsAction.attachments), child: Text(context.l10n.attachments), ), ], @@ -395,9 +395,8 @@ class _ScheduleDetailRow extends StatelessWidget { text, maxLines: maxLines, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ), ], @@ -424,9 +423,8 @@ class _ScheduleDetailRichRow extends StatelessWidget { return const SizedBox.shrink(); } final colorScheme = Theme.of(context).colorScheme; - final baseStyle = Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant); + final baseStyle = Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -483,9 +481,8 @@ class _ScheduleLocationDetailRow extends StatelessWidget { builder: (context, constraints) { final locationText = SelectableText( text, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ); if (constraints.maxWidth < 320) { return Column( diff --git a/lib/src/features/schedule/presentation/schedule_item_selection.dart b/lib/src/features/schedule/presentation/schedule_item_selection.dart index 70dc6dc4..8b26aa28 100644 --- a/lib/src/features/schedule/presentation/schedule_item_selection.dart +++ b/lib/src/features/schedule/presentation/schedule_item_selection.dart @@ -2,18 +2,21 @@ import 'package:flutter/material.dart'; import '../../../schedule/schedule_item.dart'; -typedef ScheduleItemSelectionCallback = - void Function( - BuildContext context, - ScheduleItem item, [ - Offset? globalPosition, - ]); - -typedef ScheduleItemTapCallback = - void Function(BuildContext context, [Offset? globalPosition]); - -typedef ScheduleItemAnchorCallback = - void Function(ScheduleItem item, BuildContext context); +typedef ScheduleItemSelectionCallback = void Function( + BuildContext context, + ScheduleItem item, [ + Offset? globalPosition, +]); + +typedef ScheduleItemTapCallback = void Function( + BuildContext context, [ + Offset? globalPosition, +]); + +typedef ScheduleItemAnchorCallback = void Function( + ScheduleItem item, + BuildContext context, +); /// Lets planner chips report their mounted anchors through grouped/all-day /// layouts without threading the callback through each layout delegate. diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index d806b4ed..98ee8b6e 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -17,8 +17,10 @@ import 'schedule_item_chip.dart'; import 'schedule_item_selection.dart'; import 'schedule_more_popover.dart'; -typedef ScheduleDayCreateCallback = - void Function(DateTime day, {BuildContext? anchorContext}); +typedef ScheduleDayCreateCallback = void Function( + DateTime day, { + BuildContext? anchorContext, +}); class ScheduleMonthView extends StatelessWidget { const ScheduleMonthView({ diff --git a/lib/src/features/schedule/presentation/schedule_more_popover.dart b/lib/src/features/schedule/presentation/schedule_more_popover.dart index 7dd45bad..66becccb 100644 --- a/lib/src/features/schedule/presentation/schedule_more_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_more_popover.dart @@ -69,9 +69,8 @@ Future showScheduleMorePopover({ item: item, anchorPoint: globalPosition ?? - scheduleGlobalRectFor( - itemAnchorContext, - )?.center ?? + scheduleGlobalRectFor(itemAnchorContext) + ?.center ?? fallbackAnchorPoint, ), ); diff --git a/lib/src/features/schedule/presentation/schedule_search_result_text.dart b/lib/src/features/schedule/presentation/schedule_search_result_text.dart index 16f863a3..64849c2a 100644 --- a/lib/src/features/schedule/presentation/schedule_search_result_text.dart +++ b/lib/src/features/schedule/presentation/schedule_search_result_text.dart @@ -22,13 +22,11 @@ int compareScheduleSearchResultPresentation( ) { final firstDate = scheduleSearchResultDisplayDate(first); final secondDate = scheduleSearchResultDisplayDate(second); - final dayComparison = _searchResultDay( - firstDate, - ).compareTo(_searchResultDay(secondDate)); + final dayComparison = _searchResultDay(firstDate) + .compareTo(_searchResultDay(secondDate)); if (dayComparison != 0) return dayComparison; - final timeComparison = _searchResultDate( - firstDate, - ).compareTo(_searchResultDate(secondDate)); + final timeComparison = _searchResultDate(firstDate) + .compareTo(_searchResultDate(secondDate)); if (timeComparison != 0) return timeComparison; if (first.kind != second.kind) { return first.kind == ScheduleItemKind.calendarEvent ? -1 : 1; diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index e15b8ab9..c52da2b5 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -1,6 +1,8 @@ import '../../../core/http/request_dispatch_exception.dart'; + import 'dart:async'; import 'dart:math' as math; + import '../../../dav/presentation/nextcloud_collection_dialog.dart'; import '../../../dav/nextcloud/nextcloud_dav_context.dart'; @@ -33,7 +35,9 @@ import '../../sync/sync_auth_error.dart'; import '../../sync/sync_failure_notification_policy.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/domain/task_capabilities.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import 'mini_calendar.dart'; class ScheduleSidebar extends ConsumerWidget { @@ -804,11 +808,10 @@ class _AccountHeaderRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: hasSecondaryLabel - ? Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ) + ? Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ) : Theme.of(context).textTheme.labelLarge, ), ), diff --git a/lib/src/features/schedule/presentation/schedule_toolbar.dart b/lib/src/features/schedule/presentation/schedule_toolbar.dart index 82983a3c..37316aa6 100644 --- a/lib/src/features/schedule/presentation/schedule_toolbar.dart +++ b/lib/src/features/schedule/presentation/schedule_toolbar.dart @@ -151,9 +151,8 @@ class ScheduleToolbar extends StatelessWidget { key: const ValueKey('schedule-header-search-field'), controller: searchController!, focusRequest: searchFocusRequest, - semanticLabel: MaterialLocalizations.of( - context, - ).searchFieldLabel, + semanticLabel: MaterialLocalizations.of(context) + .searchFieldLabel, onChanged: onSearchChanged!, onClear: onClearSearch!, ) diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 3dea0a82..a53c5615 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -39,7 +40,9 @@ import '../../../schedule/schedule_repository.dart'; import '../../../schedule/schedule_scope.dart'; import '../../../schedule/schedule_source_visibility.dart'; import '../../../schedule/schedule_view_mode.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import '../../calendar/presentation/event_editor.dart'; import '../../calendar/presentation/event_editor_draft.dart'; import '../../calendar/presentation/event_guest_delivery_dialog.dart'; @@ -2475,9 +2478,9 @@ class _ScheduleWorkspaceState extends ConsumerState { title: context.l10n.singleOccurrence, subtitle: context.l10n.chooseRecurringEventScope, leading: const Icon(Icons.event_outlined), - onTap: () => Navigator.of( - dialogContext, - ).pop(RecurringEventMutationScope.singleOccurrence), + onTap: () => + Navigator.of(dialogContext) + .pop(RecurringEventMutationScope.singleOccurrence), ), BusyMaxActionRow( title: context.l10n.thisAndFollowingEvents, @@ -2491,17 +2494,17 @@ class _ScheduleWorkspaceState extends ConsumerState { ), enabled: supportsFollowing, onTap: supportsFollowing - ? () => Navigator.of( - dialogContext, - ).pop(RecurringEventMutationScope.thisAndFuture) + ? () => + Navigator.of(dialogContext) + .pop(RecurringEventMutationScope.thisAndFuture) : null, ), BusyMaxActionRow( title: context.l10n.entireSeries, leading: const Icon(Icons.repeat), - onTap: () => Navigator.of( - dialogContext, - ).pop(RecurringEventMutationScope.entireSeries), + onTap: () => + Navigator.of(dialogContext) + .pop(RecurringEventMutationScope.entireSeries), ), ], ), diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 84e33b97..24e5f10b 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -1,10 +1,14 @@ import '../../../l10n/oauth_error_description.dart'; + import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import '../../../l10n/registration_description.dart'; import '../../auth/presentation/registration_setup_dialog.dart'; import '../../../core/auth/oauth_registration.dart'; + import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:busymax/src/l10n/week_preferences_scope.dart'; + import 'dart:async'; import 'package:flutter/material.dart'; @@ -12,6 +16,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:yaru/yaru.dart'; +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:uuid/uuid.dart'; import '../../../app/busymax_about_dialog.dart'; import '../../../app/busymax_yaru_theme.dart'; @@ -25,20 +31,25 @@ import '../../../app/busymax_shortcuts.dart'; import '../../../app/linux/linux_page_frame.dart'; import '../../../app/linux/linux_header_style.dart'; import '../../../core/logging/redacting_logger.dart'; +import '../../../contacts/busymax_contacts_controller.dart'; import '../../../dav/auth/dav_account_dialogs.dart'; import '../../../dav/presentation/nextcloud_collection_dialog.dart'; import '../../../dav/dav_errors.dart'; import '../../../dav/http/dav_http_transport.dart'; import '../../../dav/mutation/dav_conflict_repository.dart'; import '../../../dav/storage/dav_settings_repository.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; + import '../../../l10n/app_locale.dart'; import '../../../l10n/l10n.dart'; import '../../../platform/common/desktop_services.dart'; import '../../../platform/gtk_header_icon_service.dart'; import '../../../webcal/webcal_subscription_service.dart'; import '../../../webcal/webcal_uri.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import '../../accounts/data/accounts_repository.dart'; import '../../accounts/domain/account_connection_state.dart'; import '../../calendar/data/calendar_repository.dart'; @@ -77,6 +88,7 @@ class _SettingsScreenState extends ConsumerState { DavCancellationToken? _davCancellation; final _removingAccountIds = {}; final _busySubscriptionIds = {}; + final _busyContactAccountIds = {}; late final LaunchAtLoginRefreshObserver _autostartRefresh; @override @@ -124,6 +136,12 @@ class _SettingsScreenState extends ConsumerState { : const []; final subscriptions = ref.watch(webCalSubscriptionsProvider).valueOrNull ?? const []; + final contactAccounts = + ref.watch(busyMaxContactAccountsProvider).valueOrNull ?? + const []; + final contactSources = + ref.watch(busyMaxContactSourceSettingsProvider).valueOrNull ?? + const []; final config = ref.watch(buildConfigProvider); final settings = ref.watch(appSettingsControllerProvider); final launchAtLogin = ref.watch(launchAtLoginStateProvider); @@ -186,6 +204,18 @@ class _SettingsScreenState extends ConsumerState { removingAccountIds: _removingAccountIds, onRemoveAccount: (account) => unawaited(_removeAccount(context, ref, account)), + contactAccounts: contactAccounts, + contactSources: contactSources, + busyContactAccountIds: _busyContactAccountIds, + onEnableContacts: (account) => unawaited(_enableContacts(account)), + onDisableContacts: (account) => unawaited(_disableContacts(account.id)), + onSourceEnabled: (sourceKey, enabled) => ref + .read(busyMaxContactsControllerProvider) + .setSourceEnabled(sourceKey, enabled: enabled), + onAddCardDavContacts: () => unawaited(_addCardDavContacts()), + onAddNextcloudContacts: () => unawaited(_addNextcloudContacts()), + onRemoveContactsOnly: (account) => + unawaited(_disableContacts(account.id)), davCollections: davCollections, calendarSources: calendarSources, onCalendarSelected: (source, selected) => unawaited( @@ -581,6 +611,84 @@ class _SettingsScreenState extends ConsumerState { ); } + Future _enableContacts(AccountEntity account) => + _runContactOperation(account.id, () async { + final controller = ref.read(busyMaxContactsControllerProvider); + if (account.provider == BusyProvider.nextcloud) { + await controller.enableLinkedNextcloudContacts(account.id); + } else { + await controller.enableLinkedContacts(account.id); + } + }); + + Future _disableContacts(String id) => _runContactOperation( + id, + () async { + final controller = ref.read(busyMaxContactsControllerProvider); + if (id.startsWith('contacts:')) { + await controller.removeLinkedAccount(id.substring('contacts:'.length)); + } else { + await controller.removeContactsAccount(id); + } + }, + ); + + Future _addCardDavContacts() async { + final input = await showBusyMaxModalDialog<_CardDavContactsInput>( + context, + barrierDismissible: false, + builder: (_) => const _CardDavContactsDialog(), + ); + if (input == null || !mounted) return; + final id = const Uuid().v7(); + await _runContactOperation(id, () async { + await ref + .read(busyMaxContactsControllerProvider) + .addCardDavContactsOnly( + id: id, + label: input.label, + server: input.server, + username: input.username, + password: input.password, + ); + }); + } + + Future _addNextcloudContacts() async { + final server = await showNextcloudServerDialog(context); + if (server == null || !mounted) return; + final id = const Uuid().v7(); + await _runContactOperation(id, () async { + await ref + .read(busyMaxContactsControllerProvider) + .addNextcloudContactsOnly( + id: id, + label: context.l10n.nextcloudProvider, + server: Uri.parse(server), + ); + }); + } + + Future _runContactOperation( + String id, + Future Function() operation, + ) async { + if (_busyContactAccountIds.contains(id)) return; + setState(() => _busyContactAccountIds.add(id)); + try { + await operation(); + } on Object catch (error) { + _settingsLogger.warning( + 'Contacts configuration failed: ${redactForLog(error)}', + ); + if (mounted) { + _showMessage(context, context.l10n.operationFailed); + } + } finally { + if (mounted) setState(() => _busyContactAccountIds.remove(id)); + } + } + Future _addSubscription({String? initialUrl}) async { await showAddCalendarSubscriptionFlow(context, ref, initialUrl: initialUrl); } @@ -807,9 +915,8 @@ class _SettingsScreenState extends ConsumerState { case BusyProvider.google: accountId = (await repository.signIn(request: request)).accountId; case BusyProvider.microsoft: - accountId = (await repository.signInWithMicrosoft( - request: request, - )).accountId; + accountId = (await repository.signInWithMicrosoft(request: request)) + .accountId; case BusyProvider.appleICloud: final cancellation = DavCancellationToken(); _davCancellation = cancellation; @@ -1148,9 +1255,8 @@ class _SettingsScreenState extends ConsumerState { } void _showMessage(BuildContext context, String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); } } @@ -1410,6 +1516,15 @@ class _AccountManagementSection extends StatelessWidget { required this.onMigrate, required this.removingAccountIds, required this.onRemoveAccount, + required this.contactAccounts, + required this.contactSources, + required this.busyContactAccountIds, + required this.onEnableContacts, + required this.onDisableContacts, + required this.onSourceEnabled, + required this.onAddCardDavContacts, + required this.onAddNextcloudContacts, + required this.onRemoveContactsOnly, required this.davCollections, required this.calendarSources, required this.onCalendarSelected, @@ -1444,6 +1559,15 @@ class _AccountManagementSection extends StatelessWidget { final void Function(AccountEntity account) onMigrate; final Set removingAccountIds; final void Function(AccountEntity account) onRemoveAccount; + final List contactAccounts; + final List contactSources; + final Set busyContactAccountIds; + final void Function(AccountEntity account) onEnableContacts; + final void Function(ContactAccount account) onDisableContacts; + final Future Function(String sourceKey, bool enabled) onSourceEnabled; + final VoidCallback onAddCardDavContacts; + final VoidCallback onAddNextcloudContacts; + final void Function(ContactAccount account) onRemoveContactsOnly; final List davCollections; final List calendarSources; final void Function(CalendarSourceEntity source, bool selected) @@ -1538,6 +1662,50 @@ class _AccountManagementSection extends StatelessWidget { onRemoveAccount: () => onRemoveAccount(account), ), subsections: [ + if (account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft || + account.provider == BusyProvider.nextcloud) + _ContactsSettingsCard( + account: account, + contactAccount: contactAccounts + .where( + (candidate) => + candidate.id == + BusyMaxContactsController.linkedContactAccountId( + account.id, + ), + ) + .firstOrNull, + sources: [ + for (final setting in contactSources) + if (setting.source.accountId == + BusyMaxContactsController.linkedContactAccountId( + account.id, + )) + setting, + ], + busy: + busyContactAccountIds.contains(account.id) || + busyContactAccountIds.contains( + BusyMaxContactsController.linkedContactAccountId( + account.id, + ), + ), + onEnableRead: () => onEnableContacts(account), + onDisable: () { + final contact = contactAccounts + .where( + (candidate) => + candidate.id == + BusyMaxContactsController.linkedContactAccountId( + account.id, + ), + ) + .firstOrNull; + if (contact != null) onDisableContacts(contact); + }, + onSourceEnabled: onSourceEnabled, + ), if (account.provider == BusyProvider.google || account.provider == BusyProvider.microsoft) _RegistrationAccountCard( @@ -1624,6 +1792,18 @@ class _AccountManagementSection extends StatelessWidget { ), ], ), + _ContactsOnlySettingsCard( + accounts: [ + for (final account in contactAccounts) + if (!account.id.startsWith('contacts:')) account, + ], + sources: contactSources, + busyAccountIds: busyContactAccountIds, + onAddCardDav: onAddCardDavContacts, + onAddNextcloud: onAddNextcloudContacts, + onRemove: onRemoveContactsOnly, + onSourceEnabled: onSourceEnabled, + ), _CalendarImportCard(onImport: onImportIcs), _CalendarSubscriptionsCard( subscriptions: subscriptions, @@ -1692,6 +1872,215 @@ class _AccountSettingsGroup extends StatelessWidget { } } +class _ContactsSettingsCard extends StatelessWidget { + const _ContactsSettingsCard({ + required this.account, + required this.contactAccount, + required this.sources, + required this.busy, + required this.onEnableRead, + required this.onDisable, + required this.onSourceEnabled, + }); + + final AccountEntity account; + final ContactAccount? contactAccount; + final List sources; + final bool busy; + final VoidCallback onEnableRead; + final VoidCallback onDisable; + final Future Function(String sourceKey, bool enabled) onSourceEnabled; + + @override + Widget build(BuildContext context) { + final contact = contactAccount; + final l10n = context.l10n; + return BusyMaxGroupedList( + title: l10n.contactsTitle, + titleStyle: _settingsAccountSubsectionStyle(context), + description: l10n.contactsLinkedDescription, + filled: true, + children: contact == null + ? [ + BusyMaxActionRow( + key: ValueKey('enable-contacts-read-${account.id}'), + title: l10n.contactsEnableSuggestions, + subtitle: l10n.contactsReadPermissionDescription, + leading: const Icon(YaruIcons.user), + onTap: busy ? null : onEnableRead, + ), + ] + : [ + BusyMaxActionRow( + title: contact.errorCode == null + ? l10n.contactsSuggestionsEnabled + : l10n.contactsNeedsAttention, + subtitle: l10n.contactsReadOnlyUse, + leading: Icon( + contact.errorCode == null + ? YaruIcons.checkmark + : YaruIcons.warning, + ), + ), + BusyMaxActionRow( + key: ValueKey('reconnect-contacts-${account.id}'), + title: l10n.connectAccountAction, + leading: const Icon(YaruIcons.sync), + onTap: busy ? null : onEnableRead, + ), + for (final setting in sources) + _ContactSourceSettingsRow( + setting: setting, + onChanged: (enabled) => + onSourceEnabled(setting.source.key, enabled), + ), + BusyMaxActionRow( + key: ValueKey('disable-contacts-${account.id}'), + title: l10n.contactsDisableForAccount, + leading: Icon( + YaruIcons.trash, + color: Theme.of(context).colorScheme.error, + ), + destructive: true, + onTap: busy ? null : onDisable, + ), + ], + ); + } +} + +class _ContactsOnlySettingsCard extends StatelessWidget { + const _ContactsOnlySettingsCard({ + required this.accounts, + required this.sources, + required this.busyAccountIds, + required this.onAddCardDav, + required this.onAddNextcloud, + required this.onRemove, + required this.onSourceEnabled, + }); + + final List accounts; + final List sources; + final Set busyAccountIds; + final VoidCallback onAddCardDav; + final VoidCallback onAddNextcloud; + final void Function(ContactAccount account) onRemove; + final Future Function(String sourceKey, bool enabled) onSourceEnabled; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BusyMaxGroupedList( + title: l10n.contactsTitle, + titleStyle: _settingsSectionHeaderStyle(context), + description: l10n.contactsOnlyDescription, + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('add-carddav-contacts'), + title: l10n.contactsAddCardDav, + leading: const Icon(YaruIcons.plus), + onTap: onAddCardDav, + ), + BusyMaxActionRow( + key: const ValueKey('add-nextcloud-contacts'), + title: l10n.contactsAddNextcloud, + leading: const Icon(YaruIcons.plus), + onTap: onAddNextcloud, + ), + for (final account in accounts) ...[ + BusyMaxActionRow( + title: account.displayName, + subtitle: l10n.contactsReadOnlyUse, + leading: const Icon(YaruIcons.user), + ), + for (final setting in sources) + if (setting.source.accountId == account.id) + _ContactSourceSettingsRow( + setting: setting, + onChanged: (enabled) => + onSourceEnabled(setting.source.key, enabled), + ), + BusyMaxActionRow( + key: ValueKey('remove-contacts-only-${account.id}'), + title: l10n.removeAction, + leading: Icon( + YaruIcons.trash, + color: Theme.of(context).colorScheme.error, + ), + destructive: true, + onTap: busyAccountIds.contains(account.id) + ? null + : () => onRemove(account), + ), + ], + ], + ); + } +} + +class _ContactSourceSettingsRow extends StatefulWidget { + const _ContactSourceSettingsRow({ + required this.setting, + required this.onChanged, + }); + final BusyMaxContactSourceSetting setting; + final Future Function(bool) onChanged; + @override + State<_ContactSourceSettingsRow> createState() => + _ContactSourceSettingsRowState(); +} + +class _ContactSourceSettingsRowState extends State<_ContactSourceSettingsRow> { + bool _pending = false; + late bool _value = widget.setting.enabled; + String? _error; + @override + void didUpdateWidget(covariant _ContactSourceSettingsRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.setting.enabled != widget.setting.enabled) { + _value = widget.setting.enabled; + } + } + + Future _toggle(bool value) async { + if (_pending) return; + setState(() { + _pending = true; + _error = null; + }); + try { + await widget.onChanged(value); + if (mounted) setState(() => _value = value); + } on Object { + if (mounted) { + setState(() => _error = 'Could not save contact source preference'); + } + } finally { + if (mounted) setState(() => _pending = false); + } + } + + @override + Widget build(BuildContext context) => BusyMaxActionRow( + title: widget.setting.source.name, + subtitle: _error ?? context.l10n.contactsUseForAttendees, + leading: const Icon(YaruIcons.user), + trailing: _pending + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(), + ) + : YaruSwitch( + key: ValueKey('contact-source-${widget.setting.source.key}'), + value: _value, + onChanged: (value) => unawaited(_toggle(value)), + ), + ); +} + TextStyle? _settingsAccountSubsectionStyle(BuildContext context) { final theme = Theme.of(context); return theme.textTheme.bodySmall?.copyWith( @@ -2083,6 +2472,135 @@ String _subscriptionTiming( : context.l10n.subscriptionLastRefresh(_formatDavDateTime(context, last)); } +final class _CardDavContactsInput { + const _CardDavContactsInput({ + required this.label, + required this.server, + required this.username, + required this.password, + }); + + final String label; + final Uri server; + final String username; + final String password; +} + +class _CardDavContactsDialog extends StatefulWidget { + const _CardDavContactsDialog(); + + @override + State<_CardDavContactsDialog> createState() => _CardDavContactsDialogState(); +} + +class _CardDavContactsDialogState extends State<_CardDavContactsDialog> { + final _label = TextEditingController(); + final _server = TextEditingController(); + final _username = TextEditingController(); + final _password = TextEditingController(); + + @override + void initState() { + super.initState(); + for (final controller in [_label, _server, _username, _password]) { + controller.addListener(_changed); + } + } + + @override + void dispose() { + for (final controller in [_label, _server, _username, _password]) { + controller + ..removeListener(_changed) + ..dispose(); + } + super.dispose(); + } + + void _changed() => setState(() {}); + + Uri? get _validServer { + final value = Uri.tryParse(_server.text.trim()); + return value != null && + value.scheme == 'https' && + value.host.isNotEmpty && + value.userInfo.isEmpty && + !value.hasQuery && + !value.hasFragment + ? value + : null; + } + + bool get _valid => + _label.text.trim().isNotEmpty && + _validServer != null && + _username.text.isNotEmpty && + _password.text.isNotEmpty; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BusyMaxDialogShell( + title: l10n.contactsAddCardDav, + maxWidth: BusyMaxSizes.compactDetailsWidth, + // The shared shell names the content list `children`; its action bar is + // intentionally specified afterwards for visual reading order. + // ignore: sort_child_properties_last + children: [ + TextField( + key: const ValueKey('carddav-contacts-label'), + controller: _label, + decoration: InputDecoration(labelText: l10n.subscriptionName), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + key: const ValueKey('carddav-contacts-server'), + controller: _server, + keyboardType: TextInputType.url, + decoration: InputDecoration(labelText: l10n.contactsServerUrlLabel), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + key: const ValueKey('carddav-contacts-username'), + controller: _username, + decoration: InputDecoration(labelText: l10n.contactsUsernameLabel), + ), + const SizedBox(height: BusyMaxSpacing.md), + TextField( + key: const ValueKey('carddav-contacts-password'), + controller: _password, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration(labelText: l10n.contactsPasswordLabel), + ), + const SizedBox(height: BusyMaxSpacing.md), + Text(l10n.contactsReadOnlyUse), + ], + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.l10n.cancel), + ), + BusyMaxPushButton.suggested( + key: const ValueKey('confirm-carddav-contacts'), + onPressed: !_valid + ? null + : () => Navigator.of(context).pop( + _CardDavContactsInput( + label: _label.text.trim(), + server: _validServer!, + username: _username.text, + password: _password.text, + ), + ), + child: Text(l10n.connectAccountAction), + ), + ], + ); + } +} + final class _WebCalAddInput { const _WebCalAddInput({ required this.url, diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart index 09198130..32190ce7 100644 --- a/lib/src/features/sync/account_sync_operations.dart +++ b/lib/src/features/sync/account_sync_operations.dart @@ -2,12 +2,16 @@ import 'dart:async'; import '../../providers/busy_provider.dart'; -typedef AccountSyncAction = - Future Function(String accountId, {required bool full}); +typedef AccountSyncAction = Future Function( + String accountId, { + required bool full, +}); typedef AccountProvider = Future Function(String accountId); typedef IncompleteTaskImportLoader = Future> Function(); -typedef IncompleteTaskImportWriter = - Future Function(String accountId, bool incomplete); +typedef IncompleteTaskImportWriter = Future Function( + String accountId, + bool incomplete, +); abstract interface class AccountSyncOperations { Future syncAccount(String accountId, {required bool full}); diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 7e1c3bc8..65ba601a 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -1,6 +1,8 @@ import 'dart:convert'; + import '../maps/data/location_resolution_repository.dart'; import '../maps/domain/location_result.dart'; + import 'dart:math'; import 'package:drift/drift.dart'; @@ -21,7 +23,9 @@ import '../../microsoft_calendar/microsoft_calendar_errors.dart'; import '../../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../../microsoft_calendar/microsoft_calendar_mapper.dart'; import '../../microsoft_calendar/microsoft_shared_calendar_address.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import '../calendar/data/calendar_repository.dart'; import '../recurrence/domain/event_recurrence_codec.dart'; import '../recurrence/domain/recurrence_rule.dart'; @@ -1151,14 +1155,13 @@ class CalendarPendingOpsReplayer { ifMatch: checkedVersion?.etagOrChangeKey, ); if (request.containsKey('location')) { - await LocationResolutionRepository( - _database, - ).removeGoogleSeriesSourceUnlessLocationMatches( - accountId: _accountId, - sourceId: local.calendarSourceId, - providerSeriesId: masterId, - expectedLocation: request['location']?.toString() ?? '', - ); + await LocationResolutionRepository(_database) + .removeGoogleSeriesSourceUnlessLocationMatches( + accountId: _accountId, + sourceId: local.calendarSourceId, + providerSeriesId: masterId, + expectedLocation: request['location']?.toString() ?? '', + ); } await _markRecurringRowsSynced(op, local); return; @@ -1876,12 +1879,11 @@ class CalendarPendingOpsReplayer { if ((_operationType(op) == 'calendar.delete' || _operationType(op) == 'calendar.remove') && sourceId != null) { - await LocationResolutionRepository( - _database, - ).removeGoogleSeriesSourcesForCalendar( - accountId: op.accountId, - sourceId: sourceId, - ); + await LocationResolutionRepository(_database) + .removeGoogleSeriesSourcesForCalendar( + accountId: op.accountId, + sourceId: sourceId, + ); await (_database.update( _database.calendarSources, )..where((row) => row.id.equals(sourceId))).write( @@ -1903,13 +1905,12 @@ class CalendarPendingOpsReplayer { final recurringEventId = local?.providerRecurringEventId; if (local != null && recurringEventId != null) { if (scope == 'entireSeries') { - await LocationResolutionRepository( - _database, - ).removeGoogleSeriesSource( - accountId: local.accountId, - sourceId: local.calendarSourceId, - providerSeriesId: recurringEventId, - ); + await LocationResolutionRepository(_database) + .removeGoogleSeriesSource( + accountId: local.accountId, + sourceId: local.calendarSourceId, + providerSeriesId: recurringEventId, + ); } await (_database.update(_database.calendarEvents)..where((row) { var predicate = @@ -2831,9 +2832,9 @@ Map _microsoftLocationState(Map raw) { for (final value in raw['locations'] as List? ?? const []) if (value is Map) _microsoftLocation(value), ]..sort( - (first, second) => jsonEncode( - _normalize(first), - ).compareTo(jsonEncode(_normalize(second))), + (first, second) => + jsonEncode(_normalize(first)) + .compareTo(jsonEncode(_normalize(second))), ); return { 'location': raw['location'] is Map diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index cc1edd99..57113f53 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -8,8 +8,10 @@ import '../../google_calendar/google_calendar_errors.dart'; import '../../microsoft_calendar/microsoft_calendar_errors.dart'; import '../../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../../microsoft_calendar/microsoft_shared_calendar_address.dart'; + import 'package:drift/drift.dart'; import 'package:busymax/src/providers/busy_provider.dart'; + import '../accounts/data/accounts_repository.dart'; import '../calendar/data/calendar_repository.dart'; import '../notifications/notification_schedule_service.dart'; @@ -21,13 +23,12 @@ import 'domain_sync_schedule.dart'; // changing token-compatible event-list parameters so old cursors are rebased. const _googleExpandedEventsSyncState = '{"singleEvents":true,"version":1}'; -typedef CalendarMonthRetriever = - Future Function( - String accountId, - BusyProvider provider, - DateTime month, { - required Set sourceIds, - }); +typedef CalendarMonthRetriever = Future Function( + String accountId, + BusyProvider provider, + DateTime month, { + required Set sourceIds, +}); class CalendarSyncEngine { CalendarSyncEngine({ diff --git a/lib/src/features/sync/cloud_calendar_range_coverage_service.dart b/lib/src/features/sync/cloud_calendar_range_coverage_service.dart index 04d092a4..ec0c8190 100644 --- a/lib/src/features/sync/cloud_calendar_range_coverage_service.dart +++ b/lib/src/features/sync/cloud_calendar_range_coverage_service.dart @@ -53,9 +53,8 @@ final class CloudCalendarRangeCoverageService { }) >[]; for (final account in accounts) { - final provider = BusyProviderCodec.parseStorageValue( - account.provider, - ).provider; + final provider = BusyProviderCodec.parseStorageValue(account.provider) + .provider; if (provider != BusyProvider.google && provider != BusyProvider.microsoft) { continue; diff --git a/lib/src/features/sync/collection_id_replacement.dart b/lib/src/features/sync/collection_id_replacement.dart index 83cdffe6..680e6c68 100644 --- a/lib/src/features/sync/collection_id_replacement.dart +++ b/lib/src/features/sync/collection_id_replacement.dart @@ -1,5 +1,7 @@ -typedef CollectionIdReplacement = - Future Function(String oldId, String newId); +typedef CollectionIdReplacement = Future Function( + String oldId, + String newId, +); /// Local preference failures must never retry an already-created collection. Future notifyCollectionIdReplacement( diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 986f2c71..d11f7bbe 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -759,9 +759,8 @@ class PendingOpResolutionService { } Future _requireAccountSyncEligible() async { - final account = await AccountsRepository( - database: _database, - ).accountById(_accountId); + final account = await AccountsRepository(database: _database) + .accountById(_accountId); if (account?.isSyncEligible == true) { return; } diff --git a/lib/src/features/sync/sync_auth_error.dart b/lib/src/features/sync/sync_auth_error.dart index 74b8d441..4cf24796 100644 --- a/lib/src/features/sync/sync_auth_error.dart +++ b/lib/src/features/sync/sync_auth_error.dart @@ -2,8 +2,10 @@ import 'account_sync_operations.dart'; import 'domain_sync_schedule.dart'; import '../../core/logging/redacting_logger.dart'; import '../../core/http/request_dispatch_exception.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:flutter/services.dart'; + import '../connectivity/network_connectivity_service.dart'; const accountReconnectRequiredSyncMessage = diff --git a/lib/src/features/sync/sync_failure_notification_policy.dart b/lib/src/features/sync/sync_failure_notification_policy.dart index 2716ac12..5dd9cc45 100644 --- a/lib/src/features/sync/sync_failure_notification_policy.dart +++ b/lib/src/features/sync/sync_failure_notification_policy.dart @@ -1,5 +1,6 @@ import 'account_sync_operations.dart'; import 'domain_sync_schedule.dart'; + import 'dart:async'; import 'dart:io'; diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index dbc48b1e..873c8022 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import '../../maps/domain/geographic_point.dart'; import '../../maps/domain/location_result.dart'; import '../../maps/data/location_resolution_repository.dart'; @@ -21,7 +22,9 @@ import '../domain/task_remote_client.dart'; import '../domain/task_remote_models.dart'; import '../domain/google_task_assignment_policy.dart'; import '../domain/task_source_links.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import '../../notifications/notification_schedule_service.dart'; class TaskTreeNode { @@ -631,9 +634,9 @@ class TasksRepository { dependsOnOpId: parentCreate?.id, ); if (input.parentTaskId != null) { - final moveCreatedAt = DateTime.parse( - now, - ).add(const Duration(milliseconds: 1)).toIso8601String(); + final moveCreatedAt = DateTime.parse(now) + .add(const Duration(milliseconds: 1)) + .toIso8601String(); await _enqueue( operation: 'move_task', taskListId: taskListId, @@ -1242,9 +1245,9 @@ class TasksRepository { parentUid: parentUid, nowUtc: _nowUtc(), ); - final duplicatedMaster = IcalSemanticDocument.parse( - duplicatedRaw, - ).components.singleWhere((component) => component.recurrenceIdKey == null); + final duplicatedMaster = IcalSemanticDocument.parse(duplicatedRaw) + .components + .singleWhere((component) => component.recurrenceIdKey == null); final sortOrder = nextcloudTaskSortOrder(duplicatedMaster); final localId = 'dav-local-task-${_uuid.v4()}'; final metadata = jsonEncode({ @@ -1422,16 +1425,15 @@ class TasksRepository { final account = await (_database.select( _database.accounts, )..where((row) => row.id.equals(_accountId))).getSingle(); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: _accountId, - collectionId: collectionId, - provider: BusyProviderCodec.requireStorageValue(account.provider), - objectId: objectId, - candidateRawIcs: candidate, - projectedAtUtc: mutationNow, - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: _accountId, + collectionId: collectionId, + provider: BusyProviderCodec.requireStorageValue(account.provider), + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: mutationNow, + ); } }); if (announce) { @@ -1906,16 +1908,15 @@ class TasksRepository { postMovePatch: postMovePatch, dependsOnOperationId: dependency, ); - await DavObjectRepository( - database: _database, - ).projectLocalMutationCandidate( - accountId: _accountId, - collectionId: sourceList.davCollectionId!, - provider: BusyProviderCodec.requireStorageValue(account.provider), - objectId: objectId, - candidateRawIcs: candidateRawIcs!, - projectedAtUtc: _nowUtc(), - ); + await DavObjectRepository(database: _database) + .projectLocalMutationCandidate( + accountId: _accountId, + collectionId: sourceList.davCollectionId!, + provider: BusyProviderCodec.requireStorageValue(account.provider), + objectId: objectId, + candidateRawIcs: candidateRawIcs!, + projectedAtUtc: _nowUtc(), + ); await (_database.update(_database.tasks)..where( (row) => row.accountId.equals(_accountId) & @@ -3395,9 +3396,8 @@ _davNativeTaskFields(String? source) { timeZone: null, ); } - final dateTime = RegExp( - r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$', - ).firstMatch(raw); + final dateTime = RegExp(r'^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$') + .firstMatch(raw); if (dateTime == null) return (dateTime: null, timeZone: null); final normalized = '${dateTime.group(1)}-${dateTime.group(2)}-${dateTime.group(3)}' diff --git a/lib/src/features/tasks/domain/task_mutation_result.dart b/lib/src/features/tasks/domain/task_mutation_result.dart index a8aff645..a75772b6 100644 --- a/lib/src/features/tasks/domain/task_mutation_result.dart +++ b/lib/src/features/tasks/domain/task_mutation_result.dart @@ -35,5 +35,6 @@ class TaskMutationResult { final bool? completed; } -typedef TaskMutationCommittedCallback = - FutureOr Function(TaskMutationResult result); +typedef TaskMutationCommittedCallback = FutureOr Function( + TaskMutationResult result, +); diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 7d606585..fbbd1b7a 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -403,9 +403,8 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { ); } else { pickerContent = ScrollConfiguration( - behavior: ScrollConfiguration.of( - context, - ).copyWith(scrollbars: false), + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: contentHeight), child: SingleChildScrollView(child: content), @@ -1127,9 +1126,8 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { @override Widget build(BuildContext context) { final materialLocalizations = MaterialLocalizations.of(context); - final componentWidth = MediaQuery.textScalerOf( - context, - ).scale(_timePickerInputColumnWidth); + final componentWidth = MediaQuery.textScalerOf(context) + .scale(_timePickerInputColumnWidth); return BusyMaxContentPopoverSurface( arrowSide: widget.arrowSide, arrowAlignment: widget.arrowAlignment, @@ -1256,9 +1254,8 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { ), child: Text( MaterialLocalizations.of(context).invalidTimeLabel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), ), ), const SizedBox(height: BusyMaxSpacing.md), @@ -1321,10 +1318,8 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { ); final dividerColor = surfaceColors.divider; final inputTextStyle = - Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.normal, - height: 1, - ) ?? + Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.normal, height: 1) ?? const TextStyle(fontWeight: FontWeight.normal, height: 1); return FocusTraversalOrder( @@ -1392,9 +1387,8 @@ class _DesktopTimeValueDialogState extends State<_DesktopTimeValueDialog> { cursorColor: Theme.of(context).colorScheme.primary, backgroundCursorColor: surfaceColors.disabledForeground, - selectionColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.28), + selectionColor: Theme.of(context).colorScheme.primary + .withValues(alpha: 0.28), onChanged: (_) => _handleTimeInputChanged(), onSubmitted: (_) => _handleTimeInputChanged(), ), @@ -1589,9 +1583,8 @@ String formatDesktopDate(BuildContext context, String? date) { if (parsed == null) { return ''; } - return DateFormat.yMMMd( - Localizations.localeOf(context).toLanguageTag(), - ).format(parsed); + return DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()) + .format(parsed); } @visibleForTesting diff --git a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart index 6687c261..e946b45d 100644 --- a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart +++ b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; + import 'dart:async'; import 'package:flutter/material.dart'; @@ -343,12 +344,12 @@ class _IcalTaskFieldsEditorState extends ConsumerState { _savedDestination = ExternalLocationDestination.coordinates(point); return; } - LocationDestinationResolver( - ref.read(locationResolutionRepositoryProvider), - ).resolveSaved(location: location, identity: identity).then((destination) { - if (!mounted || generation != _savedDestinationGeneration) return; - setState(() => _savedDestination = destination); - }); + LocationDestinationResolver(ref.read(locationResolutionRepositoryProvider)) + .resolveSaved(location: location, identity: identity) + .then((destination) { + if (!mounted || generation != _savedDestinationGeneration) return; + setState(() => _savedDestination = destination); + }); } Future _openSavedLocation() async { diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 4aa98621..8cd387a8 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -7,8 +7,10 @@ import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; + import '../../accounts/data/accounts_repository.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; @@ -31,8 +33,9 @@ class NewTaskDraft { List get categories => input.categories; } -typedef TaskCategorySuggestionsStreamForAccount = - Stream> Function(String accountId); +typedef TaskCategorySuggestionsStreamForAccount = Stream> Function( + String accountId, +); Future showBusyMaxNewTaskDialog( BuildContext context, { diff --git a/lib/src/features/tasks/presentation/task_details_draft.dart b/lib/src/features/tasks/presentation/task_details_draft.dart index 42985c43..c97bd1d2 100644 --- a/lib/src/features/tasks/presentation/task_details_draft.dart +++ b/lib/src/features/tasks/presentation/task_details_draft.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import '../../../providers/busy_provider.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; import '../../recurrence/domain/recurrence_rule.dart'; @@ -8,7 +9,9 @@ import '../../maps/domain/location_result.dart'; import '../../../core/time/provider_date_time.dart'; import '../../../dav/ical/ical_task_alarm.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; + import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; + import '../data/tasks_repository.dart'; enum TaskScheduleIssue { none, dueBeforeStart, mixedTimeModes } diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 53283e3d..9aafa182 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -1,5 +1,7 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'task_recurrence.dart'; + import 'dart:async'; import 'dart:convert'; @@ -18,7 +20,9 @@ import '../../../l10n/l10n.dart'; import '../../../providers/busy_provider.dart'; import '../../maps/domain/location_result.dart'; import '../../maps/application/external_location_launcher.dart'; + import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; + import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; import '../domain/task_source_links.dart'; @@ -1015,9 +1019,8 @@ class _TaskDetailsEditorState extends State { Expanded( child: Text( message, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: color), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: color), ), ), ], @@ -1944,9 +1947,8 @@ TextStyle? _taskEditorProminentActionStyle( Color? color, FontWeight fontWeight = FontWeight.w600, }) { - return Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: color, fontWeight: fontWeight); + return Theme.of(context).textTheme.labelLarge + ?.copyWith(color: color, fontWeight: fontWeight); } enum _TaskTimeField { due, start, reminder } diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index 9418522a..2ced0fcb 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -8,8 +8,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../l10n/l10n.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; + import '../../accounts/data/accounts_repository.dart'; import '../../schedule/presentation/schedule_item_exporter.dart'; import '../../maps/application/external_location_launcher.dart'; @@ -24,8 +26,9 @@ import 'task_details_editor.dart'; export '../domain/task_mutation_result.dart'; typedef TasksRepositoryForAccount = TasksRepository Function(String accountId); -typedef TaskListsRepositoryForAccount = - TaskListsRepository Function(String accountId); +typedef TaskListsRepositoryForAccount = TaskListsRepository Function( + String accountId, +); class TaskDetailsPane extends ConsumerStatefulWidget { const TaskDetailsPane({ @@ -627,9 +630,8 @@ class _TaskDetailsPaneState extends ConsumerState { ), ); if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.taskDuplicated))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(context.l10n.taskDuplicated))); } on Object catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/src/features/tasks/presentation/task_recurrence.dart b/lib/src/features/tasks/presentation/task_recurrence.dart index 90152544..a7fba20d 100644 --- a/lib/src/features/tasks/presentation/task_recurrence.dart +++ b/lib/src/features/tasks/presentation/task_recurrence.dart @@ -59,9 +59,8 @@ String? encodeSimpleTaskRecurrence( 'pattern': pattern, 'range': { 'type': 'noEnd', - 'startDate': providerWallTimeIso8601String( - recurrenceStart, - ).substring(0, 10), + 'startDate': providerWallTimeIso8601String(recurrenceStart) + .substring(0, 10), }, }); } diff --git a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart index 7855128e..43c62b1b 100644 --- a/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart +++ b/lib/src/features/tasks/presentation/time_zone_selection_dialog.dart @@ -126,9 +126,8 @@ class _BusyMaxTimeZoneSelectionDialogState header: BusyMaxDialogTitleBar( title: Text( l10n.selectTimeZone, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), ), ), children: [ @@ -158,9 +157,9 @@ class _BusyMaxTimeZoneSelectionDialogState textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), ), ) @@ -194,9 +193,9 @@ class _BusyMaxTimeZoneSelectionDialogState size: BusyMaxSizes.iconSm, ) : null, - onTap: () => Navigator.of( - context, - ).pop(result.location.id), + onTap: () => + Navigator.of(context) + .pop(result.location.id), ), ], ), diff --git a/lib/src/features/tray/data/tray_presentation_service.dart b/lib/src/features/tray/data/tray_presentation_service.dart index 414274c3..4a4dfbe9 100644 --- a/lib/src/features/tray/data/tray_presentation_service.dart +++ b/lib/src/features/tray/data/tray_presentation_service.dart @@ -10,10 +10,13 @@ import '../../connectivity/network_connectivity_service.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../domain/tray_presentation.dart'; -typedef TrayTaskListsLoader = - Future> Function(AccountEntity account); -typedef TrayTaskListWriteCheck = - Future Function(AccountEntity account, TaskListEntity taskList); +typedef TrayTaskListsLoader = Future> Function( + AccountEntity account, +); +typedef TrayTaskListWriteCheck = Future Function( + AccountEntity account, + TaskListEntity taskList, +); final class BusyMaxTrayPresentationService { const BusyMaxTrayPresentationService({ diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index 7b5536d8..f0143a4a 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -7,7 +7,9 @@ import '../calendar_providers/calendar_provider_capabilities.dart'; import '../calendar_providers/calendar_sync_dto.dart'; import '../calendar_providers/cloud_calendar_client.dart'; import '../core/http/request_dispatch_exception.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import 'google_calendar_errors.dart'; import 'google_calendar_mapper.dart'; import 'google_calendar_models.dart'; diff --git a/lib/src/google_calendar/google_calendar_mapper.dart b/lib/src/google_calendar/google_calendar_mapper.dart index 95a2f732..7aa0ab74 100644 --- a/lib/src/google_calendar/google_calendar_mapper.dart +++ b/lib/src/google_calendar/google_calendar_mapper.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import '../calendar_providers/calendar_mutation.dart'; import '../calendar_providers/calendar_sync_dto.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; CalendarSourceDto googleCalendarSourceFromJson(Map json) { diff --git a/lib/src/google_tasks/api/google_tasks_api_error.dart b/lib/src/google_tasks/api/google_tasks_api_error.dart index b8bbe27a..a2dc54f5 100644 --- a/lib/src/google_tasks/api/google_tasks_api_error.dart +++ b/lib/src/google_tasks/api/google_tasks_api_error.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import '../../core/http/retry_after.dart'; import '../../core/http/provider_throttle.dart'; diff --git a/lib/src/google_tasks/http/authenticated_http_client.dart b/lib/src/google_tasks/http/authenticated_http_client.dart index 79115ba5..3958508a 100644 --- a/lib/src/google_tasks/http/authenticated_http_client.dart +++ b/lib/src/google_tasks/http/authenticated_http_client.dart @@ -4,7 +4,9 @@ import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import '../../core/logging/redacting_logger.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; + import '../oauth/oauth_service.dart'; class AuthenticatedHttpClient extends http.BaseClient { diff --git a/lib/src/google_tasks/oauth/oauth_loopback_flow.dart b/lib/src/google_tasks/oauth/oauth_loopback_flow.dart index ef246592..6def4fc6 100644 --- a/lib/src/google_tasks/oauth/oauth_loopback_flow.dart +++ b/lib/src/google_tasks/oauth/oauth_loopback_flow.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; + import 'pkce.dart'; import '../../core/auth/authorization_attempt.dart'; @@ -245,9 +246,8 @@ class OAuthLoopbackFlow { String failureMessage, ) async { try { - final launched = await _authorizationLauncher( - authorizationUri, - ).timeout(const Duration(seconds: 20)); + final launched = await _authorizationLauncher(authorizationUri) + .timeout(const Duration(seconds: 20)); _logger.info('OAuth browser launch result: $launched'); if (!launched) { throw OAuthException('OAuthBrowserLaunchFailed', failureMessage); diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index 5845822d..0a15aec6 100644 --- a/lib/src/google_tasks/oauth/oauth_service.dart +++ b/lib/src/google_tasks/oauth/oauth_service.dart @@ -1,7 +1,10 @@ import '../../core/auth/authorization_attempt.dart'; +import '../../core/http/request_dispatch_exception.dart'; import '../../core/http/bounded_http.dart'; import '../../core/http/retry_after.dart'; + import 'package:drift/drift.dart' show BooleanExpressionOperators, Value; + import 'dart:async'; import 'dart:convert'; @@ -18,6 +21,7 @@ import '../../core/logging/redacting_logger.dart'; import '../../providers/busy_provider.dart'; import '../api/google_tasks_api_surface.dart'; import 'oauth_loopback_flow.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; @@ -45,6 +49,20 @@ abstract interface class GoogleConnectionGateway { Future connectGoogle(AuthorizationRequest request); } +const googleContactsReadScope = + 'https://www.googleapis.com/auth/contacts.readonly'; +const googleContactsWriteScope = 'https://www.googleapis.com/auth/contacts'; + +abstract interface class GoogleContactsAuthorization { + Future authorizeGoogleContacts( + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }); + + Future googleContactsAuthorizationHeader(String accountId); +} + abstract interface class GoogleRemovalRevoker { Future revokeSelectedAuthorization( AuthorizationRemovalSnapshot snapshot, @@ -55,6 +73,7 @@ class OAuthService implements OAuthGateway, GoogleConnectionGateway, + GoogleContactsAuthorization, GoogleRemovalRevoker, RegistrationBindingResolver { OAuthService({ @@ -163,7 +182,7 @@ class OAuthService @override Future connectGoogle(AuthorizationRequest request) { final attempt = _attempts.begin(_nowUtc, request.cancellation); - return _connectGoogle(request, attempt).catchError(( + return _connectGoogle(request, attempt, const {}).catchError(( Object error, StackTrace stack, ) { @@ -179,6 +198,7 @@ class OAuthService Future _connectGoogle( AuthorizationRequest request, AuthorizationAttempt attempt, + Set optionalScopes, ) async { final persistence = _persistence; if (persistence == null) { @@ -224,6 +244,15 @@ class OAuthService ); } attempt.check(); + final requestedScopes = { + ...googleBusyMaxOAuthScope.split(' '), + ...optionalScopes, + if (previous != null) + for (final scope in previous.tokenSet.scopes) + if (scope == googleContactsReadScope || + scope == googleContactsWriteScope) + googleContactsReadScope, + }; final result = await _loopbackFlow.start( attempt: attempt, authorizationEndpoint: Uri.https( @@ -231,7 +260,7 @@ class OAuthService '/o/oauth2/v2/auth', ), clientId: registration.clientId, - scope: googleBusyMaxOAuthScope, + scope: requestedScopes.join(' '), extraAuthorizationParameters: const { 'access_type': 'offline', 'prompt': 'consent', @@ -254,6 +283,17 @@ class OAuthService 'Grant both Google Tasks and Google Calendar permissions.', ); } + if (!optionalScopes.every( + (scope) => + tokens.scopes.contains(scope) || + scope == googleContactsReadScope && + tokens.scopes.contains(googleContactsWriteScope), + )) { + throw const OAuthException( + 'OAuthMissingRequiredScope', + 'The requested Google Contacts permission was not granted.', + ); + } attempt.check(); final user = await fetchUserInfo( tokens, @@ -329,6 +369,89 @@ class OAuthService ); } + @override + Future authorizeGoogleContacts( + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) async { + final attempt = _attempts.begin(_nowUtc, cancellation); + GoogleDesktopCredential? current; + const scope = googleContactsReadScope; + try { + current = await attempt.wait(boundCredentialForAccount(accountId)); + if (current == null) { + throw const OAuthException( + 'OAuthAccountMissing', + 'The selected Google account is no longer available.', + ); + } + if (current.tokenSet.scopes.contains(googleContactsWriteScope) || + current.tokenSet.scopes.contains(scope)) { + if (persistContacts != null) { + final persistence = _persistence!; + final generation = current.generation; + await persistence.run(accountId, () async { + attempt.check(); + if (await persistence.generation(accountId) != generation) { + throw const OAuthException( + 'OAuthStaleAuthorization', + 'The account changed while contacts were being enabled.', + ); + } + await persistence.database.transaction(() async { + attempt.check(); + await persistContacts(); + attempt.check(); + }); + }); + } + attempt.committed(); + return; + } + final result = await _connectGoogle( + AuthorizationRequest.reconnect(accountId), + attempt, + {scope}, + ); + await result.commit!(() async { + // Grant metadata and the linked account share the credential commit. + // The controller validates against the accepted grant inside this + // transaction, rather than the parent's previous scope set. + await (_persistence!.database.update( + _persistence.database.accounts, + )..where((row) => row.id.equals(accountId))).write( + AccountsCompanion( + grantedScopes: Value(result.tokenSet.scopes.join(' ')), + ), + ); + await persistContacts?.call(); + }); + } on OAuthException catch (error) { + if (current == null) rethrow; + throw AuthorizationScopedOAuthException( + accountId: accountId, + generation: current.generation, + cause: error, + ); + } finally { + _attempts.finish(attempt); + } + } + + @override + Future googleContactsAuthorizationHeader(String accountId) async { + final tokenSet = await validTokenForAccount(accountId); + if (!tokenSet.scopes.contains(googleContactsReadScope) && + !tokenSet.scopes.contains(googleContactsWriteScope)) { + throw const OAuthException( + 'OAuthContactsConsentRequired', + 'Google Contacts permission must be granted for this account.', + ); + } + return 'Bearer ${tokenSet.accessToken}'; + } + Future _existingBoundCredential(String id) async { final record = await (_persistence?.readCurrentCredential(id) ?? diff --git a/lib/src/ical/ical_import_service.dart b/lib/src/ical/ical_import_service.dart index 5d873cc1..0c0171e4 100644 --- a/lib/src/ical/ical_import_service.dart +++ b/lib/src/ical/ical_import_service.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; + import '../features/maps/domain/location_result.dart'; import '../dav/dav_errors.dart'; @@ -162,9 +163,8 @@ final class IcalImportService { s.capabilities.canCreateEvents || (s.davCollectionId != null && native[s.davCollectionId] != null && - collectionCapabilitiesFromStored( - native[s.davCollectionId]!, - ).canCreateTask), + collectionCapabilitiesFromStored(native[s.davCollectionId]!) + .canCreateTask), ), for (final collection in collections) if (accounts.any((a) => a.id == collection.accountId) && diff --git a/lib/src/l10n/registration_setup_content.dart b/lib/src/l10n/registration_setup_content.dart index 62ca3fee..40884838 100644 --- a/lib/src/l10n/registration_setup_content.dart +++ b/lib/src/l10n/registration_setup_content.dart @@ -159,8 +159,7 @@ extension RegistrationSetupContent on AppLocalizations { values: 'http://localhost', valuesLabel: registrationRedirectUri, linkLabel: registrationOpenEntraAuthentication, - link: - 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade', + link: 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade', ), step( registrationPermissions, @@ -171,8 +170,7 @@ extension RegistrationSetupContent on AppLocalizations { valuesLabel: registrationScopes, copyAll: true, linkLabel: registrationOpenEntraPermissions, - link: - 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade', + link: 'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade', ), step(registrationConnect, registrationGuideMicrosoftConnect), ]; diff --git a/lib/src/l10n/time_format.dart b/lib/src/l10n/time_format.dart index 11c25d61..085711b8 100644 --- a/lib/src/l10n/time_format.dart +++ b/lib/src/l10n/time_format.dart @@ -96,11 +96,12 @@ String normalizeTimeDigits(String input) { ); } -String _normalizeTimeInput(String input) => normalizeTimeDigits(input) - .replaceAll(RegExp('[\u200e\u200f\u061c]'), '') - .replaceAll(RegExp(r'\s|\u00a0|\u202f'), ' ') - .replaceAll(RegExp(' +'), ' ') - .trim(); +String _normalizeTimeInput(String input) => + normalizeTimeDigits(input) + .replaceAll(RegExp('[\u200e\u200f\u061c]'), '') + .replaceAll(RegExp(r'\s|\u00a0|\u202f'), ' ') + .replaceAll(RegExp(' +'), ' ') + .trim(); /// User entry parsing, separate from canonical HH:mm storage parsing. /// Validates the original components before converting a 12-hour value. diff --git a/lib/src/l10n/time_format_scope.dart b/lib/src/l10n/time_format_scope.dart index a09dcd6b..14b8eadc 100644 --- a/lib/src/l10n/time_format_scope.dart +++ b/lib/src/l10n/time_format_scope.dart @@ -43,14 +43,13 @@ String formatClockDateTime( BuildContext context, DateTime value, String dateLabel, -) => BusyMaxTimeFormatScope.of( - context, -).compose(dateLabel, value, context.l10n.dateTimeDisplay); +) => + BusyMaxTimeFormatScope.of(context) + .compose(dateLabel, value, context.l10n.dateTimeDisplay); String formatScheduleBoundary(BuildContext context, int minutes) => - BusyMaxTimeFormatScope.of( - context, - ).scheduleBoundary(minutes, endOfDayLabel: context.l10n.timeEndOfDay); + BusyMaxTimeFormatScope.of(context) + .scheduleBoundary(minutes, endOfDayLabel: context.l10n.timeEndOfDay); /// Measure actual localized labels at the active text scale; shared by ruler /// layout and all gutter-dependent hit/resize positions. diff --git a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart index 7a3ccfec..e60dcb4f 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart @@ -8,9 +8,11 @@ import '../calendar_providers/calendar_mutation.dart'; import '../calendar_providers/calendar_provider_capabilities.dart'; import '../calendar_providers/calendar_sync_dto.dart'; import '../calendar_providers/cloud_calendar_client.dart'; -import '../core/http/request_dispatch_exception.dart'; +import '../core/http/shared_graph_transport.dart'; import '../core/time/provider_date_time.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; + import 'microsoft_calendar_errors.dart'; import 'microsoft_calendar_mapper.dart'; import 'microsoft_calendar_models.dart'; @@ -27,25 +29,60 @@ class MicrosoftCalendarApiClient Future Function()? authorizationHeaderProvider, Future Function()? sharedCalendarAuthorizationHeaderProvider, Future Function()? categoryAuthorizationHeaderProvider, + Future Function(String? claims)? + authorizationHeaderWithClaimsProvider, + Future Function(String? claims)? + sharedCalendarAuthorizationHeaderWithClaimsProvider, + Future Function(String? claims)? + categoryAuthorizationHeaderWithClaimsProvider, Future Function()? unauthorizedRefreshProvider, }) : _httpClient = httpClient, _baseUri = baseUri, _responseTimeZone = responseTimeZone, - _authorizationHeaderProvider = authorizationHeaderProvider, - _sharedCalendarAuthorizationHeaderProvider = - sharedCalendarAuthorizationHeaderProvider, - _categoryAuthorizationHeaderProvider = - categoryAuthorizationHeaderProvider, - _unauthorizedRefreshProvider = unauthorizedRefreshProvider; + _ordinaryGraph = BusyMaxGraphTransport( + httpClient: httpClient, + authorizationHeader: + authorizationHeaderWithClaimsProvider ?? + (_) async => + await authorizationHeaderProvider?.call() ?? + 'Bearer fixture-token', + recoverUnauthorized: unauthorizedRefreshProvider, + maximumResponseBytes: 160 * 1024 * 1024, + ), + _sharedGraph = BusyMaxGraphTransport( + httpClient: httpClient, + authorizationHeader: + sharedCalendarAuthorizationHeaderWithClaimsProvider ?? + authorizationHeaderWithClaimsProvider ?? + (_) async => + await (sharedCalendarAuthorizationHeaderProvider ?? + authorizationHeaderProvider) + ?.call() ?? + 'Bearer fixture-token', + recoverUnauthorized: unauthorizedRefreshProvider, + maximumResponseBytes: 160 * 1024 * 1024, + ), + _categoryGraph = BusyMaxGraphTransport( + httpClient: httpClient, + authorizationHeader: + categoryAuthorizationHeaderWithClaimsProvider ?? + authorizationHeaderWithClaimsProvider ?? + (_) async => + await (categoryAuthorizationHeaderProvider ?? + authorizationHeaderProvider) + ?.call() ?? + 'Bearer fixture-token', + recoverUnauthorized: unauthorizedRefreshProvider, + maximumResponseBytes: 160 * 1024 * 1024, + ); final http.Client _httpClient; final Uri _baseUri; final String _responseTimeZone; final String? accountTenantId; - final Future Function()? _authorizationHeaderProvider; - final Future Function()? _sharedCalendarAuthorizationHeaderProvider; - final Future Function()? _categoryAuthorizationHeaderProvider; - final Future Function()? _unauthorizedRefreshProvider; + final BusyMaxGraphTransport _ordinaryGraph; + final BusyMaxGraphTransport _sharedGraph; + final BusyMaxGraphTransport _categoryGraph; @override BusyProvider get provider => BusyProvider.microsoft; @@ -331,17 +368,11 @@ class MicrosoftCalendarApiClient 'This attachment has no downloadable file content.', ); } - final response = await _send( - 'GET', - _uri( - '${_calendarPath(calendarId)}/events/${_enc(eventId)}' - '/attachments/${_enc(attachment.id)}/\$value', - ), + final uri = _uri( + '${_calendarPath(calendarId)}/events/${_enc(eventId)}' + '/attachments/${_enc(attachment.id)}/\$value', ); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw MicrosoftCalendarApiError.fromResponse(response); - } - return response.bodyBytes; + return _graphFor(uri).download(uri, maximumBytes: 150 * 1024 * 1024); } Future createSmallEventAttachment({ @@ -465,8 +496,9 @@ class MicrosoftCalendarApiClient if (end == bytes.length) { session.finalRangeMayHaveBeenSubmitted = true; } - final response = await _httpClient.put( + final response = await _ordinaryGraph.uploadPreauthorized( session.url, + bytes: bytes.sublist(offset, end), headers: { // This opaque Outlook URL is pre-authenticated. Never include the // Graph bearer token, including when it has a different host. @@ -474,7 +506,6 @@ class MicrosoftCalendarApiClient 'Content-Length': '${end - offset}', 'Content-Range': 'bytes $offset-${end - 1}/${bytes.length}', }, - body: bytes.sublist(offset, end), ); if (end == bytes.length) { if (response.statusCode != 201) { @@ -485,10 +516,7 @@ class MicrosoftCalendarApiClient if (response.statusCode != 200) { throw const MicrosoftAttachmentUploadUncertain(); } - session.update( - (jsonDecode(response.body) as Map).cast(), - bytes.length, - ); + session.update(response.jsonObject(), bytes.length); if (session.nextOffset <= offset) { throw const MicrosoftAttachmentUploadUncertain(); } @@ -916,70 +944,26 @@ class MicrosoftCalendarApiClient String method, Uri uri, { Map? body, - bool retried = false, }) async { _trustedNextLink(uri.toString()); - final authorizationHeaderProvider = - uri.pathSegments.contains('masterCategories') - ? _categoryAuthorizationHeaderProvider ?? _authorizationHeaderProvider - : uri.pathSegments.contains('users') - ? _sharedCalendarAuthorizationHeaderProvider ?? - _authorizationHeaderProvider - : _authorizationHeaderProvider; - String? authorizationHeader; - try { - authorizationHeader = await authorizationHeaderProvider?.call(); - } on Object catch (error, stackTrace) { - Error.throwWithStackTrace( - KnownUnsentRequestException( - kind: RequestPreDispatchFailureKind.authentication, - cause: error, - ), - stackTrace, - ); - } - final headers = { - 'Accept': 'application/json', - 'Prefer': 'outlook.timezone="$_responseTimeZone"', - if (body != null) 'Content-Type': 'application/json', - if (authorizationHeader != null) 'Authorization': authorizationHeader, - }; - final encodedBody = body == null ? null : jsonEncode(body); - final response = switch (method) { - 'GET' => await _httpClient.get(uri, headers: headers), - 'POST' => await _httpClient.post( - uri, - headers: headers, - body: encodedBody, - ), - 'PATCH' => await _httpClient.patch( - uri, - headers: headers, - body: encodedBody, - ), - 'DELETE' => await _httpClient.delete(uri, headers: headers), - _ => throw ArgumentError.value(method, 'method', 'Unsupported method'), - }; - final unauthorizedRefreshProvider = _unauthorizedRefreshProvider; - if (response.statusCode == 401 && - !retried && - unauthorizedRefreshProvider != null) { - try { - await unauthorizedRefreshProvider(); - } on Object catch (error, stackTrace) { - Error.throwWithStackTrace( - KnownUnsentRequestException( - kind: RequestPreDispatchFailureKind.authentication, - cause: error, - ), - stackTrace, - ); - } - return _send(method, uri, body: body, retried: true); - } - return response; + return _graphFor(uri).request( + method, + uri, + body: body, + headers: { + 'Accept': 'application/json', + 'Prefer': 'outlook.timezone="$_responseTimeZone"', + }, + ); } + BusyMaxGraphTransport _graphFor(Uri uri) => + uri.pathSegments.contains('masterCategories') + ? _categoryGraph + : uri.pathSegments.contains('users') + ? _sharedGraph + : _ordinaryGraph; + Uri _uri(String path, {Map? query}) { final basePath = _baseUri.path.endsWith('/') ? _baseUri.path.substring(0, _baseUri.path.length - 1) diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 90a7f9f7..1e5235eb 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -5,6 +5,7 @@ import '../calendar_providers/calendar_sync_dto.dart'; import '../core/time/provider_date_time.dart'; import '../features/maps/domain/geographic_point.dart'; import '../features/maps/domain/location_result.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; Map microsoftStructuredLocation({ diff --git a/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart b/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart index 84bc3013..c1d71ec1 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../../calendar_providers/attachment_upload_session.dart'; -import '../../core/http/request_dispatch_exception.dart'; +import '../../core/http/shared_graph_transport.dart'; import 'microsoft_todo_api_error.dart'; import 'microsoft_todo_api_models.dart'; import 'microsoft_todo_json.dart'; @@ -128,16 +128,23 @@ class MicrosoftTodoRestApiClient required http.Client httpClient, required Uri baseUri, Future Function()? authorizationHeaderProvider, + Future Function(String? claims)? + authorizationHeaderWithClaimsProvider, Future Function()? unauthorizedRefreshProvider, - }) : _httpClient = httpClient, - _baseUri = baseUri, - _authorizationHeaderProvider = authorizationHeaderProvider, - _unauthorizedRefreshProvider = unauthorizedRefreshProvider; + }) : _baseUri = baseUri, + _graph = BusyMaxGraphTransport( + httpClient: httpClient, + authorizationHeader: + authorizationHeaderWithClaimsProvider ?? + (_) async => + await authorizationHeaderProvider?.call() ?? + 'Bearer fixture-token', + recoverUnauthorized: unauthorizedRefreshProvider, + maximumResponseBytes: 32 * 1024 * 1024, + ); - final http.Client _httpClient; final Uri _baseUri; - final Future Function()? _authorizationHeaderProvider; - final Future Function()? _unauthorizedRefreshProvider; + final BusyMaxGraphTransport _graph; @override Future getMe() async { @@ -285,20 +292,10 @@ class MicrosoftTodoRestApiClient required String taskId, required String attachmentId, }) async { - final response = await _send( - 'GET', - _uri( - '${microsoftTaskAttachmentPath(taskListId, taskId, attachmentId)}/\$value', - ), + final uri = _uri( + '${microsoftTaskAttachmentPath(taskListId, taskId, attachmentId)}/\$value', ); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw MicrosoftTodoApiError.fromResponse( - statusCode: response.statusCode, - body: response.body, - headers: response.headers, - ); - } - return response.bodyBytes; + return _graph.download(uri, maximumBytes: 25 * 1024 * 1024); } @override @@ -430,16 +427,15 @@ class MicrosoftTodoRestApiClient if (end == bytes.length) { session.finalRangeMayHaveBeenSubmitted = true; } - final authorization = await _authorizationHeaderProvider?.call(); - final response = await _httpClient.put( + final response = await _graph.request( + 'PUT', contentUri, headers: { - if (authorization != null) 'Authorization': authorization, 'Content-Type': 'application/octet-stream', 'Content-Length': '${end - offset}', 'Content-Range': 'bytes $offset-${end - 1}/${bytes.length}', }, - body: bytes.sublist(offset, end), + bytes: bytes.sublist(offset, end), ); if (end == bytes.length) { if (response.statusCode != 201) { @@ -587,69 +583,8 @@ class MicrosoftTodoRestApiClient Uri uri, { Map? body, }) async { - final encodedBody = body == null ? null : jsonEncode(body); - var response = await _sendOnce( - method, - uri, - body: body, - encodedBody: encodedBody, - ); - final refresh = _unauthorizedRefreshProvider; - if (response.statusCode == 401 && refresh != null) { - try { - await refresh(); - } on Object catch (error, stackTrace) { - Error.throwWithStackTrace( - KnownUnsentRequestException( - kind: RequestPreDispatchFailureKind.authentication, - cause: error, - ), - stackTrace, - ); - } - response = await _sendOnce( - method, - uri, - body: body, - encodedBody: encodedBody, - ); - } - return response; - } - - Future _sendOnce( - String method, - Uri uri, { - Map? body, - String? encodedBody, - }) async { - final headers = {}; - if (body != null) { - headers['Content-Type'] = 'application/json; charset=utf-8'; - } - String? authorizationHeader; - try { - authorizationHeader = await _authorizationHeaderProvider?.call(); - } on Object catch (error, stackTrace) { - Error.throwWithStackTrace( - KnownUnsentRequestException( - kind: RequestPreDispatchFailureKind.authentication, - cause: error, - ), - stackTrace, - ); - } - if (authorizationHeader != null) { - headers['Authorization'] = authorizationHeader; - } - - return switch (method) { - 'DELETE' => _httpClient.delete(uri, headers: headers), - 'GET' => _httpClient.get(uri, headers: headers), - 'PATCH' => _httpClient.patch(uri, headers: headers, body: encodedBody), - 'POST' => _httpClient.post(uri, headers: headers, body: encodedBody), - _ => throw ArgumentError.value(method, 'method'), - }; + _trustedNextLink(uri.toString()); + return _graph.request(method, uri, body: body); } Uri _uriOrFullUrl(String? fullUrl, String path) { diff --git a/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart b/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart index 59d99863..76e55bac 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart @@ -324,9 +324,9 @@ class MicrosoftTodoChecklistItemsPageDto { Map json, ) { return MicrosoftTodoChecklistItemsPageDto( - items: microsoftJsonObjectList( - json['value'], - ).map(MicrosoftTodoChecklistItemDto.fromJson).toList(), + items: microsoftJsonObjectList(json['value']) + .map(MicrosoftTodoChecklistItemDto.fromJson) + .toList(), nextLink: microsoftStringOrNull(json['@odata.nextLink']), rawJson: Map.unmodifiable(json), ); @@ -346,9 +346,9 @@ class MicrosoftTodoTaskListsPageDto { factory MicrosoftTodoTaskListsPageDto.fromJson(Map json) { return MicrosoftTodoTaskListsPageDto( - items: microsoftJsonObjectList( - json['value'], - ).map(MicrosoftTodoTaskListDto.fromJson).toList(), + items: microsoftJsonObjectList(json['value']) + .map(MicrosoftTodoTaskListDto.fromJson) + .toList(), nextLink: microsoftStringOrNull(json['@odata.nextLink']), rawJson: Map.unmodifiable(json), ); @@ -371,9 +371,9 @@ class MicrosoftTodoTaskListsDeltaPageDto extends MicrosoftTodoTaskListsPageDto { Map json, ) { return MicrosoftTodoTaskListsDeltaPageDto( - items: microsoftJsonObjectList( - json['value'], - ).map(MicrosoftTodoTaskListDto.fromJson).toList(), + items: microsoftJsonObjectList(json['value']) + .map(MicrosoftTodoTaskListDto.fromJson) + .toList(), nextLink: microsoftStringOrNull(json['@odata.nextLink']), deltaLink: microsoftStringOrNull(json['@odata.deltaLink']), rawJson: Map.unmodifiable(json), @@ -392,9 +392,9 @@ class MicrosoftTodoTasksPageDto { factory MicrosoftTodoTasksPageDto.fromJson(Map json) { return MicrosoftTodoTasksPageDto( - items: microsoftJsonObjectList( - json['value'], - ).map(MicrosoftTodoTaskDto.fromJson).toList(), + items: microsoftJsonObjectList(json['value']) + .map(MicrosoftTodoTaskDto.fromJson) + .toList(), nextLink: microsoftStringOrNull(json['@odata.nextLink']), rawJson: Map.unmodifiable(json), ); @@ -415,9 +415,9 @@ class MicrosoftTodoTasksDeltaPageDto extends MicrosoftTodoTasksPageDto { factory MicrosoftTodoTasksDeltaPageDto.fromJson(Map json) { return MicrosoftTodoTasksDeltaPageDto( - items: microsoftJsonObjectList( - json['value'], - ).map(MicrosoftTodoTaskDto.fromJson).toList(), + items: microsoftJsonObjectList(json['value']) + .map(MicrosoftTodoTaskDto.fromJson) + .toList(), nextLink: microsoftStringOrNull(json['@odata.nextLink']), deltaLink: microsoftStringOrNull(json['@odata.deltaLink']), rawJson: Map.unmodifiable(json), diff --git a/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart b/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart index 8c6120f4..805f271e 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart @@ -258,8 +258,7 @@ class MicrosoftTodoTaskRemoteClient throw const TaskRemoteError( statusCode: 400, code: 'unsupported_provider_operation', - message: - 'Nested tasks are not supported by Microsoft To Do sync in this version.', + message: 'Nested tasks are not supported by Microsoft To Do sync in this version.', ); } final dto = await _translateMicrosoftErrors( @@ -328,8 +327,7 @@ class MicrosoftTodoTaskRemoteClient throw const TaskRemoteError( statusCode: 400, code: 'unsupported_provider_operation', - message: - 'Moving Microsoft To Do tasks between lists is not supported in this version.', + message: 'Moving Microsoft To Do tasks between lists is not supported in this version.', ); } diff --git a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart index 086589ab..8dbbd50a 100644 --- a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart +++ b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart @@ -2,7 +2,9 @@ import '../../core/auth/authorization_attempt.dart'; import '../../core/http/request_dispatch_exception.dart'; import '../../core/http/bounded_http.dart'; import '../../core/http/retry_after.dart'; -import 'package:drift/drift.dart' show BooleanExpressionOperators; + +import 'package:drift/drift.dart' show BooleanExpressionOperators, Value; + import 'dart:async'; import 'dart:convert'; @@ -10,6 +12,7 @@ import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import '../../config/build_config.dart'; +import '../../db/app_database.dart' show AccountsCompanion; import '../../core/auth/oauth_registration.dart'; import '../../core/auth/registration_staging.dart'; import '../../core/auth/authorization_persistence.dart'; @@ -17,8 +20,10 @@ import '../../core/logging/redacting_logger.dart'; import '../../core/auth/microsoft_graph_scopes.dart'; import '../../google_tasks/oauth/oauth_loopback_flow.dart'; import '../../providers/busy_provider.dart'; + import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; + import '../api/microsoft_todo_api_models.dart'; const microsoftTodoOAuthScopes = @@ -32,6 +37,9 @@ const microsoftSharedCalendarScope = const microsoftCategoryScope = 'https://graph.microsoft.com/MailboxSettings.Read'; +const microsoftContactsReadScope = 'https://graph.microsoft.com/Contacts.Read'; +const microsoftContactsWriteScope = + 'https://graph.microsoft.com/Contacts.ReadWrite'; abstract interface class MicrosoftCategoryAuthorization { Future authorizeCategoryAccess( @@ -47,6 +55,19 @@ abstract interface class MicrosoftSharedCalendarAuthorization { }); } +abstract interface class MicrosoftContactsAuthorization { + Future authorizeMicrosoftContacts( + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }); + + Future microsoftContactsAuthorizationHeader( + String accountId, { + String? claims, + }); +} + const microsoftSignInCallbackNotReceivedMessage = 'Microsoft sign-in callback was not received by BusyMax. Try signing in ' 'again. If the browser opened an old tab, close it and start sign-in ' @@ -72,7 +93,8 @@ class MicrosoftOAuthService MicrosoftConnectionGateway, RegistrationBindingResolver, MicrosoftSharedCalendarAuthorization, - MicrosoftCategoryAuthorization { + MicrosoftCategoryAuthorization, + MicrosoftContactsAuthorization { MicrosoftOAuthService({ required BuildConfig config, required http.Client httpClient, @@ -197,6 +219,18 @@ class MicrosoftOAuthService microsoftCategoryScope, )) microsoftCategoryScope, + if (previous != null && + hasMicrosoftGraphScope( + previous.tokenSet.scopes, + microsoftContactsWriteScope, + )) + microsoftContactsReadScope + else if (previous != null && + hasMicrosoftGraphScope( + previous.tokenSet.scopes, + microsoftContactsReadScope, + )) + microsoftContactsReadScope, ].join(' '); attempt.check(); final result = await _loopbackFlow.start( @@ -222,11 +256,22 @@ class MicrosoftOAuthService cancellation: attempt.cancellation, ); if (!hasMicrosoftGraphScopes(tokens.scopes, { - 'User.Read', - 'Tasks.ReadWrite', - 'Calendars.ReadWrite', - ...optionalScopes, - })) { + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + ...optionalScopes.where( + (scope) => scope != microsoftContactsReadScope, + ), + }) || + optionalScopes.contains(microsoftContactsReadScope) && + !hasMicrosoftGraphScope( + tokens.scopes, + microsoftContactsReadScope, + ) && + !hasMicrosoftGraphScope( + tokens.scopes, + microsoftContactsWriteScope, + )) { throw const OAuthException( 'MicrosoftOAuthMissingRequiredScope', 'Grant Microsoft Tasks and Calendar permissions.', @@ -460,15 +505,23 @@ class MicrosoftOAuthService return tokenSet; } - Future authorizationHeaderForAccount(String accountId) async { - final tokenSet = await validTokenForAccount(accountId); + Future authorizationHeaderForAccount( + String accountId, { + String? claims, + }) async { + final tokenSet = claims == null || claims.isEmpty + ? await validTokenForAccount(accountId) + : await _refreshBound(accountId, claims: claims); return 'Bearer ${tokenSet.accessToken}'; } Future sharedCalendarAuthorizationHeaderForAccount( - String accountId, - ) async { - final tokenSet = await validTokenForAccount(accountId); + String accountId, { + String? claims, + }) async { + final tokenSet = claims == null || claims.isEmpty + ? await validTokenForAccount(accountId) + : await _refreshBound(accountId, claims: claims); if (!hasMicrosoftGraphScope( tokenSet.scopes, microsoftSharedCalendarScope, @@ -481,8 +534,13 @@ class MicrosoftOAuthService return 'Bearer ${tokenSet.accessToken}'; } - Future categoryAuthorizationHeaderForAccount(String accountId) async { - final tokenSet = await validTokenForAccount(accountId); + Future categoryAuthorizationHeaderForAccount( + String accountId, { + String? claims, + }) async { + final tokenSet = claims == null || claims.isEmpty + ? await validTokenForAccount(accountId) + : await _refreshBound(accountId, claims: claims); if (!hasMicrosoftGraphScope(tokenSet.scopes, microsoftCategoryScope)) { throw const OAuthException( 'MicrosoftOAuthCategoryConsentRequired', @@ -502,17 +560,78 @@ class MicrosoftOAuthService String accountId, { AuthorizationCancellation? cancellation, }) => _optionalConsent(accountId, microsoftSharedCalendarScope, cancellation); + @override + Future authorizeMicrosoftContacts( + String accountId, { + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => _optionalConsent( + accountId, + microsoftContactsReadScope, + cancellation, + persistContacts: persistContacts, + ); + + @override + Future microsoftContactsAuthorizationHeader( + String accountId, { + String? claims, + }) async { + final tokenSet = claims == null || claims.isEmpty + ? await validTokenForAccount(accountId) + : await _refreshBound(accountId, claims: claims); + final hasRead = hasMicrosoftGraphScope( + tokenSet.scopes, + microsoftContactsReadScope, + ); + final hasWrite = hasMicrosoftGraphScope( + tokenSet.scopes, + microsoftContactsWriteScope, + ); + if (!hasRead && !hasWrite) { + throw const OAuthException( + 'MicrosoftOAuthContactsConsentRequired', + 'Microsoft Contacts permission must be granted for this account.', + ); + } + return 'Bearer ${tokenSet.accessToken}'; + } + Future _optionalConsent( String id, String scope, - AuthorizationCancellation? cancellation, - ) async { + AuthorizationCancellation? cancellation, { + Future Function()? persistContacts, + }) async { final attempt = _attempts.begin(_nowUtc, cancellation); MicrosoftDesktopCredential? current; try { final active = await attempt.wait(boundCredentialForAccount(id)); current = active; - if (hasMicrosoftGraphScope(active.tokenSet.scopes, scope)) { + if (hasMicrosoftGraphScope(active.tokenSet.scopes, scope) || + scope == microsoftContactsReadScope && + hasMicrosoftGraphScope( + active.tokenSet.scopes, + microsoftContactsWriteScope, + )) { + if (persistContacts != null) { + final persistence = _persistence!; + final generation = active.generation; + await persistence.run(id, () async { + attempt.check(); + if (await persistence.generation(id) != generation) { + throw const OAuthException( + 'OAuthStaleAuthorization', + 'The account changed while contacts were being enabled.', + ); + } + await persistence.database.transaction(() async { + attempt.check(); + await persistContacts(); + attempt.check(); + }); + }); + } attempt.committed(); return; } @@ -521,7 +640,16 @@ class MicrosoftOAuthService optionalScopes: {scope}, capturedAttempt: attempt, ); - await result.commit!(() async {}); + await result.commit!(() async { + await (_persistence!.database.update( + _persistence.database.accounts, + )..where((row) => row.id.equals(id))).write( + AccountsCompanion( + grantedScopes: Value(result.tokenSet.scopes.join(' ')), + ), + ); + await persistContacts?.call(); + }); } on OAuthException catch (error) { if (current == null) rethrow; throw AuthorizationScopedOAuthException( @@ -615,7 +743,7 @@ class MicrosoftOAuthService return operation; } - Future _refreshBound(String id) async { + Future _refreshBound(String id, {String? claims}) async { final current = await boundCredentialForAccount(id); await _persistence!.checkTokenCooldown( id, @@ -627,6 +755,7 @@ class MicrosoftOAuthService refreshed = await refreshToken( current.tokenSet, registration: current.registration, + claims: claims, ); } on OAuthRefreshException catch (error) { await _persistence.recordTokenCooldown(id, current, error, _nowUtc()); @@ -644,6 +773,7 @@ class MicrosoftOAuthService Future refreshToken( OAuthTokenSet current, { MicrosoftPublicRegistration? registration, + String? claims, }) async { final clientId = registration?.clientId ?? _config.microsoftOAuthClientId.trim(); @@ -672,16 +802,9 @@ class MicrosoftOAuthService 'client_id': clientId, 'grant_type': 'refresh_token', 'refresh_token': current.refreshToken!, - 'scope': [ - microsoftTodoOAuthScopes, - if (hasMicrosoftGraphScope( - current.scopes, - microsoftSharedCalendarScope, - )) - microsoftSharedCalendarScope, - if (hasMicrosoftGraphScope(current.scopes, microsoftCategoryScope)) - microsoftCategoryScope, - ].join(' '), + // Omit optional scope to renew the existing grant without requesting + // a contacts write permission or inventing a reduced provider grant. + if (claims != null && claims.isNotEmpty) 'claims': claims, }, ); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -807,9 +930,8 @@ String? microsoftTenantIdFromIdToken(String? idToken, {String? clientId}) { final value = decoded['tid']; if (value is! String) return null; final tenantId = value.toLowerCase(); - return RegExp( - r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', - ).hasMatch(tenantId) + return RegExp(r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$') + .hasMatch(tenantId) ? tenantId : null; } on Object { diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index e0e84310..004f7ffa 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -89,11 +89,10 @@ final class BusyMaxTrayServiceConfiguration { final BusyMaxTrayActions actions; } -typedef BusyMaxTrayMenuUpdater = - Future Function( - DBusMenuItem menu, - BusyMaxTrayMenuPresentation presentation, - ); +typedef BusyMaxTrayMenuUpdater = Future Function( + DBusMenuItem menu, + BusyMaxTrayMenuPresentation presentation, +); final class BusyMaxTrayMenuController { BusyMaxTrayMenuController({ diff --git a/lib/src/platform/gtk_header_icon_service.dart b/lib/src/platform/gtk_header_icon_service.dart index 54e84c27..b707ce36 100644 --- a/lib/src/platform/gtk_header_icon_service.dart +++ b/lib/src/platform/gtk_header_icon_service.dart @@ -111,8 +111,9 @@ final class GtkHeaderIconCatalog { ) => assets[_catalogKey(icon, direction)]; } -typedef GtkHeaderIconBatchLoader = - Future Function(List> requests); +typedef GtkHeaderIconBatchLoader = Future Function( + List> requests, +); /// Cached Linux application-header artwork supplied by GTK's active icon /// theme. A replacement catalog is published only after its full batch loads. @@ -327,8 +328,7 @@ String resolvedNativeHeaderIconName( BuildContext context, BusyMaxLinuxHeaderIcon icon, ) { - final asset = GtkHeaderIconScope.of( - context, - ).catalog.assetFor(icon, Directionality.of(context)); + final asset = GtkHeaderIconScope.of(context).catalog + .assetFor(icon, Directionality.of(context)); return asset?.resolvedName ?? icon.gtkNames.first; } diff --git a/lib/src/schedule/schedule_event_rescheduling.dart b/lib/src/schedule/schedule_event_rescheduling.dart index 4332a861..01816282 100644 --- a/lib/src/schedule/schedule_event_rescheduling.dart +++ b/lib/src/schedule/schedule_event_rescheduling.dart @@ -156,13 +156,13 @@ enum ScheduleRescheduleResult { savedWithSyncFailure, } -typedef ScheduleScopeSelector = - Future Function( - CalendarEventDetail detail, - bool supportsFollowing, - ); -typedef ScheduleGuestSelector = - Future Function(EventEditorDraft draft); +typedef ScheduleScopeSelector = Future Function( + CalendarEventDetail detail, + bool supportsFollowing, +); +typedef ScheduleGuestSelector = Future Function( + EventEditorDraft draft, +); /// The only bridge from pointer proposals to the existing mutation pipeline. /// No store writes or provider requests occur until all decisions are complete. diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 08a36fc2..c03a7399 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/providers/busy_provider.dart'; + import '../features/calendar/domain/event_timing_policy.dart'; import '../features/tasks/domain/task_checklist_item.dart'; import '../features/tasks/domain/task_source_links.dart'; diff --git a/lib/src/schedule/schedule_projection.dart b/lib/src/schedule/schedule_projection.dart index 3d40ab37..ec26a76f 100644 --- a/lib/src/schedule/schedule_projection.dart +++ b/lib/src/schedule/schedule_projection.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:busymax/src/providers/busy_provider.dart'; + import 'schedule_item.dart'; import 'schedule_range.dart'; import 'schedule_scope.dart'; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index bcc6cd6a..f389f9c7 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -304,9 +304,8 @@ class ScheduleRepository { itemFilter: (item) { final start = item.start; return start != null && - ScheduleProjection.day( - start, - ).isBefore(ScheduleProjection.day(before)); + ScheduleProjection.day(start) + .isBefore(ScheduleProjection.day(before)); }, ); } @@ -912,13 +911,11 @@ class ScheduleRepository { notes: task.notes ?? task.bodyContent, isAssigned: provider == BusyProvider.google && - GoogleTaskAssignmentPolicy.fromJson( - task.assignmentInfoJson, - ).isAssigned, + GoogleTaskAssignmentPolicy.fromJson(task.assignmentInfoJson) + .isAssigned, originalTaskUrl: provider == BusyProvider.google - ? GoogleTaskAssignmentPolicy.fromJson( - task.assignmentInfoJson, - ).originalTaskUrl + ? GoogleTaskAssignmentPolicy.fromJson(task.assignmentInfoJson) + .originalTaskUrl : null, sourceLinks: provider == BusyProvider.google ? googleTaskSourceLinks( @@ -1200,9 +1197,8 @@ DateTime? _taskDue(Task task, BusyProvider provider) { final due = _davTaskScheduleTemporal(task, dueOnly: true); if (due != null) return _parseDavTaskTemporal(due); // DAV's legacy dueUtc projection can contain DTSTART when DUE is absent. - if (_jsonMapFromString( - task.providerMetadataJson, - )?.containsKey('nativeStart') == + if (_jsonMapFromString(task.providerMetadataJson) + ?.containsKey('nativeStart') == true) { return null; } diff --git a/lib/src/schedule/schedule_search_match.dart b/lib/src/schedule/schedule_search_match.dart index eae1a5ce..4cae6b26 100644 --- a/lib/src/schedule/schedule_search_match.dart +++ b/lib/src/schedule/schedule_search_match.dart @@ -82,9 +82,8 @@ bool matchesScheduleQuery(ScheduleItem item, String query) { item.accountEmail ?? '', ..._contextFields(item), ].map((value) => value.toLowerCase()).toList(); - return _terms( - query, - ).every((term) => fields.any((field) => field.contains(term))); + return _terms(query) + .every((term) => fields.any((field) => field.contains(term))); } bool matchesTaskCompletion( @@ -123,9 +122,8 @@ bool matchesScheduleFilters( } final personTerms = _terms(filters.person); if (personTerms.isNotEmpty && - !scheduleEventPeople( - item, - ).any((identity) => _matchesPerson(identity, personTerms))) { + !scheduleEventPeople(item) + .any((identity) => _matchesPerson(identity, personTerms))) { return false; } } diff --git a/lib/src/ui/common/schedule/native_collection_export.dart b/lib/src/ui/common/schedule/native_collection_export.dart index 0341547a..7c04aa5b 100644 --- a/lib/src/ui/common/schedule/native_collection_export.dart +++ b/lib/src/ui/common/schedule/native_collection_export.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:file_selector/file_selector.dart'; + import '../../../dav/nextcloud/nextcloud_native_import.dart'; /// Use a new directory and neutral filenames. Never overwrite user files or diff --git a/lib/src/ui/common/schedule/schedule_interactions.dart b/lib/src/ui/common/schedule/schedule_interactions.dart index e2dfddd3..aeaf00c1 100644 --- a/lib/src/ui/common/schedule/schedule_interactions.dart +++ b/lib/src/ui/common/schedule/schedule_interactions.dart @@ -11,17 +11,15 @@ import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; import '../../../schedule/schedule_event_rescheduling.dart'; import '../../../schedule/schedule_item.dart'; -typedef SchedulePreviewBuilder = - Widget Function( - BuildContext context, - ScheduleInterval interval, - bool allDay, - ); -typedef ScheduleRescheduleCallback = - Future Function( - ScheduleRescheduleRequest request, - bool Function() isActive, - ); +typedef SchedulePreviewBuilder = Widget Function( + BuildContext context, + ScheduleInterval interval, + bool allDay, +); +typedef ScheduleRescheduleCallback = Future Function( + ScheduleRescheduleRequest request, + bool Function() isActive, +); final class SchedulePointerTime { const SchedulePointerTime(this.wall, {this.dateOnly = false}); diff --git a/lib/src/ui/common/schedule/schedule_planner_events.dart b/lib/src/ui/common/schedule/schedule_planner_events.dart index 01c90b82..da4a1f85 100644 --- a/lib/src/ui/common/schedule/schedule_planner_events.dart +++ b/lib/src/ui/common/schedule/schedule_planner_events.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; + import 'package:flutter/widgets.dart'; import 'package:infinite_calendar_view/infinite_calendar_view.dart' as icv; diff --git a/lib/src/ui/windows/windows_calendar_activation_flows.dart b/lib/src/ui/windows/windows_calendar_activation_flows.dart index a5033bd9..b5491589 100644 --- a/lib/src/ui/windows/windows_calendar_activation_flows.dart +++ b/lib/src/ui/windows/windows_calendar_activation_flows.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import '../../providers/busy_provider.dart'; import 'package:file_selector/file_selector.dart'; diff --git a/lib/src/ui/windows/windows_contacts_settings.dart b/lib/src/ui/windows/windows_contacts_settings.dart new file mode 100644 index 00000000..514d21c2 --- /dev/null +++ b/lib/src/ui/windows/windows_contacts_settings.dart @@ -0,0 +1,317 @@ +import '../../l10n/l10n.dart'; + +import 'dart:async'; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../providers/busy_provider.dart'; + +import 'package:fluent_ui/fluent_ui.dart'; + +class WindowsContactsSettings extends ConsumerStatefulWidget { + const WindowsContactsSettings({super.key}); + @override + ConsumerState createState() => + _WindowsContactsSettingsState(); +} + +class _WindowsContactsSettingsState + extends ConsumerState { + bool _pending = false; + String? _error; + Future _run(Future Function() action) async { + if (_pending) return; + setState(() { + _pending = true; + _error = null; + }); + try { + await action(); + } on Object catch (error) { + if (mounted) { + setState( + () => _error = error is ContactsException + ? error.code + : context.l10n.operationFailed, + ); + } + } finally { + if (mounted) setState(() => _pending = false); + } + } + + Widget _action(String label, Future Function() action) => Button( + onPressed: _pending ? null : () => unawaited(_run(action)), + child: Text(label), + ); + + Future _add(bool nextcloud) async { + final input = await showDialog<_DavInput>( + context: context, + builder: (_) => _DavDialog(nextcloud: nextcloud), + ); + if (!mounted || input == null) return; + { + final controller = ref.read(busyMaxContactsControllerProvider); + final id = const Uuid().v7(); + if (nextcloud) { + await controller.addNextcloudContactsOnly( + id: id, + label: input.label, + server: Uri.parse(input.server), + ); + } else { + await controller.addCardDavContactsOnly( + id: id, + label: input.label, + server: Uri.parse(input.server), + username: input.username, + password: input.password, + ); + } + } + } + + Future _reconnect(ContactAccount account) async { + final controller = ref.read(busyMaxContactsControllerProvider); + final parent = await controller.store.linkedBusyMaxAccount(account.id); + if (parent != null || + account.provider != ContactProviderKind.carddav || + await controller.isIndependentNextcloud(account.id)) { + await controller.reconnectContactsAccount(account.id); + return; + } + if (!mounted) return; + final password = await showDialog( + context: context, + builder: (_) => const _DavReconnectDialog(), + ); + if (!mounted || password == null) return; + await controller.reconnectDavContactsOnly(account.id, password: password); + } + + @override + Widget build(BuildContext context) { + final parents = + ref.watch(accountManagementStreamProvider).valueOrNull ?? const []; + final accounts = + ref.watch(busyMaxContactAccountsProvider).valueOrNull ?? + const []; + final sources = + ref.watch(busyMaxContactSourceSettingsProvider).valueOrNull ?? const []; + final controller = ref.read(busyMaxContactsControllerProvider); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.contactsTitle), + if (accounts.isEmpty) Text(context.l10n.contactsLinkedDescription), + for (final parent in parents.where( + (p) => [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + ].contains(p.provider), + )) ...[ + Text('${context.l10n.contactsTitle}: ${parent.displayLabel}'), + Text(context.l10n.contactsReadPermissionDescription), + _action( + context.l10n.contactsEnableSuggestions, + () => parent.provider == BusyProvider.nextcloud + ? controller.enableLinkedNextcloudContacts(parent.id) + : controller.enableLinkedContacts(parent.id), + ), + ], + Wrap( + spacing: 8, + children: [ + _action(context.l10n.contactsAddCardDav, () => _add(false)), + _action(context.l10n.contactsAddNextcloud, () => _add(true)), + ], + ), + for (final account in accounts) ...[ + Text( + '${account.displayName} · ${context.l10n.contactsReadOnlyUse} · ${account.enabled ? context.l10n.contactsSuggestionsEnabled : context.l10n.off}', + ), + if (account.errorCode != null) Text(account.errorCode!), + Wrap( + spacing: 8, + children: [ + _action( + context.l10n.connectAccountAction, + () => _reconnect(account), + ), + _action( + context.l10n.contactsDisableForAccount, + () => controller.disableContactsAccount(account.id), + ), + _action( + context.l10n.removeAction, + () => controller.removeContactsAccount(account.id), + ), + ], + ), + ], + for (final setting in sources) + ToggleSwitch( + key: ValueKey('contacts-source:${setting.source.key}'), + checked: setting.enabled, + content: Text(setting.source.name), + onChanged: _pending + ? null + : (enabled) => unawaited( + _run( + () => controller.setSourceEnabled( + setting.source.key, + enabled: enabled, + ), + ), + ), + ), + if (_pending) const ProgressRing(), + if (_error != null) Text(_error!), + ], + ), + ), + ); + } +} + +final class _DavInput { + const _DavInput(this.label, this.server, this.username, this.password); + final String label, server, username, password; +} + +class _DavDialog extends StatefulWidget { + const _DavDialog({required this.nextcloud}); + final bool nextcloud; + @override + State<_DavDialog> createState() => _DavDialogState(); +} + +class _DavDialogState extends State<_DavDialog> { + final _label = TextEditingController(); + final _server = TextEditingController(); + final _username = TextEditingController(); + final _password = TextEditingController(); + String? _error; + @override + void dispose() { + _label.dispose(); + _server.dispose(); + _username.dispose(); + _password.dispose(); + super.dispose(); + } + + void _submit() { + final uri = Uri.tryParse(_server.text.trim()); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + _label.text.trim().isEmpty || + !widget.nextcloud && + (_username.text.isEmpty || _password.text.isEmpty)) { + setState(() => _error = context.l10n.contactsReadPermissionDescription); + return; + } + Navigator.pop( + context, + _DavInput( + _label.text.trim(), + uri.toString(), + _username.text, + _password.text, + ), + ); + } + + @override + Widget build(BuildContext context) => ContentDialog( + title: Text( + widget.nextcloud + ? context.l10n.contactsAddNextcloud + : context.l10n.contactsAddCardDav, + ), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InfoLabel( + label: context.l10n.subscriptionName, + child: TextBox(controller: _label, obscureText: false), + ), + InfoLabel( + label: context.l10n.contactsServerUrlLabel, + child: TextBox(controller: _server, obscureText: false), + ), + if (!widget.nextcloud) + InfoLabel( + label: context.l10n.contactsUsernameLabel, + child: TextBox(controller: _username, obscureText: false), + ), + if (!widget.nextcloud) + InfoLabel( + label: context.l10n.contactsPasswordLabel, + child: TextBox(controller: _password, obscureText: true), + ), + Text(context.l10n.contactsReadOnlyUse), + if (_error != null) Text(_error!), + ], + ), + ), + actions: [ + Button( + child: Text(context.l10n.cancel), + onPressed: () => Navigator.pop(context), + ), + FilledButton( + onPressed: _submit, + child: Text(context.l10n.connectAccountAction), + ), + ], + ); +} + +class _DavReconnectDialog extends StatefulWidget { + const _DavReconnectDialog(); + @override + State<_DavReconnectDialog> createState() => _DavReconnectDialogState(); +} + +class _DavReconnectDialogState extends State<_DavReconnectDialog> { + final _password = TextEditingController(); + @override + void dispose() { + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ContentDialog( + title: Text(context.l10n.connectAccountAction), + content: TextBox( + controller: _password, + obscureText: true, + placeholder: context.l10n.contactsPasswordLabel, + ), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + Button( + onPressed: () { + if (_password.text.isNotEmpty) Navigator.pop(context, _password.text); + }, + child: Text(context.l10n.connectAccountAction), + ), + ], + ); +} diff --git a/lib/src/ui/windows/windows_desktop_runtime.dart b/lib/src/ui/windows/windows_desktop_runtime.dart index bdf1c100..a3ab6282 100644 --- a/lib/src/ui/windows/windows_desktop_runtime.dart +++ b/lib/src/ui/windows/windows_desktop_runtime.dart @@ -1,8 +1,10 @@ import '../../schedule/schedule_commands.dart'; + import 'dart:async'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../l10n/time_format_scope.dart'; import '../../../l10n/generated/app_localizations.dart'; diff --git a/lib/src/ui/windows/windows_event_attachments_dialog.dart b/lib/src/ui/windows/windows_event_attachments_dialog.dart index f746b81e..3240a42e 100644 --- a/lib/src/ui/windows/windows_event_attachments_dialog.dart +++ b/lib/src/ui/windows/windows_event_attachments_dialog.dart @@ -330,10 +330,12 @@ class _WindowsEventAttachmentsDialogState contentType: file.mimeType ?? 'application/octet-stream', bytes: await file.readAsBytes(), ); + if (!mounted) return; _refresh(); } on Object catch (error) { + if (!mounted) return; _refresh(); - if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + setState(() => _error = context.l10n.exportFailed('$error')); } finally { if (mounted) setState(() => _saving = false); } diff --git a/lib/src/ui/windows/windows_event_editor_dialog.dart b/lib/src/ui/windows/windows_event_editor_dialog.dart index 5f615e5f..6c1f134a 100644 --- a/lib/src/ui/windows/windows_event_editor_dialog.dart +++ b/lib/src/ui/windows/windows_event_editor_dialog.dart @@ -1,13 +1,16 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'dart:async'; import 'windows_time_picker.dart'; import '../../features/calendar/domain/event_property_policy.dart'; + import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_bootstrap.dart'; +import '../../contacts/busymax_contacts_controller.dart'; import '../../calendar_providers/calendar_mutation.dart'; import '../../calendar_providers/calendar_provider_capabilities.dart'; import '../../features/accounts/data/accounts_repository.dart'; @@ -208,6 +211,9 @@ Future showWindowsEventEditorDialog( final initialShowAs = showAs; final initialVisibility = visibility; String? guestError; + List guestSuggestions = const []; + var guestSuggestionGeneration = 0; + StreamSubscription? contactsChanges; var saving = false; var saved = false; var allowPop = false; @@ -247,6 +253,15 @@ Future showWindowsEventEditorDialog( builder: (dialogContext) => EditorStateBuilder( textControllers: [title, description, location, guestEmail, categories], builder: (context, setState) { + contactsChanges ??= ref + .read(busyMaxContactsControllerProvider) + .changes + .listen((_) { + guestSuggestionGeneration++; + if (dialogContext.mounted) { + setState(() => guestSuggestions = const []); + } + }); final l10n = AppLocalizations.of(context); final validEnd = EventEditorDraft.newEvent( @@ -326,6 +341,8 @@ Future showWindowsEventEditorDialog( eventLabelId = null; eventLabelChanged = true; } + guestSuggestionGeneration++; + guestSuggestions = const []; selectedSource = source; // Destination conversion belongs to the shared save // policy. Keep the editable data intact while browsing. @@ -841,9 +858,8 @@ Future showWindowsEventEditorDialog( children: [ for (final category in catalog) ToggleButton( - checked: _categories( - categories.text, - ).contains(category.displayName), + checked: _categories(categories.text) + .contains(category.displayName), onChanged: saving ? null : (checked) { @@ -1300,8 +1316,52 @@ Future showWindowsEventEditorDialog( controller: guestEmail, label: l10n.addGuest, enabled: !saving, - onChanged: () => - setState(() => guestError = null), + onChanged: () { + setState(() => guestError = null); + final query = guestEmail.text.trim(); + final generation = ++guestSuggestionGeneration; + final accountId = selectedSource.accountId; + final sourceId = selectedSource.id; + if (query.isEmpty) { + setState(() => guestSuggestions = const []); + return; + } + unawaited(() async { + try { + final suggestions = await ref + .read(busyMaxContactsControllerProvider) + .suggestAttendees( + query, + busyMaxAccountId: accountId, + excludedAddresses: { + for (final attendee in attendees) + attendee.email, + }, + ); + if (!dialogContext.mounted || + generation != + guestSuggestionGeneration || + selectedSource.accountId != accountId || + selectedSource.id != sourceId || + guestEmail.text.trim() != query) { + return; + } + setState( + () => guestSuggestions = suggestions, + ); + } on Object { + if (!dialogContext.mounted || + generation != + guestSuggestionGeneration || + selectedSource.accountId != accountId || + selectedSource.id != sourceId || + guestEmail.text.trim() != query) { + return; + } + setState(() => guestSuggestions = const []); + } + }()); + }, onAdd: () { final email = guestEmail.text.trim(); if (!_looksLikeEmail(email)) { @@ -1324,9 +1384,96 @@ Future showWindowsEventEditorDialog( attendeesChanged = true; }); } + guestSuggestionGeneration++; guestEmail.clear(); + setState(() => guestSuggestions = const []); }, ), + for (final suggestion in guestSuggestions) + Button( + key: ValueKey( + 'windows-guest-contact-${suggestion.email}', + ), + onPressed: saving + ? null + : () async { + if (!guestSuggestions.contains( + suggestion, + )) { + return; + } + final capturedGeneration = + guestSuggestionGeneration; + final capturedAccount = + selectedSource.accountId; + final capturedSource = selectedSource.id; + try { + if (!await ref + .read( + busyMaxContactsControllerProvider, + ) + .suggestionEligible( + suggestion, + busyMaxAccountId: capturedAccount, + )) { + return; + } + } on Object { + return; + } + if (!dialogContext.mounted || + capturedGeneration != + guestSuggestionGeneration || + selectedSource.accountId != + capturedAccount || + selectedSource.id != capturedSource || + !guestSuggestions.contains( + suggestion, + ) || + !(originalDraft?.canManageAttendees ?? + true) || + saving || + selectedSource.provider == + BusyProvider.nextcloud && + selectedSource + .davEffectivePermissions['canInvite'] == + false) { + return; + } + guestSuggestionGeneration++; + if (!attendees.any( + (item) => + item.email.toLowerCase() == + suggestion.email.toLowerCase(), + )) { + setState(() { + attendees = [ + ...attendees, + EventAttendeeDraft( + email: suggestion.email, + displayName: + suggestion.displayName.isEmpty + ? null + : suggestion.displayName, + ), + ]; + attendeesChanged = true; + }); + } + guestEmail.clear(); + setState( + () => guestSuggestions = const [], + ); + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + suggestion.displayName.isEmpty + ? suggestion.email + : '${suggestion.displayName} · ${suggestion.email}', + ), + ), + ), if (guestError != null) InfoBar( title: Text(guestError!), @@ -1766,6 +1913,21 @@ Future showWindowsEventEditorDialog( }, ), ); + guestSuggestionGeneration++; + final cancellation = contactsChanges?.cancel(); + if (cancellation != null) { + unawaited( + cancellation.catchError((Object error, StackTrace stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stack, + library: 'contacts suggestions', + ), + ); + }), + ); + } consentCancellation?.cancel(); title.dispose(); description.dispose(); diff --git a/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart b/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart index d06ac8e8..7c616df7 100644 --- a/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart +++ b/lib/src/ui/windows/windows_keyboard_shortcuts_dialog.dart @@ -95,9 +95,9 @@ class _ShortcutGroup extends StatelessWidget { ), decoration: BoxDecoration( border: Border.all( - color: FluentTheme.of( - context, - ).resources.controlStrokeColorDefault, + color: FluentTheme.of(context) + .resources + .controlStrokeColorDefault, ), borderRadius: BorderRadius.circular(4), ), diff --git a/lib/src/ui/windows/windows_nextcloud_dialogs.dart b/lib/src/ui/windows/windows_nextcloud_dialogs.dart index 979a6837..44ad0da6 100644 --- a/lib/src/ui/windows/windows_nextcloud_dialogs.dart +++ b/lib/src/ui/windows/windows_nextcloud_dialogs.dart @@ -1,7 +1,10 @@ import 'dart:async'; + import '../common/schedule/native_collection_export.dart'; import 'windows_nextcloud_scheduling_dialog.dart'; + import 'dart:math' as math; + import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; diff --git a/lib/src/ui/windows/windows_nextcloud_scheduling_dialog.dart b/lib/src/ui/windows/windows_nextcloud_scheduling_dialog.dart index 47b83146..a91904f2 100644 --- a/lib/src/ui/windows/windows_nextcloud_scheduling_dialog.dart +++ b/lib/src/ui/windows/windows_nextcloud_scheduling_dialog.dart @@ -1,6 +1,8 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; + import 'dart:async'; import 'dart:math' as math; + import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; diff --git a/lib/src/ui/windows/windows_recurrence_dialog.dart b/lib/src/ui/windows/windows_recurrence_dialog.dart index 8904de94..d578495e 100644 --- a/lib/src/ui/windows/windows_recurrence_dialog.dart +++ b/lib/src/ui/windows/windows_recurrence_dialog.dart @@ -268,9 +268,8 @@ String _rfcWeekday(int weekday) => rfcWeekdays[weekday - 1]; String _weekdayLabel(BuildContext context, String weekday) { final day = rfcWeekdays.indexOf(weekday) + 1; final date = DateTime(2024, 1, day); - return DateFormat.E( - Localizations.localeOf(context).toLanguageTag(), - ).format(date); + return DateFormat.E(Localizations.localeOf(context).toLanguageTag()) + .format(date); } String _dateValue(DateTime date) => diff --git a/lib/src/ui/windows/windows_registration_setup_dialog.dart b/lib/src/ui/windows/windows_registration_setup_dialog.dart index 03087e7d..71265134 100644 --- a/lib/src/ui/windows/windows_registration_setup_dialog.dart +++ b/lib/src/ui/windows/windows_registration_setup_dialog.dart @@ -2,6 +2,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; + import '../../app/busymax_shortcuts.dart'; import '../../app/app_bootstrap.dart'; import '../../core/auth/oauth_registration.dart'; diff --git a/lib/src/ui/windows/windows_schedule_day_week_view.dart b/lib/src/ui/windows/windows_schedule_day_week_view.dart index 09b51a7e..f7050379 100644 --- a/lib/src/ui/windows/windows_schedule_day_week_view.dart +++ b/lib/src/ui/windows/windows_schedule_day_week_view.dart @@ -1,5 +1,6 @@ import 'package:busymax/src/ui/common/schedule/clock_hours_painter.dart'; import 'package:busymax/src/l10n/time_format_scope.dart'; + import 'dart:math' as math; import 'dart:ui' as ui; diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index 99f2b07f..352c1d2b 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -1,7 +1,10 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:busymax/src/l10n/week_preferences_scope.dart'; + import 'dart:async'; + import '../../providers/busy_provider.dart'; + import 'dart:math' as math; import 'package:fluent_ui/fluent_ui.dart'; @@ -2372,9 +2375,8 @@ class WindowsScheduleMonthView extends StatelessWidget { } return ScheduleInteractionRegion( onReschedule: onReschedule, - previewColor: FluentTheme.of( - context, - ).accentColor.defaultBrushFor(FluentTheme.of(context).brightness), + previewColor: FluentTheme.of(context).accentColor + .defaultBrushFor(FluentTheme.of(context).brightness), previewBuilder: (context, interval, allDay) => Text( schedulePreviewLabel(context, interval, allDay), style: FluentTheme.of(context).typography.caption, @@ -2444,9 +2446,8 @@ class WindowsScheduleMonthView extends StatelessWidget { style: day.month == selectedDate.month ? null : TextStyle( - color: FluentTheme.of( - context, - ).inactiveColor, + color: FluentTheme.of(context) + .inactiveColor, ), ), ), @@ -2643,9 +2644,8 @@ class WindowsScheduleYearView extends StatelessWidget { color: FluentTheme.of(context) .accentColor .defaultBrushFor( - FluentTheme.of( - context, - ).brightness, + FluentTheme.of(context) + .brightness, ), width: selected ? 2 : 1, ) diff --git a/lib/src/ui/windows/windows_schedule_source_pane.dart b/lib/src/ui/windows/windows_schedule_source_pane.dart index 793633a5..43f453ce 100644 --- a/lib/src/ui/windows/windows_schedule_source_pane.dart +++ b/lib/src/ui/windows/windows_schedule_source_pane.dart @@ -1,4 +1,5 @@ import 'dart:async'; + import 'windows_nextcloud_dialogs.dart'; import '../../dav/nextcloud/nextcloud_dav_context.dart'; @@ -975,9 +976,8 @@ class _MiniMonth extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E( - locale, - ).format(weekdayBase.add(Duration(days: index))), + DateFormat.E(locale) + .format(weekdayBase.add(Duration(days: index))), maxLines: 1, style: FluentTheme.of(context).typography.caption, ), diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart index b79e214f..1d8f8035 100644 --- a/lib/src/ui/windows/windows_settings_page.dart +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -1,3 +1,4 @@ +import 'windows_contacts_settings.dart'; import '../../l10n/oauth_error_description.dart'; import '../../core/auth/oauth_models.dart'; import '../../core/auth/authorization_attempt.dart'; @@ -5,14 +6,18 @@ import '../../l10n/registration_description.dart'; import 'windows_registration_setup_dialog.dart'; import '../../core/auth/oauth_registration.dart'; import 'windows_time_picker.dart'; + import 'dart:async'; + import 'windows_nextcloud_dialogs.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; + import 'windows_workspace_shell.dart'; + import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -601,6 +606,7 @@ class _WindowsSettingsPageState extends ConsumerState { ), ), const SizedBox(height: 20), + const WindowsContactsSettings(), KeyedSubtree(key: _accountsKey, child: _SectionTitle(l10n.accounts)), Card( child: accounts.when( @@ -996,9 +1002,8 @@ class _WindowsAccountCalendars extends StatelessWidget { header: true, child: Text( l10n.calendars, - style: FluentTheme.of( - context, - ).typography.caption?.copyWith(fontWeight: FontWeight.w600), + style: FluentTheme.of(context).typography.caption + ?.copyWith(fontWeight: FontWeight.w600), ), ), const SizedBox(height: 8), @@ -1037,9 +1042,8 @@ class _WindowsCalendarSettingsColumnHeader extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - final style = FluentTheme.of( - context, - ).typography.caption?.copyWith(fontWeight: FontWeight.w600); + final style = FluentTheme.of(context).typography.caption + ?.copyWith(fontWeight: FontWeight.w600); return Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: Row( diff --git a/lib/src/ui/windows/windows_task_details_dialog.dart b/lib/src/ui/windows/windows_task_details_dialog.dart index dd08dd85..e5ca3a8f 100644 --- a/lib/src/ui/windows/windows_task_details_dialog.dart +++ b/lib/src/ui/windows/windows_task_details_dialog.dart @@ -1,7 +1,11 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'windows_time_picker.dart'; + import 'package:busymax/src/l10n/time_format_scope.dart'; + import '../../features/tasks/presentation/task_recurrence.dart'; + import 'dart:async'; import 'dart:convert'; @@ -534,9 +538,8 @@ Future showWindowsTaskDetailsDialog( microsoftTaskAttachmentsProvider(key), ); attachmentsFuture = ref.read( - microsoftTaskAttachmentsProvider( - key, - ).future, + microsoftTaskAttachmentsProvider(key) + .future, ); } on Object catch (failure) { error = l10n.exportFailed('$failure'); @@ -621,9 +624,8 @@ Future showWindowsTaskDetailsDialog( microsoftTaskAttachmentsProvider(key), ); attachmentsFuture = ref.read( - microsoftTaskAttachmentsProvider( - key, - ).future, + microsoftTaskAttachmentsProvider(key) + .future, ); } on Object catch (failure) { error = l10n.exportFailed('$failure'); @@ -668,9 +670,8 @@ Future showWindowsTaskDetailsDialog( microsoftTaskAttachmentsProvider(key), ); attachmentsFuture = ref.read( - microsoftTaskAttachmentsProvider( - key, - ).future, + microsoftTaskAttachmentsProvider(key) + .future, ); } on Object catch (failure) { error = l10n.exportFailed('$failure'); @@ -732,9 +733,8 @@ Future showWindowsTaskDetailsDialog( microsoftTaskAttachmentsProvider(key), ); final future = ref.read( - microsoftTaskAttachmentsProvider( - key, - ).future, + microsoftTaskAttachmentsProvider(key) + .future, ); setState(() { attachmentsFuture = future; @@ -1087,9 +1087,8 @@ Future showWindowsTaskDetailsDialog( children: [ for (final category in catalog) ToggleButton( - checked: _categories( - categories.text, - ).contains(category.displayName), + checked: _categories(categories.text) + .contains(category.displayName), onChanged: !enabled ? null : (selected) { diff --git a/lib/src/ui/windows/windows_task_editor_dialog.dart b/lib/src/ui/windows/windows_task_editor_dialog.dart index 4a2c9c23..e6597a53 100644 --- a/lib/src/ui/windows/windows_task_editor_dialog.dart +++ b/lib/src/ui/windows/windows_task_editor_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'windows_time_picker.dart'; + import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/lib/src/ui/windows/windows_tasks_page.dart b/lib/src/ui/windows/windows_tasks_page.dart index 499af6b2..0c8ebbce 100644 --- a/lib/src/ui/windows/windows_tasks_page.dart +++ b/lib/src/ui/windows/windows_tasks_page.dart @@ -554,9 +554,8 @@ class _WindowsTasksPageState extends ConsumerState { ? TextDecoration.lineThrough : null, color: displayedCompleted - ? FluentTheme.of( - context, - ).inactiveColor + ? FluentTheme.of(context) + .inactiveColor : null, ), child: Text(task.title), diff --git a/lib/src/ui/windows/windows_time_zone_dialog.dart b/lib/src/ui/windows/windows_time_zone_dialog.dart index c18eb0a9..e8f33a53 100644 --- a/lib/src/ui/windows/windows_time_zone_dialog.dart +++ b/lib/src/ui/windows/windows_time_zone_dialog.dart @@ -34,9 +34,10 @@ Future showWindowsTimeZoneDialog( child: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.search)), ), onChanged: (query) => setState( - () => results = BusyMaxTimeZoneCatalog.search( - query, - ).take(100).toList(), + () => + results = BusyMaxTimeZoneCatalog.search(query) + .take(100) + .toList(), ), ), const SizedBox(height: 12), diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart index ab6c98dd..f6fbe93a 100644 --- a/lib/src/webcal/webcal_subscription_service.dart +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; + import '../features/maps/data/location_resolution_repository.dart'; + import 'dart:math' as math; import 'package:crypto/crypto.dart'; diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 5aa5a689..1aa08248 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -96,6 +96,11 @@ + + +

Adds synchronized read-only contacts and attendee suggestions on Linux, Windows, and Android using shared contacts, DAV, and Graph integrations.

+
+

Beta maintenance release.

diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt index afb53edc..f508ae18 100644 --- a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -38,6 +38,7 @@ import com.microsoft.identity.client.IAuthenticationResult import com.microsoft.identity.client.IMultipleAccountPublicClientApplication import com.microsoft.identity.client.Prompt import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.claims.ClaimsRequest import com.microsoft.identity.client.exception.MsalClientException import com.microsoft.identity.client.exception.MsalException import com.microsoft.identity.client.exception.MsalServiceException @@ -511,8 +512,12 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, try { val account = application.accounts.firstOrNull { it.id == nativeId } ?: throw MicrosoftAccountMissingException() - val parameters = AcquireTokenSilentParameters.Builder() - .withScopes(scopes).forAccount(account).fromAuthority(selectedAuthority ?: account.authority).build() + val parametersBuilder = AcquireTokenSilentParameters.Builder() + .withScopes(scopes).forAccount(account).fromAuthority(selectedAuthority ?: account.authority) + call.argument("claims")?.takeIf { it.isNotBlank() }?.let { + parametersBuilder.withClaims(ClaimsRequest.getClaimsRequestFromJsonString(it)) + } + val parameters = parametersBuilder.build() postSuccess(result, msalResult(application.acquireTokenSilent(parameters))) } catch (error: Exception) { val failure = classifyMicrosoftSilentFailure(error) diff --git a/packages/busymax_android_platform/lib/busymax_android_platform.dart b/packages/busymax_android_platform/lib/busymax_android_platform.dart index 1bd3eb58..363a8af2 100644 --- a/packages/busymax_android_platform/lib/busymax_android_platform.dart +++ b/packages/busymax_android_platform/lib/busymax_android_platform.dart @@ -94,6 +94,7 @@ final class BusyMaxAndroidPlatform { String? authorityTenant, String? nativeAccountId, String? authority, + String? claims, }) async => AndroidAuthorizationToken.fromMap( _stringMap( await _methodChannel.invokeMethod('authorizeMicrosoftSilent', { @@ -103,6 +104,7 @@ final class BusyMaxAndroidPlatform { 'authorityTenant': ?authorityTenant, 'nativeAccountId': ?nativeAccountId, 'authority': ?authority, + 'claims': ?claims, }), ), ); diff --git a/pubspec.lock b/pubspec.lock index f6d290d9..a3029273 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "55dd9e9cb83f8b62aa5dd8744d3bfedf79d33f9457c1684916c2b5c69c6e3a9b" + url: "https://pub.dev" + source: hosted + version: "1.0.8" _fe_analyzer_shared: dependency: transitive description: @@ -128,6 +136,27 @@ packages: relative: true source: path version: "0.1.0" + busystack_contacts: + dependency: "direct main" + description: + path: "../busystack_contacts" + relative: true + source: path + version: "0.1.0" + busystack_dav: + dependency: "direct main" + description: + path: "../busystack_dav" + relative: true + source: path + version: "0.1.0" + busystack_graph: + dependency: "direct main" + description: + path: "../busystack_graph" + relative: true + source: path + version: "0.1.0" characters: dependency: transitive description: @@ -580,6 +609,14 @@ packages: url: "https://pub.dev" source: hosted version: "17.5.0" + googleapis: + dependency: transitive + description: + name: googleapis + sha256: d38bb64016ef27eca7fde992994b9d2999bcea307685807e15d6a08bff05dc61 + url: "https://pub.dev" + source: hosted + version: "17.0.0" graphs: dependency: transitive description: @@ -640,10 +677,10 @@ packages: dependency: "direct dev" description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.10.1" infinite_calendar_view: dependency: "direct main" description: @@ -1424,10 +1461,10 @@ packages: dependency: "direct main" description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: @@ -1476,5 +1513,5 @@ packages: source: hosted version: "0.1.3" sdks: - dart: ">=3.12.0 <4.0.0" + dart: ">=3.13.4 <4.0.0" flutter: ">=3.47.5" diff --git a/pubspec.yaml b/pubspec.yaml index f78be3b3..f9393789 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,14 +1,20 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.2.4 +version: 0.3.0 environment: - sdk: ^3.12.0 + sdk: ^3.13.4 dependencies: flutter: sdk: flutter + busystack_contacts: + path: ../busystack_contacts + busystack_dav: + path: ../busystack_dav + busystack_graph: + path: ../busystack_graph flutter_localizations: sdk: flutter collection: ^1.19.0 @@ -41,9 +47,9 @@ dependencies: tray_manager: 0.5.3 ubuntu_localizations: ^0.5.2+3 url_launcher: ^6.3.0 - uuid: ^4.5.0 + uuid: 4.6.0 xdg_status_notifier_item: ^0.0.1 - xml: 6.6.1 + xml: 7.0.1 yaru: ^10.2.0 workmanager: ^0.10.10 busymax_android_platform: @@ -54,7 +60,7 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter - image: 4.8.0 + image: 4.10.1 build_runner: ^2.15.0 drift_dev: ^2.33.0 flutter_lints: ^6.0.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index edb59b17..9c1daf43 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.2.4" +version: "0.3.0" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index fd4e79d6..bd7c86fe 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -335,9 +335,8 @@ void main() { } test('about links match the BusyStack product metadata', () { - final source = File( - 'lib/src/app/busymax_about_dialog.dart', - ).readAsStringSync(); + final source = File('lib/src/app/busymax_about_dialog.dart') + .readAsStringSync(); expect(source, contains('https://busystack.org')); expect(source, contains('https://github.com/busystack/busymax')); @@ -345,9 +344,8 @@ void main() { }); test('about dialog uses the shared Flutter modal and close adapter', () { - final source = File( - 'lib/src/app/busymax_about_dialog.dart', - ).readAsStringSync(); + final source = File('lib/src/app/busymax_about_dialog.dart') + .readAsStringSync(); final dialogs = File('lib/src/app/busymax_dialogs.dart').readAsStringSync(); expect(source, contains('showBusyMaxModalDialog')); @@ -361,9 +359,8 @@ void main() { }); test('about logo renders the PNG asset, not the launcher SVG', () { - final source = File( - 'lib/src/app/busymax_about_dialog.dart', - ).readAsStringSync(); + final source = File('lib/src/app/busymax_about_dialog.dart') + .readAsStringSync(); final pubspec = File('pubspec.yaml').readAsStringSync(); expect(source, contains('Image.asset')); diff --git a/test/app/app_bootstrap_provider_test.dart b/test/app/app_bootstrap_provider_test.dart index 4aba61e1..aef5aa93 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -257,9 +257,8 @@ void main() { ), ); - final account = await AccountsRepository( - database: database, - ).accountById('account-1'); + final account = await AccountsRepository(database: database) + .accountById('account-1'); expect(operations.accountCalls, 1); expect(account?.authState, accountAuthStateReauthRequired); expect( @@ -293,9 +292,8 @@ void main() { test('signed-in sync runner skips a reconnect-required account', () async { final database = AppDatabase(NativeDatabase.memory()); await _seedSignedInGoogleAccount(database); - await AccountsRepository( - database: database, - ).markReconnectRequired('account-1'); + await AccountsRepository(database: database) + .markReconnectRequired('account-1'); final operations = _RecordingAccountSyncOperations(); final container = ProviderContainer( overrides: [ @@ -328,9 +326,8 @@ void main() { await database.close(); }); final runSync = container.read(signedInSyncRunnerProvider); - await AccountsRepository( - database: database, - ).markReconnectRequired('account-1'); + await AccountsRepository(database: database) + .markReconnectRequired('account-1'); await runSync('account-1', false); @@ -453,9 +450,8 @@ void main() { ), ); - final account = await AccountsRepository( - database: database, - ).accountById('account-1'); + final account = await AccountsRepository(database: database) + .accountById('account-1'); expect(account?.authState, accountAuthStateReauthRequired); expect(oAuth.activeId, 'account-1'); }); diff --git a/test/app/app_settings_controller_test.dart b/test/app/app_settings_controller_test.dart index 46ac1a3e..327fc4bd 100644 --- a/test/app/app_settings_controller_test.dart +++ b/test/app/app_settings_controller_test.dart @@ -154,99 +154,96 @@ void main() { }, ); - test( - 'sidebar movement is adjacent, queued, isolated and restart persistent', - () async { - final store = MemorySettingsStore(); - final controller = AppSettingsController(store); - addTearDown(controller.dispose); - await controller.ready; - for (final section in SidebarOrderSection.values) { - final accountId = - section == SidebarOrderSection.calendars || - section == SidebarOrderSection.taskLists - ? 'account' - : null; - await controller.registerSidebarIds(section, [ - 'a', - 'b', - 'c', - ], accountId: accountId); - final first = controller.moveSidebarItem(section, 'c', -1, [ - 'a', - 'b', - 'c', - ], accountId: accountId); - expect( - controller.state.sidebarOrder.sequence(section, accountId: accountId), - ['a', 'c', 'b'], - ); - final second = controller.moveSidebarItem(section, 'c', -1, [ - 'a', - 'b', - 'c', - ], accountId: accountId); - await Future.wait([first, second]); - await controller.registerSidebarIds(section, [ - 'b', - 'a', - 'c', - 'd', - ], accountId: accountId); - await controller.moveSidebarItem(section, 'c', -1, [ - 'a', - 'b', - 'c', - 'd', - ], accountId: accountId); - await controller.moveSidebarItem(section, 'd', 1, [ - 'a', - 'b', - 'c', - 'd', - ], accountId: accountId); - expect( - controller.state.sidebarOrder.sequence(section, accountId: accountId), - ['c', 'a', 'b', 'd'], - ); - // Absent rows are omitted; the next present row is the adjacent sibling. - await controller.moveSidebarItem(section, 'c', 1, [ - 'c', - 'b', - ], accountId: accountId); - expect( - controller.state.sidebarOrder.apply( - section, - ['d', 'b', 'c'], - (id) => id, - accountId: accountId, - ), - ['b', 'c', 'd'], - ); - } - await controller.registerSidebarIds(SidebarOrderSection.taskLists, [ + test('sidebar movement is adjacent, queued, isolated and restart persistent', () async { + final store = MemorySettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + await controller.ready; + for (final section in SidebarOrderSection.values) { + final accountId = + section == SidebarOrderSection.calendars || + section == SidebarOrderSection.taskLists + ? 'account' + : null; + await controller.registerSidebarIds(section, [ + 'a', 'b', + 'c', + ], accountId: accountId); + final first = controller.moveSidebarItem(section, 'c', -1, [ 'a', - ], accountId: 'other'); - expect(controller.state.sidebarOrder.taskListIdsByAccount['other'], [ 'b', + 'c', + ], accountId: accountId); + expect( + controller.state.sidebarOrder.sequence(section, accountId: accountId), + ['a', 'c', 'b'], + ); + final second = controller.moveSidebarItem(section, 'c', -1, [ 'a', - ]); - final saveCount = store.saves; - await controller.registerSidebarIds(SidebarOrderSection.taskLists, [ + 'b', + 'c', + ], accountId: accountId); + await Future.wait([first, second]); + await controller.registerSidebarIds(section, [ + 'b', + 'a', + 'c', + 'd', + ], accountId: accountId); + await controller.moveSidebarItem(section, 'c', -1, [ + 'a', + 'b', + 'c', + 'd', + ], accountId: accountId); + await controller.moveSidebarItem(section, 'd', 1, [ 'a', 'b', - ], accountId: 'other'); - expect(store.saves, saveCount); - final restarted = AppSettingsController(store); - addTearDown(restarted.dispose); - await restarted.ready; + 'c', + 'd', + ], accountId: accountId); expect( - restarted.state.sidebarOrder.toJson(), - controller.state.sidebarOrder.toJson(), + controller.state.sidebarOrder.sequence(section, accountId: accountId), + ['c', 'a', 'b', 'd'], ); - }, - ); + // Absent rows are omitted; the next present row is the adjacent sibling. + await controller.moveSidebarItem(section, 'c', 1, [ + 'c', + 'b', + ], accountId: accountId); + expect( + controller.state.sidebarOrder.apply( + section, + ['d', 'b', 'c'], + (id) => id, + accountId: accountId, + ), + ['b', 'c', 'd'], + ); + } + await controller.registerSidebarIds(SidebarOrderSection.taskLists, [ + 'b', + 'a', + ], accountId: 'other'); + expect(controller.state.sidebarOrder.taskListIdsByAccount['other'], [ + 'b', + 'a', + ]); + final saveCount = store.saves; + await controller.registerSidebarIds(SidebarOrderSection.taskLists, [ + 'a', + 'b', + ], accountId: 'other'); + expect(store.saves, saveCount); + final restarted = AppSettingsController(store); + addTearDown(restarted.dispose); + await restarted.ready; + expect( + restarted.state.sidebarOrder.toJson(), + controller.state.sidebarOrder.toJson(), + ); + }); test( 'ID replacement retains temporary position and removes server duplicates', diff --git a/test/app/app_settings_test.dart b/test/app/app_settings_test.dart index ffa306e1..38f51fa8 100644 --- a/test/app/app_settings_test.dart +++ b/test/app/app_settings_test.dart @@ -68,9 +68,8 @@ void main() { } for (final value in [null, 'unknown', 12]) { expect( - AppSettings.fromJson({ - 'timeFormatPreference': value, - }).timeFormatPreference, + AppSettings.fromJson({'timeFormatPreference': value}) + .timeFormatPreference, BusyMaxTimeFormatPreference.system, ); } @@ -93,9 +92,8 @@ void main() { } for (final invalid in [null, 'Monday', 'unknown', 1]) { expect( - AppSettings.fromJson({ - 'firstDayOfWeekPreference': invalid, - }).firstDayOfWeekPreference, + AppSettings.fromJson({'firstDayOfWeekPreference': invalid}) + .firstDayOfWeekPreference, BusyMaxFirstDayOfWeekPreference.system, ); } diff --git a/test/app/busymax_button_style_test.dart b/test/app/busymax_button_style_test.dart index 72492b2c..59c89660 100644 --- a/test/app/busymax_button_style_test.dart +++ b/test/app/busymax_button_style_test.dart @@ -260,9 +260,8 @@ void main() { await mouse.addPointer(location: Offset.zero); await mouse.moveTo(tester.getCenter(standard)); await tester.pumpAndSettle(); - final colors = _theme( - brightness: Brightness.light, - ).extension()!; + final colors = _theme(brightness: Brightness.light) + .extension()!; expect(_buttonMaterial(tester, standard).color, colors.controlHover); await mouse.down(tester.getCenter(standard)); @@ -467,9 +466,8 @@ void main() { MaterialApp( theme: _theme(brightness: Brightness.light), builder: (context, child) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: const TextScaler.linear(2)), + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(2)), child: Directionality( textDirection: TextDirection.rtl, child: child!, diff --git a/test/app/busymax_dialogs_test.dart b/test/app/busymax_dialogs_test.dart index bee43c1e..7c5fd076 100644 --- a/test/app/busymax_dialogs_test.dart +++ b/test/app/busymax_dialogs_test.dart @@ -237,9 +237,8 @@ void main() { localizedTestApp( child: Builder( builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: const TextScaler.linear(2)), + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(2)), child: const BusyMaxConfirmDialog( title: 'Discard all unsaved changes?', message: message, diff --git a/test/app/busymax_grouped_surface_test.dart b/test/app/busymax_grouped_surface_test.dart index 483d7976..621359d1 100644 --- a/test/app/busymax_grouped_surface_test.dart +++ b/test/app/busymax_grouped_surface_test.dart @@ -279,9 +279,9 @@ void main() { isEmpty, ); expect( - DefaultTextStyle.of( - tester.element(find.text('Personal account')), - ).style.color, + DefaultTextStyle.of(tester.element(find.text('Personal account'))) + .style + .color, colors.mutedForeground, ); expect( @@ -391,9 +391,9 @@ void main() { ); expect( - DefaultTextStyle.of( - tester.element(find.text('Account unavailable')), - ).style.color, + DefaultTextStyle.of(tester.element(find.text('Account unavailable'))) + .style + .color, colors.disabledForeground, ); }); @@ -453,9 +453,9 @@ void main() { colors.disabledForeground, ); expect( - DefaultTextStyle.of( - tester.element(find.text('Disabled text action')), - ).style.color, + DefaultTextStyle.of(tester.element(find.text('Disabled text action'))) + .style + .color, colors.disabledForeground, ); expect( diff --git a/test/app/busymax_search_field_test.dart b/test/app/busymax_search_field_test.dart index 0ca80b60..6375b0cf 100644 --- a/test/app/busymax_search_field_test.dart +++ b/test/app/busymax_search_field_test.dart @@ -87,9 +87,8 @@ void main() { final toolbar = File( 'lib/src/features/schedule/presentation/schedule_toolbar.dart', ).readAsStringSync(); - final linuxHeader = File( - 'lib/src/app/linux/linux_header_style.dart', - ).readAsStringSync(); + final linuxHeader = File('lib/src/app/linux/linux_header_style.dart') + .readAsStringSync(); expect(design, contains('class BusyMaxSearchField')); expect(RegExp(r'YaruSearchField\(').allMatches(design), hasLength(1)); diff --git a/test/app/caldav_release_metadata_test.dart b/test/app/caldav_release_metadata_test.dart index fe61ce41..12bea3a5 100644 --- a/test/app/caldav_release_metadata_test.dart +++ b/test/app/caldav_release_metadata_test.dart @@ -8,13 +8,11 @@ void main() { () { final pubspec = File('pubspec.yaml').readAsStringSync(); final snap = File('snap/snapcraft.yaml').readAsStringSync(); - final metainfo = File( - 'linux/io.busystack.busymax.metainfo.xml', - ).readAsStringSync(); + final metainfo = File('linux/io.busystack.busymax.metainfo.xml') + .readAsStringSync(); final windowsRunner = File('windows/runner/Runner.rc').readAsStringSync(); - final androidBuild = File( - 'android/app/build.gradle.kts', - ).readAsStringSync(); + final androidBuild = File('android/app/build.gradle.kts') + .readAsStringSync(); final pubspecVersion = _capture( pubspec, @@ -29,12 +27,12 @@ void main() { RegExp(r' { @override Widget build(BuildContext context) { return MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: disableAnimations), + data: MediaQuery.of(context) + .copyWith(disableAnimations: disableAnimations), child: LinuxPageFrame( header: Builder( builder: (context) => SizedBox( diff --git a/test/app/linux_window_host_test.dart b/test/app/linux_window_host_test.dart index 70725bc3..1c71b4b3 100644 --- a/test/app/linux_window_host_test.dart +++ b/test/app/linux_window_host_test.dart @@ -664,9 +664,8 @@ class _HostHarnessState extends State<_HostHarness> { theme: ThemeData(brightness: dark ? Brightness.dark : Brightness.light), home: Builder( builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(disableAnimations: disableAnimations), + data: MediaQuery.of(context) + .copyWith(disableAnimations: disableAnimations), child: LinuxApplicationMediaQuery( alwaysUse24HourFormat: true, gtkAnimationsEnabled: gtkAnimationsEnabled, diff --git a/test/app/localization_audit_test.dart b/test/app/localization_audit_test.dart index 47e6e35b..a6939ae7 100644 --- a/test/app/localization_audit_test.dart +++ b/test/app/localization_audit_test.dart @@ -191,12 +191,10 @@ void main() { }); test('package metadata uses reviewed product wording in every locale', () { - final desktop = File( - 'linux/io.busystack.busymax.desktop', - ).readAsStringSync(); - final metainfo = File( - 'linux/io.busystack.busymax.metainfo.xml', - ).readAsStringSync(); + final desktop = File('linux/io.busystack.busymax.desktop') + .readAsStringSync(); + final metainfo = File('linux/io.busystack.busymax.metainfo.xml') + .readAsStringSync(); const summaries = { 'ar': 'مدير التقويم والمهام', @@ -278,9 +276,9 @@ void main() { expect(portuguese.deleteTask, 'Eliminar tarefa'); expect(portuguese.copyAndDelete, 'Copiar e eliminar'); - final portugueseArb = File( - 'lib/l10n/app_pt_PT.arb', - ).readAsStringSync().toLowerCase(); + final portugueseArb = File('lib/l10n/app_pt_PT.arb') + .readAsStringSync() + .toLowerCase(); for (final brazilianForm in [ 'excluído', 'excluir', @@ -1342,9 +1340,8 @@ void main() { ); } expect( - lookupAppLocalizations( - const Locale('en'), - ).repeatYearlyMonthValue('Sep', 'sep'), + lookupAppLocalizations(const Locale('en')) + .repeatYearlyMonthValue('Sep', 'sep'), 'Sep', ); }); @@ -1455,9 +1452,8 @@ Iterable _metadataTranslationFailures() sync* { RegExp(r'app_([A-Za-z_]+)\.arb$').firstMatch(file.path)!.group(1)!, ]..sort(); final desktop = File('linux/io.busystack.busymax.desktop').readAsStringSync(); - final metainfo = File( - 'linux/io.busystack.busymax.metainfo.xml', - ).readAsStringSync(); + final metainfo = File('linux/io.busystack.busymax.metainfo.xml') + .readAsStringSync(); final snap = File('snap/snapcraft.yaml').readAsStringSync(); if (!snap.contains('Snap Store listing translations are managed outside')) { @@ -1582,9 +1578,8 @@ Iterable _scanIcuOptions(String value) sync* { } if (index >= value.length) return; - final optionMatch = RegExp( - r'(?:[A-Za-z][A-Za-z0-9_]*|=[0-9]+)', - ).matchAsPrefix(value, index); + final optionMatch = RegExp(r'(?:[A-Za-z][A-Za-z0-9_]*|=[0-9]+)') + .matchAsPrefix(value, index); if (optionMatch == null) return; index = optionMatch.end; while (index < value.length && value[index].trim().isEmpty) { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index a30dcd10..ca528e9b 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -31,16 +31,13 @@ void main() { test( 'main window delegates four-corner clipping and window states to Handy', () { - final source = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); + final source = File('linux/runner/my_application.cc') + .readAsStringSync(); final linuxCmake = File('linux/CMakeLists.txt').readAsStringSync(); - final runnerCmake = File( - 'linux/runner/CMakeLists.txt', - ).readAsStringSync(); - final workflow = File( - '.github/workflows/flutter-linux.yml', - ).readAsStringSync(); + final runnerCmake = File('linux/runner/CMakeLists.txt') + .readAsStringSync(); + final workflow = File('.github/workflows/flutter-linux.yml') + .readAsStringSync(); final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); final activateStart = source.indexOf( @@ -91,9 +88,8 @@ void main() { test('Yaru GTK3 compatibility replaces legacy decoration rings', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); - final searchTheme = File( - 'linux/runner/gtk_search_entry_theme.cc', - ).readAsStringSync(); + final searchTheme = File('linux/runner/gtk_search_entry_theme.cc') + .readAsStringSync(); final themeGateStart = source.indexOf( 'static gboolean current_gtk_theme_uses_legacy_yaru_shadow()', ); @@ -169,9 +165,8 @@ void main() { 'lib/src/features/diagnostics/presentation/diagnostics_screen.dart', ).readAsStringSync(); final router = File('lib/src/app/app_router.dart').readAsStringSync(); - final design = File( - 'lib/src/app/busymax_design.dart', - ).readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart') + .readAsStringSync(); final dateTimeFields = File( 'lib/src/features/tasks/presentation/desktop_date_time_fields.dart', ).readAsStringSync(); @@ -281,9 +276,8 @@ void main() { ); test('tray DBus menu labels come from its injected presentation', () { - final source = File( - 'lib/src/platform/busymax_tray_service.dart', - ).readAsStringSync(); + final source = File('lib/src/platform/busymax_tray_service.dart') + .readAsStringSync(); final logo = File('assets/branding/busymax-logo.svg').readAsStringSync(); expect(source, contains('BusyMaxTrayMenuPresentation presentation')); @@ -351,9 +345,8 @@ void main() { final runner = File('linux/runner/my_application.cc').readAsStringSync(); final linuxMain = File('linux/runner/main.cc').readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); - final commands = File( - 'lib/src/schedule/schedule_commands.dart', - ).readAsStringSync(); + final commands = File('lib/src/schedule/schedule_commands.dart') + .readAsStringSync(); final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -380,9 +373,8 @@ void main() { test('one Flutter view owns the main header and full-height sidebar', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final frame = File( - 'lib/src/app/linux/linux_page_frame.dart', - ).readAsStringSync(); + final frame = File('lib/src/app/linux/linux_page_frame.dart') + .readAsStringSync(); final schedule = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); @@ -420,9 +412,8 @@ void main() { }); test('top-level Linux header triggers use semantic GTK icon assets', () { - final style = File( - 'lib/src/app/linux/linux_header_style.dart', - ).readAsStringSync(); + final style = File('lib/src/app/linux/linux_header_style.dart') + .readAsStringSync(); final toolbar = File( 'lib/src/features/schedule/presentation/schedule_toolbar.dart', ).readAsStringSync(); @@ -481,22 +472,18 @@ void main() { final toolbar = File( 'lib/src/features/schedule/presentation/schedule_toolbar.dart', ).readAsStringSync(); - final linuxHeader = File( - 'lib/src/app/linux/linux_header_style.dart', - ).readAsStringSync(); + final linuxHeader = File('lib/src/app/linux/linux_header_style.dart') + .readAsStringSync(); final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - final iconService = File( - 'lib/src/platform/gtk_header_icon_service.dart', - ).readAsStringSync(); + final iconService = File('lib/src/platform/gtk_header_icon_service.dart') + .readAsStringSync(); final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final nativeIcons = File( - 'linux/runner/gtk_header_icons.cc', - ).readAsStringSync(); - final nativeSearchTheme = File( - 'linux/runner/gtk_search_entry_theme.cc', - ).readAsStringSync(); + final nativeIcons = File('linux/runner/gtk_header_icons.cc') + .readAsStringSync(); + final nativeSearchTheme = File('linux/runner/gtk_search_entry_theme.cc') + .readAsStringSync(); expect(workspace, contains('final header = ScheduleToolbar(')); expect(workspace, isNot(contains("'schedule-search-close-button'"))); @@ -632,9 +619,8 @@ void main() { test('native GTK window preferences notify and clean up safely', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final helper = File( - 'linux/runner/gtk_window_preferences.cc', - ).readAsStringSync(); + final helper = File('linux/runner/gtk_window_preferences.cc') + .readAsStringSync(); for (final property in [ 'gtk-decoration-layout', 'gtk-titlebar-double-click', @@ -660,9 +646,8 @@ void main() { test('Linux content menus use native GTK popup menus on mapped host', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final service = File( - 'lib/src/platform/native_menu_service.dart', - ).readAsStringSync(); + final service = File('lib/src/platform/native_menu_service.dart') + .readAsStringSync(); final start = runner.indexOf('constexpr char kNativeMenuActionNamespace'); final end = runner.indexOf('static void respond_success', start); @@ -810,9 +795,8 @@ void main() { test('confirmations use the shared app dialog surface', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final dialogs = File( - 'lib/src/app/busymax_dialogs.dart', - ).readAsStringSync(); + final dialogs = File('lib/src/app/busymax_dialogs.dart') + .readAsStringSync(); final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); final confirmStart = design.indexOf('class BusyMaxConfirmDialog'); final confirmBody = design.substring(confirmStart); @@ -841,9 +825,8 @@ void main() { final scheduling = File( 'lib/src/dav/presentation/nextcloud_scheduling_dialog.dart', ).readAsStringSync(); - final accounts = File( - 'lib/src/dav/auth/dav_account_dialogs.dart', - ).readAsStringSync(); + final accounts = File('lib/src/dav/auth/dav_account_dialogs.dart') + .readAsStringSync(); final settings = File( 'lib/src/features/settings/presentation/settings_screen.dart', ).readAsStringSync(); @@ -889,54 +872,46 @@ void main() { expect(preview, isNot(contains('\n CheckboxListTile('))); }); - test( - 'schedule search filters use BusyMax rows without a dedicated native subsystem', - () { - final runner = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); - final workspace = File( - 'lib/src/features/schedule/presentation/schedule_workspace.dart', - ).readAsStringSync(); - final filters = File( - 'lib/src/features/schedule/presentation/schedule_search_filters.dart', - ).readAsStringSync(); - - expect(runner, isNot(contains('native_schedule_search_filters'))); - expect(runner, isNot(contains('NativeScheduleFilter'))); - expect(runner, isNot(contains('content_overlay'))); - expect( - File( - 'lib/src/platform/linux_schedule_search_filter_service.dart', - ).existsSync(), - isFalse, - ); - expect( - File( - 'test/platform/linux_schedule_search_filter_service_test.dart', - ).existsSync(), - isFalse, - ); + test('schedule search filters use BusyMax rows without a dedicated native subsystem', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final filters = File( + 'lib/src/features/schedule/presentation/schedule_search_filters.dart', + ).readAsStringSync(); - expect(filters, contains('BusyMaxSidebarSurface(')); - expect(filters, contains('BusyMaxGroupedList(')); - expect(filters, contains('BusyMaxComboRow<')); - expect(filters, contains('BusyMaxSwitchRow(')); - expect(filters, contains('BusyMaxActionRow(')); - expect(filters, contains('DesktopDateValueRow(')); - expect(filters, contains('busyMaxGroupedTextFieldDecoration(')); - expect(workspace, contains('ScheduleSearchFilters(')); - expect(workspace, contains('showBusyMaxModalDialog(')); - expect(workspace, contains('BusyMaxDialogShell(')); - expect(workspace, isNot(contains('linux-native-search-filter-spacer'))); - }, - ); + expect(runner, isNot(contains('native_schedule_search_filters'))); + expect(runner, isNot(contains('NativeScheduleFilter'))); + expect(runner, isNot(contains('content_overlay'))); + expect( + File('lib/src/platform/linux_schedule_search_filter_service.dart') + .existsSync(), + isFalse, + ); + expect( + File('test/platform/linux_schedule_search_filter_service_test.dart') + .existsSync(), + isFalse, + ); + + expect(filters, contains('BusyMaxSidebarSurface(')); + expect(filters, contains('BusyMaxGroupedList(')); + expect(filters, contains('BusyMaxComboRow<')); + expect(filters, contains('BusyMaxSwitchRow(')); + expect(filters, contains('BusyMaxActionRow(')); + expect(filters, contains('DesktopDateValueRow(')); + expect(filters, contains('busyMaxGroupedTextFieldDecoration(')); + expect(workspace, contains('ScheduleSearchFilters(')); + expect(workspace, contains('showBusyMaxModalDialog(')); + expect(workspace, contains('BusyMaxDialogShell(')); + expect(workspace, isNot(contains('linux-native-search-filter-spacer'))); + }); test('timezone selection uses native GTK and Handy controls on Linux', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final service = File( - 'lib/src/platform/native_dialog_service.dart', - ).readAsStringSync(); + final service = File('lib/src/platform/native_dialog_service.dart') + .readAsStringSync(); final selector = File( 'lib/src/features/tasks/presentation/time_zone_selection_dialog.dart', ).readAsStringSync(); @@ -1123,79 +1098,70 @@ void main() { expect(restore, isNot(contains('gtk_window_present(self->main_window)'))); }); - test( - 'text prompts reuse the shared Yaru grouped form without native reinvention', - () { - final runner = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); - final service = File( - 'lib/src/platform/native_dialog_service.dart', - ).readAsStringSync(); - final dialogs = File( - 'lib/src/app/busymax_dialogs.dart', - ).readAsStringSync(); - final design = File( - 'lib/src/app/busymax_design.dart', - ).readAsStringSync(); - final nativeDialogsStart = runner.indexOf('static void respond_bool('); - final nativeDialogsEnd = runner.indexOf( - 'struct NativeTimeZoneOption', - nativeDialogsStart, - ); - final promptStart = design.indexOf('class BusyMaxPromptDialog'); - final promptEnd = design.indexOf( - 'class BusyMaxConfirmDialog', - promptStart, - ); + test('text prompts reuse the shared Yaru grouped form without native reinvention', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final service = File('lib/src/platform/native_dialog_service.dart') + .readAsStringSync(); + final dialogs = File('lib/src/app/busymax_dialogs.dart') + .readAsStringSync(); + final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); + final nativeDialogsStart = runner.indexOf('static void respond_bool('); + final nativeDialogsEnd = runner.indexOf( + 'struct NativeTimeZoneOption', + nativeDialogsStart, + ); + final promptStart = design.indexOf('class BusyMaxPromptDialog'); + final promptEnd = design.indexOf( + 'class BusyMaxConfirmDialog', + promptStart, + ); + + expect(nativeDialogsStart, isNonNegative); + expect(nativeDialogsEnd, greaterThan(nativeDialogsStart)); + final nativeDialogs = runner.substring( + nativeDialogsStart, + nativeDialogsEnd, + ); + expect(nativeDialogs, isNot(contains('handle_native_prompt'))); + expect(nativeDialogs, isNot(contains('respond_native_prompt'))); + expect(nativeDialogs, isNot(contains('gtk_entry_new()'))); + expect(nativeDialogs, isNot(contains('gtk_dialog_new_with_buttons('))); + expect(nativeDialogs, isNot(contains('gtk_message_dialog_new('))); + expect(runner, isNot(contains('strcmp(method, "confirm")'))); + expect(runner, isNot(contains('strcmp(method, "prompt")'))); - expect(nativeDialogsStart, isNonNegative); - expect(nativeDialogsEnd, greaterThan(nativeDialogsStart)); - final nativeDialogs = runner.substring( - nativeDialogsStart, - nativeDialogsEnd, - ); - expect(nativeDialogs, isNot(contains('handle_native_prompt'))); - expect(nativeDialogs, isNot(contains('respond_native_prompt'))); - expect(nativeDialogs, isNot(contains('gtk_entry_new()'))); - expect(nativeDialogs, isNot(contains('gtk_dialog_new_with_buttons('))); - expect(nativeDialogs, isNot(contains('gtk_message_dialog_new('))); - expect(runner, isNot(contains('strcmp(method, "confirm")'))); - expect(runner, isNot(contains('strcmp(method, "prompt")'))); - - expect(service, isNot(contains('NativeTextPromptResult'))); - expect(service, isNot(contains('Future'))); - expect(service, isNot(contains('invokeMapMethod'))); - expect(service, isNot(contains("'prompt'"))); - expect(service, contains('on MissingPluginException')); - expect(service, contains('on PlatformException')); - expect(dialogs, isNot(contains('nativeDialogService.prompt('))); - expect(dialogs, contains('showBusyMaxModalDialog(')); - expect(dialogs, contains('BusyMaxPromptDialog(')); - - expect(promptStart, isNonNegative); - expect(promptEnd, greaterThan(promptStart)); - final prompt = design.substring(promptStart, promptEnd); - expect(prompt, contains('BusyMaxDialogShell(')); - expect(prompt, contains('header: BusyMaxEditorHeader(')); - expect(prompt, isNot(contains('actions: ['))); - expect(prompt, isNot(contains('BusyMaxDialogTitleBar('))); - expect(prompt, contains('BusyMaxGroupedList(')); - expect(prompt, contains('filled: true')); - expect(prompt, contains('YaruListTile.square(')); - expect(prompt, contains('busyMaxGroupedTextFieldDecoration(')); - expect(prompt, contains('TextEditingController(')); - expect(prompt, contains('_canSubmit ? _submit : null')); - expect(prompt, contains('onFieldSubmitted: (_) => _submit()')); - expect( - design, - contains('header ?? BusyMaxDialogTitleBar(title: Text(title))'), - ); - expect(prompt, isNot(contains('maxWidth:'))); - expect(prompt, isNot(contains('InputDecoration('))); - expect(prompt, isNot(contains('AlertDialog('))); - }, - ); + expect(service, isNot(contains('NativeTextPromptResult'))); + expect(service, isNot(contains('Future'))); + expect(service, isNot(contains('invokeMapMethod'))); + expect(service, isNot(contains("'prompt'"))); + expect(service, contains('on MissingPluginException')); + expect(service, contains('on PlatformException')); + expect(dialogs, isNot(contains('nativeDialogService.prompt('))); + expect(dialogs, contains('showBusyMaxModalDialog(')); + expect(dialogs, contains('BusyMaxPromptDialog(')); + + expect(promptStart, isNonNegative); + expect(promptEnd, greaterThan(promptStart)); + final prompt = design.substring(promptStart, promptEnd); + expect(prompt, contains('BusyMaxDialogShell(')); + expect(prompt, contains('header: BusyMaxEditorHeader(')); + expect(prompt, isNot(contains('actions: ['))); + expect(prompt, isNot(contains('BusyMaxDialogTitleBar('))); + expect(prompt, contains('BusyMaxGroupedList(')); + expect(prompt, contains('filled: true')); + expect(prompt, contains('YaruListTile.square(')); + expect(prompt, contains('busyMaxGroupedTextFieldDecoration(')); + expect(prompt, contains('TextEditingController(')); + expect(prompt, contains('_canSubmit ? _submit : null')); + expect(prompt, contains('onFieldSubmitted: (_) => _submit()')); + expect( + design, + contains('header ?? BusyMaxDialogTitleBar(title: Text(title))'), + ); + expect(prompt, isNot(contains('maxWidth:'))); + expect(prompt, isNot(contains('InputDecoration('))); + expect(prompt, isNot(contains('AlertDialog('))); + }); test('modal editors use the semantic window role with themed geometry', () { final design = File('lib/src/app/busymax_design.dart').readAsStringSync(); @@ -1235,9 +1201,8 @@ void main() { test( 'retained native surfaces are themed without a native main header', () { - final source = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); + final source = File('linux/runner/my_application.cc') + .readAsStringSync(); final start = source.indexOf('static void refresh_native_surface_css'); final end = source.indexOf( 'static void set_native_surface_theme', @@ -1310,15 +1275,12 @@ void main() { test( 'native GTK theme colors and retained-surface styles stay separate', () { - final source = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); - final nativeStyle = File( - 'lib/src/platform/native_style.dart', - ).readAsStringSync(); - final gtkFontService = File( - 'lib/src/platform/gtk_font_service.dart', - ).readAsStringSync(); + final source = File('linux/runner/my_application.cc') + .readAsStringSync(); + final nativeStyle = File('lib/src/platform/native_style.dart') + .readAsStringSync(); + final gtkFontService = File('lib/src/platform/gtk_font_service.dart') + .readAsStringSync(); final app = File('lib/src/app/busymax_app.dart').readAsStringSync(); final main = File('lib/main_linux.dart').readAsStringSync(); @@ -1483,9 +1445,8 @@ void main() { }); test('BusyMax text theme does not override Yaru weights globally', () { - final source = File( - 'lib/src/app/busymax_yaru_theme.dart', - ).readAsStringSync(); + final source = File('lib/src/app/busymax_yaru_theme.dart') + .readAsStringSync(); final start = source.indexOf('static TextTheme _busyMaxTextTheme'); final end = source.indexOf('\n}\n\nclass _TextStyleNormalizer', start); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index b6ae797e..e0cc9bc4 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -140,9 +141,9 @@ void main() { light.inputDecorationTheme.focusedBorder as OutlineInputBorder; expect(focusedBorder.borderSide.color, _testAccentColor); - final outlinedShape = - light.outlinedButtonTheme.style?.shape?.resolve({}) - as RoundedRectangleBorder; + final outlinedShape = light.outlinedButtonTheme.style?.shape?.resolve( + {}, + ) as RoundedRectangleBorder; expect(outlinedShape.borderRadius, BorderRadius.circular(BusyMaxRadius.sm)); expect( @@ -591,9 +592,8 @@ void main() { }); test('BusyMaxSurfaceColors copyWith preserves and overrides fields', () { - final base = _buildBusyMaxTheme( - brightness: Brightness.dark, - ).extension()!; + final base = _buildBusyMaxTheme(brightness: Brightness.dark) + .extension()!; final updated = base.copyWith( window: const Color(0xFF010203), @@ -615,12 +615,10 @@ void main() { }); test('BusyMaxSurfaceColors lerp blends semantic fields', () { - final start = _buildBusyMaxTheme( - brightness: Brightness.light, - ).extension()!; - final end = _buildBusyMaxTheme( - brightness: Brightness.dark, - ).extension()!; + final start = _buildBusyMaxTheme(brightness: Brightness.light) + .extension()!; + final end = _buildBusyMaxTheme(brightness: Brightness.dark) + .extension()!; final midpoint = start.lerp(end, 0.5); @@ -1613,9 +1611,8 @@ void main() { test( 'BusyMax Yaru theme derives text theme from Yaru instead of scratch', () { - final source = File( - 'lib/src/app/busymax_yaru_theme.dart', - ).readAsStringSync(); + final source = File('lib/src/app/busymax_yaru_theme.dart') + .readAsStringSync(); expect(source, contains('_busyMaxTextTheme(')); expect(source, contains('base.copyWith(')); diff --git a/test/app/weekday_startup_activation_test.dart b/test/app/weekday_startup_activation_test.dart index 6de403f9..021e4ea4 100644 --- a/test/app/weekday_startup_activation_test.dart +++ b/test/app/weekday_startup_activation_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; + import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/app_router.dart'; import 'package:busymax/src/app/busymax_app.dart'; diff --git a/test/architecture/platform_boundary_test.dart b/test/architecture/platform_boundary_test.dart index 6cd45322..7379093a 100644 --- a/test/architecture/platform_boundary_test.dart +++ b/test/architecture/platform_boundary_test.dart @@ -18,13 +18,11 @@ void main() { ); addTearDown(() => root.deleteSync(recursive: true)); Directory('${root.path}/lib/src').createSync(recursive: true); - File( - '${root.path}/lib/main_android.dart', - ).writeAsStringSync("import 'package:busymax/src/bridge.dart';\n"); + File('${root.path}/lib/main_android.dart') + .writeAsStringSync("import 'package:busymax/src/bridge.dart';\n"); File('${root.path}/lib/src/android_background.dart').writeAsStringSync(''); - File( - '${root.path}/lib/src/bridge.dart', - ).writeAsStringSync("export 'package:yaru/yaru.dart';\n"); + File('${root.path}/lib/src/bridge.dart') + .writeAsStringSync("export 'package:yaru/yaru.dart';\n"); final failures = boundary_check.findPlatformBoundaryViolations(root); diff --git a/test/calendar_providers/calendar_description_test.dart b/test/calendar_providers/calendar_description_test.dart index b1a5dacd..d23b77b7 100644 --- a/test/calendar_providers/calendar_description_test.dart +++ b/test/calendar_providers/calendar_description_test.dart @@ -27,25 +27,22 @@ void main() { }, ); - test( - 'meeting footer preservation replaces user text but retains provider link', - () { - const original = - '

Old agenda

'; - final edited = preserveMicrosoftMeetingBodyHtml( - editedHtml: '
New agenda
', - originalHtml: original, - meetingUrl: 'https://teams.microsoft.com/l/meetup-join/example', - ); - expect(edited, contains('New agenda')); - expect(edited, isNot(contains('Old agenda'))); - expect(edited, contains('Join Microsoft Teams meeting')); - expect( - edited, - contains('https://teams.microsoft.com/l/meetup-join/example'), - ); - }, - ); + test('meeting footer preservation replaces user text but retains provider link', () { + const original = + '

Old agenda

'; + final edited = preserveMicrosoftMeetingBodyHtml( + editedHtml: '
New agenda
', + originalHtml: original, + meetingUrl: 'https://teams.microsoft.com/l/meetup-join/example', + ); + expect(edited, contains('New agenda')); + expect(edited, isNot(contains('Old agenda'))); + expect(edited, contains('Join Microsoft Teams meeting')); + expect( + edited, + contains('https://teams.microsoft.com/l/meetup-join/example'), + ); + }); test( 'nested meeting block is preserved without stale trailing user text', diff --git a/test/config/build_config_test.dart b/test/config/build_config_test.dart index 30247c43..8fe03f68 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -1,44 +1,42 @@ import '../support/desktop_registration_config.dart'; + import 'package:busymax/src/config/desktop_oauth_configuration.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/config/build_config.dart'; void main() { - test( - 'managed desktop registrations are explicit and original values remain separate', - () { - final originalsOnly = syntheticDesktopConfig(managed: false); - expect(originalsOnly.hasBusyMaxGoogleRegistration, isFalse); - expect(originalsOnly.hasBusyMaxMicrosoftRegistration, isFalse); - final configured = syntheticDesktopConfig(); - expect(configured.hasBusyMaxGoogleRegistration, isTrue); - expect(configured.hasBusyMaxMicrosoftRegistration, isTrue); - expect(configured.googleOAuthClientId, originalsOnly.googleOAuthClientId); - expect( - configured.microsoftOAuthClientId, - originalsOnly.microsoftOAuthClientId, - ); - expect( - validGoogleDesktopConfiguration( - clientId: configured.busyMaxGoogleOAuthClientId, - clientSecret: configured.busyMaxGoogleOAuthClientSecret, - projectId: '', - ), - isFalse, - ); - expect( - validMicrosoftDesktopConfiguration( - clientId: configured.busyMaxMicrosoftOAuthClientId, - ), - isTrue, - ); - for (final invalid in ['', 'not-a-client-id']) { - expect(validMicrosoftDesktopConfiguration(clientId: invalid), isFalse); - } - expect(BuildConfig.forAndroid().hasBusyMaxGoogleRegistration, isFalse); - expect(BuildConfig.forAndroid().hasBusyMaxMicrosoftRegistration, isFalse); - }, - ); + test('managed desktop registrations are explicit and original values remain separate', () { + final originalsOnly = syntheticDesktopConfig(managed: false); + expect(originalsOnly.hasBusyMaxGoogleRegistration, isFalse); + expect(originalsOnly.hasBusyMaxMicrosoftRegistration, isFalse); + final configured = syntheticDesktopConfig(); + expect(configured.hasBusyMaxGoogleRegistration, isTrue); + expect(configured.hasBusyMaxMicrosoftRegistration, isTrue); + expect(configured.googleOAuthClientId, originalsOnly.googleOAuthClientId); + expect( + configured.microsoftOAuthClientId, + originalsOnly.microsoftOAuthClientId, + ); + expect( + validGoogleDesktopConfiguration( + clientId: configured.busyMaxGoogleOAuthClientId, + clientSecret: configured.busyMaxGoogleOAuthClientSecret, + projectId: '', + ), + isFalse, + ); + expect( + validMicrosoftDesktopConfiguration( + clientId: configured.busyMaxMicrosoftOAuthClientId, + ), + isTrue, + ); + for (final invalid in ['', 'not-a-client-id']) { + expect(validMicrosoftDesktopConfiguration(clientId: invalid), isFalse); + } + expect(BuildConfig.forAndroid().hasBusyMaxGoogleRegistration, isFalse); + expect(BuildConfig.forAndroid().hasBusyMaxMicrosoftRegistration, isFalse); + }); test('managed Microsoft needs only its own valid client ID', () { const config = BuildConfig( diff --git a/test/contacts/busymax_contacts_controller_test.dart b/test/contacts/busymax_contacts_controller_test.dart new file mode 100644 index 00000000..ad8546b3 --- /dev/null +++ b/test/contacts/busymax_contacts_controller_test.dart @@ -0,0 +1,132 @@ +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; +import 'package:busymax/src/contacts/busymax_contacts_store.dart'; +import 'package:busymax/src/core/auth/account_token_broker.dart'; +import 'package:busymax/src/core/auth/authorization_attempt.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; + +void main() { + test('actual attendee lookup filters disabled sources and exclusions before limit', () async { + final database = AppDatabase.memoryForTests(); + final store = BusyMaxContactsStore(database); + final controller = BusyMaxContactsController( + store: store, + accounts: AccountsRepository(database: database), + authorization: _UnusedAuthorization(), + httpClient: MockClient((_) async => throw StateError('no network')), + readDavSecret: (_) async => null, + writeDavSecret: (_, _) async {}, + deleteDavSecret: (_) async {}, + readLinkedDavCredential: (_) async => null, + launchBrowser: (_) async => false, + syncInterval: const Duration(days: 1), + ); + addTearDown(() async { + await controller.close(); + await database.close(); + }); + // Start with an empty directory so background synchronization has no + // provider to contact. The records below then exercise the same cached + // lookup called by all three production event editors. + await controller.start(); + await store.write((transaction) async { + await transaction.putAccount(_account); + await transaction.putSource(_source('disabled')); + await transaction.putSource(_source('enabled')); + for (var i = 0; i < 12; i++) { + await transaction.putContact( + _record('disabled', 'disabled-$i', 'alex-$i@example.test'), + ); + } + await transaction.putContact( + _record('enabled', 'selected', 'selected@example.test'), + ); + await transaction.putContact( + _record('enabled', 'excluded', 'excluded@example.test'), + ); + }); + await store.setSourceEnabled(_source('disabled').key, enabled: false); + + final suggestions = await controller.suggestAttendees( + 'e', + excludedAddresses: const {'excluded@example.test'}, + limit: 1, + ); + + expect(suggestions, hasLength(1)); + expect(suggestions.single.email, 'selected@example.test'); + expect(suggestions.single.displayName, 'Selected Person'); + expect(suggestions.single.identities.single.sourceId, 'enabled'); + }); +} + +const _account = ContactAccount( + id: 'contacts-only', + provider: ContactProviderKind.carddav, + displayName: 'Contacts', + subject: 'alex', + grantedScopes: {'carddav:read'}, +); + +ContactSource _source(String id) => ContactSource( + accountId: _account.id, + id: id, + provider: ContactProviderKind.carddav, + kind: ContactSourceKind.carddavAddressBook, + name: id, + remotePath: 'https://contacts.example.test/$id/', + capabilities: ContactCapabilities( + create: false, + update: false, + delete: false, + photoRead: false, + photoWrite: false, + fields: ContactField.values.toSet(), + ), +); + +CardDavContactRecord _record(String sourceId, String remoteId, String email) { + final identity = ContactIdentity( + accountId: _account.id, + provider: ContactProviderKind.carddav, + sourceId: sourceId, + remoteId: remoteId, + ); + final name = remoteId == 'selected' ? 'Selected Person' : 'Alex $remoteId'; + final vcard = newVcard( + ContactPatch( + name: FieldEdit.set(ContactName(display: name)), + emails: FieldEdit.set([ContactValue(email)]), + ), + uid: remoteId, + ); + return CardDavContactRecord( + identity: identity, + vcard: vcard, + revision: '"one"', + projection: projectVcard(identity, vcard), + ); +} + +final class _UnusedAuthorization implements ContactsAuthorizationBroker { + @override + Future authorizeContacts( + BusyProvider provider, + String accountId, { + + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => throw StateError('authorization is not used by cached lookup'); + + @override + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + + String? claims, + }) => throw StateError('authorization is not used by cached lookup'); +} diff --git a/test/contacts/busymax_contacts_store_test.dart b/test/contacts/busymax_contacts_store_test.dart new file mode 100644 index 00000000..a941e50e --- /dev/null +++ b/test/contacts/busymax_contacts_store_test.dart @@ -0,0 +1,246 @@ +import 'dart:async'; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:busymax/src/contacts/busymax_contacts_store.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase database; + late BusyMaxContactsStore store; + + setUp(() async { + database = AppDatabase.memoryForTests(); + store = BusyMaxContactsStore(database); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'calendar-account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'subject', + credentialKind: 'oauth', + createdAtUtc: '2026-10-06T00:00:00.000Z', + updatedAtUtc: '2026-10-06T00:00:00.000Z', + ), + ); + }); + + tearDown(() async { + await store.close(); + await database.close(); + }); + + test('data and cursor commit atomically across awaited operations', () async { + await store.write((transaction) async { + await transaction.putAccount(_account('contacts')); + await Future.delayed(Duration.zero); + await transaction.putSource(_source('contacts')); + await transaction.putContact(_record('contacts', 'one')); + await transaction.putSyncState( + ContactSyncState( + sourceKey: _source('contacts').key, + cursor: 'cursor-1', + ), + ); + }); + + expect(await store.read((transaction) => transaction.count()), 1); + expect( + (await store.read( + (transaction) => transaction.syncState(_source('contacts').key), + ))?.cursor, + 'cursor-1', + ); + }); + + test('delayed write failure rolls back records and cursor', () async { + await store.write((transaction) async { + await transaction.putAccount(_account('contacts')); + await transaction.putSource(_source('contacts')); + }); + + await expectLater( + store.write((transaction) async { + await transaction.putContact(_record('contacts', 'rolled-back')); + await Future.delayed(Duration.zero); + await transaction.putSyncState( + ContactSyncState( + sourceKey: _source('contacts').key, + cursor: 'must-not-commit', + ), + ); + throw StateError('fixture rollback'); + }), + throwsStateError, + ); + + expect(await store.read((transaction) => transaction.count()), 0); + expect( + await store.read( + (transaction) => transaction.syncState(_source('contacts').key), + ), + isNull, + ); + }); + + test( + 'linked and contacts-only sources retain independent eligibility', + () async { + await store.saveLinkedAccount( + account: _account('linked'), + busyMaxAccountId: 'calendar-account', + reuseAuthorization: true, + ); + await store.write((transaction) async { + await transaction.putSource(_source('linked')); + await transaction.putAccount(_account('standalone')); + await transaction.putSource(_source('standalone')); + }); + + var selection = await store.selection( + busyMaxAccountId: 'calendar-account', + ); + expect(selection.accountIds, {'linked', 'standalone'}); + expect(selection.sourceKeys, { + _source('linked').key, + _source('standalone').key, + }); + + await store.setSourceEnabled(_source('linked').key, enabled: false); + selection = await store.selection(busyMaxAccountId: 'calendar-account'); + expect(selection.accountIds, {'standalone'}); + expect(selection.sourceKeys, {_source('standalone').key}); + expect(await store.linkedBusyMaxAccount('linked'), 'calendar-account'); + }, + ); + + test( + 'closing drains an awaited transaction without closing BusyMax DB', + () async { + final entered = Completer(); + final release = Completer(); + final operation = store.write((transaction) async { + await transaction.putAccount(_account('contacts')); + entered.complete(); + await release.future; + await transaction.putSource(_source('contacts')); + }); + await entered.future; + final closing = store.close(); + release.complete(); + await Future.wait([operation, closing]); + + expect( + await database + .customSelect('SELECT count(*) AS total FROM accounts') + .getSingle() + .then((row) => row.read('total')), + 1, + ); + }, + ); + test( + 'failing transaction still closes changes and retains the application DB', + () async { + final entered = Completer(); + final release = Completer(); + var notifications = 0; + final subscription = store.changes.listen((_) => notifications++); + final streamDone = subscription.asFuture(); + final operation = store.write((tx) async { + entered.complete(); + await release.future; + throw StateError('delayed rollback'); + }); + final failure = expectLater(operation, throwsStateError); + await entered.future; + final closing = store.close(); + release.complete(); + await failure; + await closing.timeout(const Duration(seconds: 3)); + await streamDone; + expect(notifications, 0); + await expectLater( + store.setSourceEnabled('missing', enabled: false), + throwsStateError, + ); + expect(await database.customSelect('SELECT 1').getSingle(), isNotNull); + await store.close(); + }, + ); + test( + 'preferences notify only after commit and preserve the value on failure', + () async { + await store.write((tx) async { + await tx.putAccount(_account('contacts')); + await tx.putSource(_source('contacts')); + }); + var notifications = 0; + final subscription = store.changes.listen((_) => notifications++); + addTearDown(subscription.cancel); + await store.setSourceEnabled(_source('contacts').key, enabled: false); + await Future.delayed(Duration.zero); + expect(notifications, 1); + expect(await store.sourceEnabled(_source('contacts').key), false); + await database.customStatement( + "CREATE TRIGGER fail_preference BEFORE UPDATE ON bm_contact_source_preferences BEGIN SELECT RAISE(ABORT, 'fixture'); END", + ); + await expectLater( + store.setSourceEnabled(_source('contacts').key, enabled: true), + throwsA(anything), + ); + await Future.delayed(Duration.zero); + expect(notifications, 1); + expect(await store.sourceEnabled(_source('contacts').key), false); + }, + ); +} + +ContactAccount _account(String id) => ContactAccount( + id: id, + provider: ContactProviderKind.carddav, + displayName: id, + subject: id, + grantedScopes: const {'dav:read', 'dav:write'}, +); + +ContactSource _source(String accountId) => ContactSource( + accountId: accountId, + id: 'book', + provider: ContactProviderKind.carddav, + kind: ContactSourceKind.carddavAddressBook, + name: 'Address book', + remotePath: 'https://contacts.example.test/book/', + capabilities: ContactCapabilities( + create: true, + update: true, + delete: true, + photoRead: true, + photoWrite: true, + fields: ContactField.values.toSet(), + ), +); + +CardDavContactRecord _record(String accountId, String id) { + final identity = ContactIdentity( + accountId: accountId, + provider: ContactProviderKind.carddav, + sourceId: 'book', + remoteId: id, + ); + final vcard = newVcard( + ContactPatch( + name: FieldEdit.set(ContactName(display: 'Contact $id')), + emails: FieldEdit.set([ContactValue('$id@example.test')]), + ), + uid: id, + ); + return CardDavContactRecord( + identity: identity, + vcard: vcard, + revision: '"one"', + projection: projectVcard(identity, vcard), + ); +} diff --git a/test/contacts/contacts_enrollment_test.dart b/test/contacts/contacts_enrollment_test.dart new file mode 100644 index 00000000..bb8f91fc --- /dev/null +++ b/test/contacts/contacts_enrollment_test.dart @@ -0,0 +1,300 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:drift/native.dart'; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/contacts_fixture.dart'; + +void main() { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + '$provider first enrollment, enabled read-only reconnect and reconnect synchronize', + () async { + final fixture = ContactsFixture(AppDatabase.memoryForTests()); + addTearDown(fixture.close); + await fixture.addParent(provider); + await fixture.controller.enableLinkedContacts(provider.name); + final id = BusyMaxContactsController.linkedContactAccountId( + provider.name, + ); + for (var reconnect = 0; reconnect < 3; reconnect++) { + await fixture.controller.enableLinkedContacts(provider.name); + await fixture.controller.synchronizeAccount(id); + final account = await fixture.store.read((tx) => tx.account(id)); + expect(account!.enabled, isTrue); + expect( + (await fixture.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + )).single.email, + 'ada@example.test', + ); + } + final account = await fixture.store.read((tx) => tx.account(id)); + expect(account!.generation, 3); + expect( + account.grantedScopes, + contains( + provider == BusyProvider.google + ? 'https://www.googleapis.com/auth/contacts.readonly' + : 'Contacts.Read', + ), + ); + final before = account.toJson(); + fixture.authorizationFailure = StateError( + 'cancelled or invalid consent', + ); + await expectLater( + fixture.controller.enableLinkedContacts(provider.name), + throwsStateError, + ); + expect( + (await fixture.store.read((tx) => tx.account(id)))!.toJson(), + before, + ); + await fixture.controller.synchronizeAccount(id); + expect( + (await fixture.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + )), + hasLength(1), + ); + }, + ); + } + test( + 'post-commit preference changes immediately change lookup eligibility', + () async { + final fixture = ContactsFixture(AppDatabase.memoryForTests()); + addTearDown(fixture.close); + await fixture.addParent(BusyProvider.google); + await fixture.controller.enableLinkedContacts('google'); + await fixture.controller.synchronizeAccount('contacts:google'); + final source = (await fixture.controller.sourceSettings()).single.source; + final changed = fixture.controller.changes.first; + await fixture.controller.setSourceEnabled(source.key, enabled: false); + await changed; + expect( + (await fixture.controller.sourceSettings()).single.enabled, + isFalse, + ); + expect( + await fixture.controller.suggestAttendees( + 'ada', + busyMaxAccountId: 'google', + ), + isEmpty, + ); + expect( + (await fixture.store.read((tx) => tx.account('contacts:google')))! + .enabled, + isTrue, + ); + expect(await fixture.store.read((tx) => tx.count()), 1); + }, + ); + test( + 'manual failure remains awaitable and close drains failure safely', + () async { + final fixture = ContactsFixture(AppDatabase.memoryForTests()); + await fixture.addParent(BusyProvider.google); + await fixture.controller.enableLinkedContacts('google'); + await fixture.controller.synchronizeAccount('contacts:google'); + fixture.offline = true; + final failing = fixture.controller.synchronizeAccount('contacts:google'); + final assertion = expectLater(failing, throwsA(isA())); + await assertion; + expect( + (await fixture.store.read((tx) => tx.account('contacts:google')))! + .errorCode, + isNotNull, + ); + await fixture.controller.close().timeout(const Duration(seconds: 3)); + expect( + await fixture.database.customSelect('SELECT 1').getSingle(), + isNotNull, + ); + await fixture.database.close(); + }, + ); + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final failure in ['identity', 'grant', 'persistence']) { + test( + '$provider $failure read-only reconnect preserves enabled account, scopes and cache', + () async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + addTearDown(f.close); + await f.addParent(provider); + await f.controller.enableLinkedContacts(provider.name); + await f.controller.synchronizeAccount('contacts:${provider.name}'); + final before = (await f.store.read( + (tx) => tx.account('contacts:${provider.name}'), + ))!.toJson(); + final parentBefore = (await f.accounts.accountById(provider.name))! + .grantedScopes; + if (failure == 'identity') f.consentSubject = 'wrong-person'; + if (failure == 'grant') { + f.consentScopes = provider == BusyProvider.google + ? 'calendar tasks' + : 'Calendars.ReadWrite Tasks.ReadWrite'; + } + if (failure == 'persistence') { + await f.database.customStatement( + "CREATE TRIGGER reject_contacts_update BEFORE UPDATE ON bm_contact_accounts BEGIN SELECT RAISE(ABORT, 'fixture durable write rejected'); END", + ); + } + await expectLater( + f.controller.enableLinkedContacts(provider.name), + throwsA(isA()), + ); + if (failure == 'persistence') { + await f.database.customStatement( + 'DROP TRIGGER reject_contacts_update', + ); + } + expect( + (await f.store.read( + (tx) => tx.account('contacts:${provider.name}'), + ))!.toJson(), + before, + ); + expect( + (await f.accounts.accountById(provider.name))!.grantedScopes, + parentBefore, + ); + await f.controller.synchronizeAccount('contacts:${provider.name}'); + expect( + await f.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + ), + hasLength(1), + ); + }, + ); + } + test( + '$provider disk restart retains enabled reconnected read-only account and source preference', + () async { + final profile = await Directory.systemTemp.createTemp( + 'busymax-enrollment-restart-', + ); + addTearDown(() => profile.delete(recursive: true)); + ContactsFixture open() => ContactsFixture( + AppDatabase(NativeDatabase(File('${profile.path}/app.db'))), + ); + var f = open(); + await f.addParent(provider); + await f.controller.enableLinkedContacts(provider.name); + await f.controller.synchronizeAccount('contacts:${provider.name}'); + final source = (await f.controller.sourceSettings()).single.source; + await f.controller.setSourceEnabled(source.key, enabled: false); + await f.close(); + f = open(); + addTearDown(f.close); + await f.controller.start(); + await f.controller.synchronizeAccount('contacts:${provider.name}'); + expect( + (await f.store.read((tx) => tx.account('contacts:${provider.name}')))! + .enabled, + isTrue, + ); + expect( + await f.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + ), + isEmpty, + ); + await f.controller.setSourceEnabled(source.key, enabled: true); + expect( + await f.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + ), + hasLength(1), + ); + }, + ); + test( + '$provider close cancels consent and drains owned enrollment', + () async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + await f.addParent(provider); + f.consentPause = Completer(); + final enrollment = f.controller.enableLinkedContacts(provider.name); + final assertion = expectLater( + enrollment, + throwsA(isA()), + ); + await Future.delayed(const Duration(milliseconds: 40)); + await f.controller.close().timeout(const Duration(seconds: 3)); + await assertion; + expect( + await f.database.customSelect('SELECT 1').getSingle(), + isNotNull, + ); + await f.database.close(); + }, + ); + } + test( + 'scheduled failure is observed while another account synchronizes', + () async { + final uncaught = []; + final completion = Completer(); + runZonedGuarded(() async { + try { + final f = ContactsFixture( + AppDatabase.memoryForTests(), + syncInterval: const Duration(milliseconds: 20), + ); + try { + await f.addParent(BusyProvider.google); + await f.addParent(BusyProvider.microsoft); + await f.controller.enableLinkedContacts('google'); + await f.controller.enableLinkedContacts('microsoft'); + await f.controller.synchronizeAll(); + f.googleOffline = true; + await Future.delayed(const Duration(milliseconds: 1100)); + expect( + (await f.store.read((tx) => tx.account('contacts:google')))! + .errorCode, + isNotNull, + ); + expect( + (await f.store.read((tx) => tx.account('contacts:microsoft')))! + .errorCode, + isNull, + ); + expect( + await f.controller.suggestAttendees( + 'ada', + busyMaxAccountId: 'microsoft', + ), + hasLength(1), + ); + await expectLater( + f.controller.synchronizeAccount('contacts:google'), + throwsA(isA()), + ); + } finally { + await f.close(); + } + completion.complete(); + } catch (e, s) { + completion.completeError(e, s); + } + }, (e, s) => uncaught.add(e)); + await completion.future.timeout(const Duration(seconds: 5)); + expect(uncaught, isEmpty); + }, + ); +} diff --git a/test/contacts/contacts_read_only_state_test.dart b/test/contacts/contacts_read_only_state_test.dart new file mode 100644 index 00000000..27c0ec06 --- /dev/null +++ b/test/contacts/contacts_read_only_state_test.dart @@ -0,0 +1,347 @@ +import 'dart:convert'; + +import 'package:busystack_contacts/google.dart'; +import 'package:busystack_contacts/microsoft.dart'; +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/contacts_fixture.dart'; +import '../support/dav_contacts_fixture.dart'; +import 'read_only_contacts_provider_test.dart' show effective; + +void main() { + test('linked Nextcloud reconnect preserves legacy markers and the parent app password', () async { + final db = AppDatabase.memoryForTests(); + final credential = BusyMaxLinkedDavCredential( + server: server, + username: 'actual-user', + password: 'original-app-password', + ); + final f = ContactsFixture( + db, + realDav: true, + linkedDavCredential: credential, + ); + addTearDown(f.close); + final dav = DavFixture(); + f.respondDav = dav.respond; + await f.accounts.upsertSignedInAccount( + id: 'nextcloud', + provider: BusyProvider.nextcloud, + providerAccountId: 'actual-user', + authority: server.toString(), + displayName: 'Parent Nextcloud', + grantedScopes: '', + ); + const id = 'contacts:nextcloud'; + await f.store.saveLinkedAccount( + account: ContactAccount( + id: id, + provider: ContactProviderKind.carddav, + displayName: 'Legacy Nextcloud', + subject: credential.username, + grantedScopes: const {'carddav:write'}, + generation: 4, + ), + busyMaxAccountId: 'nextcloud', + reuseAuthorization: true, + ); + await f.controller.start(); + await f.controller.synchronizeAccount(id); + final source = (await f.controller.sourceSettings()).first.source; + await f.store.setSourceEnabled(source.key, enabled: false); + final queued = ContactMutation( + id: 'legacy-linked-queued', + accountId: id, + sourceKey: source.key, + kind: ContactMutationKind.providerAction, + state: ContactMutationState.queued, + createdAt: DateTime.utc(2026), + payload: const {'type': 'historical', 'evidence': 'retain'}, + ); + await f.store.write((tx) => tx.putMutation(queued)); + await f.controller.reconnectContactsAccount(id); + await f.controller.synchronizeAccount(id); + final account = (await f.store.read((tx) => tx.account(id)))!; + expect(account.id, id); + expect(account.subject, credential.username); + expect(account.generation, 5); + expect(account.grantedScopes, {'carddav:write'}); + expect(await f.store.sourceEnabled(source.key), isFalse); + expect( + (await f.store.read((tx) => tx.mutations(accountId: id))) + .singleWhere((mutation) => mutation.id == queued.id) + .toJson(), + queued.toJson(), + ); + for (final setting in await f.controller.sourceSettings()) { + effective(setting.source); + } + expect(f.davSecrets, isEmpty); + await f.controller.removeContactsAccount(id); + expect(await f.accounts.accountById('nextcloud'), isNotNull); + expect( + dav.requests.where( + (r) => + ['PUT', 'DELETE', 'MKCOL', 'PROPPATCH', 'POST'].contains(r.method), + ), + isEmpty, + ); + expect(dav.card, rawCard); + expect(credential.password, 'original-app-password'); + }); + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final factory in [true, false]) { + test( + '$provider factory=$factory broad grants remain read-only across enrollment/rebind/restart/timer', + () async { + final db = AppDatabase.memoryForTests(); + final f = ContactsFixture(db, realDav: !factory); + await f.addParent(provider); + final grant = provider == BusyProvider.google + ? 'https://www.googleapis.com/auth/contacts' + : 'Contacts.ReadWrite'; + f.consentScopes = provider == BusyProvider.google + ? 'calendar tasks $grant' + : 'Calendars.ReadWrite Tasks.ReadWrite $grant'; + await f.controller.enableLinkedContacts(provider.name); + final id = 'contacts:${provider.name}'; + await f.controller.synchronizeAccount(id); + final source = (await f.controller.sourceSettings()).first.source; + final record = (await f.store.read((tx) => tx.contacts())).first; + final native = record.toJson(); + final account = (await f.store.read((tx) => tx.account(id)))!; + final cursor = (await f.store.read((tx) => tx.syncState(source.key)))! + .cursor; + effective(source); + expect(account.grantedScopes, contains(grant)); + await f.store.setSourceEnabled(source.key, enabled: false); + final journals = >{}; + await f.store.write((tx) async { + for (final state in [ + ContactMutationState.queued, + ContactMutationState.executing, + ContactMutationState.unknown, + ContactMutationState.conflict, + ]) { + final mutation = ContactMutation( + id: state.name, + accountId: id, + sourceKey: source.key, + kind: ContactMutationKind.providerAction, + state: state, + createdAt: DateTime.utc(2026), + errorCode: 'keep-${state.name}', + payload: const { + 'type': 'historical', + 'evidence': 'remote outcome uncertain', + }, + ); + journals[state.name] = mutation.toJson(); + await tx.putMutation(mutation); + } + }); + // Schema-20 persisted source from an earlier writable build. + final old = { + ...source.toJson(), + 'rename': true, + 'remove': true, + 'capabilities': { + ...source.capabilities.toJson(), + 'create': true, + 'update': true, + 'delete': true, + 'photoWrite': true, + }, + }; + await db.customStatement( + 'UPDATE bm_contact_sources SET data = ? WHERE source_key = ?', + [jsonEncode(old), source.key], + ); + await f.controller.close(); + final restarted = ContactsFixture( + db, + realDav: !factory, + syncInterval: const Duration(milliseconds: 30), + ); + addTearDown(restarted.close); + restarted.offline = true; + for (final setting in await restarted.controller.sourceSettings()) { + effective(setting.source); + } + await restarted.controller.start(); + expect(await restarted.controller.suggestAttendees('ada'), isEmpty); + expect(await restarted.store.sourceEnabled(source.key), isFalse); + expect( + { + ...(await restarted.store.read((tx) => tx.account(id)))!.toJson(), + 'error': null, + }, + {...account.toJson(), 'error': null}, + ); + expect( + (await restarted.store.read((tx) => tx.contact(record.identity)))! + .toJson(), + native, + ); + expect( + (await restarted.store.read((tx) => tx.syncState(source.key)))! + .cursor, + cursor, + ); + final row = await db + .customSelect('SELECT data FROM bm_contact_sources') + .getSingle(); + effective( + ContactSource.fromJson( + (jsonDecode(row.read('data')) as Map).cast(), + ), + ); + restarted.offline = false; + await restarted.controller.synchronizeAccount(id); + await Future.delayed(const Duration(milliseconds: 100)); + for (final mutation in await restarted.store.read( + (tx) => tx.mutations(), + )) { + if (mutation.id == 'executing') { + expect(mutation.state, ContactMutationState.unknown); + expect(mutation.errorCode, 'interrupted-after-dispatch'); + final expected = { + ...journals[mutation.id]!, + 'state': 'unknown', + 'error': 'interrupted-after-dispatch', + }; + expect(mutation.toJson(), expected); + } else { + expect(mutation.toJson(), journals[mutation.id]); + } + } + await restarted.store.setSourceEnabled(source.key, enabled: true); + await restarted.controller.reconnectContactsAccount(id); + await restarted.controller.synchronizeAccount(id); + for (final setting in await restarted.controller.sourceSettings()) { + effective(setting.source); + } + final before = restarted.requests.length; + for (final call in [ + () => restarted.controller.directory.create( + source.key, + const ContactPatch(), + ), + () => restarted.controller.directory.update( + record, + const ContactPatch( + name: FieldEdit.set(ContactName(display: 'Changed')), + ), + ), + () => restarted.controller.directory.delete(record), + () => restarted.controller.directory.setPhoto( + record, + null, + 'image/png', + ), + ]) { + await expectLater(call(), throwsA(isA())); + } + final rejectedAction = await restarted.controller.directory + .providerAction( + id, + provider == BusyProvider.google + ? const GoogleGroupAction( + GoogleGroupOperation.create, + name: 'New', + ) + : const MicrosoftFolderAction( + MicrosoftFolderOperation.create, + name: 'New', + ), + ); + expect(rejectedAction.state, ContactMutationState.failed); + expect(rejectedAction.errorCode, 'busymax-contacts-read-only'); + expect( + restarted.requests + .skip(before) + .where((request) => request.method != 'GET'), + isEmpty, + ); + expect( + restarted.requests.where((request) => request.method != 'GET'), + isEmpty, + ); + expect( + (await restarted.store.read((tx) => tx.mutations())) + .where((m) => journals.containsKey(m.id)), + hasLength(4), + ); + }, + ); + } + } + test('old DAV password/config and writable markers survive offline restart and read-only reconnect', () async { + final db = AppDatabase.memoryForTests(); + final f = ContactsFixture(db, realDav: true); + final dav = DavFixture(); + f.respondDav = dav.respond; + final account = await f.controller.addCardDavContactsOnly( + id: 'legacy-dav', + label: 'DAV', + server: server, + username: 'actual-user', + password: 'original-password', + ); + final config = jsonDecode(f.davSecrets[account.id]!) as Map; + config['readOnly'] = false; + final encoded = jsonEncode(config); + f.davSecrets[account.id] = encoded; + await f.store.write( + (tx) => tx.putAccount( + ContactAccount( + id: account.id, + provider: account.provider, + displayName: account.displayName, + subject: account.subject, + grantedScopes: const {'carddav:write'}, + ), + ), + ); + final records = (await f.store.read((tx) => tx.contacts())) + .map((c) => c.toJson()) + .toList(); + await f.controller.close(); + final restarted = ContactsFixture(db, realDav: true); + addTearDown(restarted.close); + restarted.davSecrets.addAll(f.davSecrets); + restarted.respondDav = dav.respond; + dav.offline = true; + await restarted.controller.start(); + expect(restarted.davSecrets[account.id], encoded); + expect( + (await restarted.store.read((tx) => tx.contacts())) + .map((c) => c.toJson()), + records, + ); + for (final setting in await restarted.controller.sourceSettings()) { + effective(setting.source); + } + dav.offline = false; + await restarted.controller.reconnectDavContactsOnly(account.id); + await restarted.controller.synchronizeAccount(account.id); + expect(restarted.davSecrets[account.id], encoded); + expect( + (await restarted.store.read((tx) => tx.account(account.id)))! + .grantedScopes, + {'carddav:write'}, + ); + expect(dav.puts, 0); + await restarted.controller.removeContactsAccount(account.id); + expect( + dav.requests.where( + (r) => ['PUT', 'DELETE', 'MKCOL', 'PROPPATCH'].contains(r.method), + ), + isEmpty, + ); + expect(dav.card, rawCard); + }); +} diff --git a/test/contacts/parent_removal_lifecycle_test.dart b/test/contacts/parent_removal_lifecycle_test.dart new file mode 100644 index 00000000..e86c39e1 --- /dev/null +++ b/test/contacts/parent_removal_lifecycle_test.dart @@ -0,0 +1,1125 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart' + show QueryInterceptor, QueryExecutor, TransactionExecutor, ApplyInterceptor; +import 'package:drift/native.dart'; +import 'package:busymax/src/db/app_database.dart'; + +import 'package:busystack_contacts/google.dart'; +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; +import 'package:busymax/src/contacts/busymax_contacts_store.dart'; +import 'package:busymax/src/core/auth/account_token_broker.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/core/auth/oauth_registration.dart'; +import 'package:busymax/src/core/auth/microsoft_graph_scopes.dart'; +import 'package:busymax/src/features/auth/data/auth_repository.dart'; +import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../core/auth/authorization_transition_test.dart' + show Harness, FailureStore, tenant, fixtureIdToken; + +/// Pauses actual serialization, without replacing persistence or its queue. +class RemovalGate implements CrossEngineAccountGate { + bool capture = false; + final readFinished = Completer(), releaseRead = Completer(); + final removalEntered = Completer(), releaseRemoval = Completer(); + int calls = 0; + @override + Future run(String id, Future Function() operation) async { + if (!capture) return operation(); + final call = ++calls; + if (call == 1) { + final result = await operation(); + readFinished.complete(); + await releaseRead.future; + return result; + } + if (call == 2) { + removalEntered.complete(); + await releaseRemoval.future; + } + return operation(); + } +} + +/// Faults only the real preparation SQL/COMMIT, never parent deletion or secrets. +class PreparationFault extends QueryInterceptor { + String? contactId; + String? phase; + bool persistent = false; + final failure = StateError('original-preparation-failure'); + final recoveryFailure = StateError('unresolved-detach-journal'); + QueryExecutor? disableTransaction; + int hits = 0; + + void fail() { + hits++; + if (!persistent) phase = null; + throw hits == 1 ? failure : recoveryFailure; + } + + @override + Future runCustom( + QueryExecutor executor, + String statement, + List args, + ) async { + final data = args + .whereType() + .where((s) => s.startsWith('{')) + .map((s) => jsonDecode(s) as Map) + .firstOrNull; + if (data != null && + data['id'] == contactId && + data['enabled'] == false && + statement.startsWith('INSERT INTO bm_contact_accounts')) { + if (phase == 'disable-write') { + fail(); + } + if (phase == 'disable-commit') disableTransaction = executor; + } + if (phase == 'detach-journal' && + data?['account'] == contactId && + data?['state'] == 'unknown' && + statement.startsWith('INSERT INTO bm_contact_mutations')) { + fail(); + } + await super.runCustom(executor, statement, args); + } + + @override + Future commitTransaction(TransactionExecutor inner) async { + if (identical(inner, disableTransaction)) { + disableTransaction = null; + fail(); + } + await super.commitTransaction(inner); + } + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + if (phase == 'account-read' && + statement == 'SELECT data FROM bm_contact_accounts WHERE id = ?' && + args.singleOrNull == contactId) { + fail(); + } + return super.runSelect(executor, statement, args); + } +} + +class LifecycleFixture { + LifecycleFixture( + this.provider, { + RemovalGate? gate, + AppDatabase? database, + FailureStore? secrets, + }) : h = Harness(gate: gate, database: database, secretStore: secrets) { + store = BusyMaxContactsStore(h.db); + controller = BusyMaxContactsController( + store: store, + accounts: h.accounts, + authorization: DesktopAccountTokenBroker( + google: h.google, + microsoft: h.microsoft, + ), + readAuthorizationGeneration: h.persistence.generation, + httpClient: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode( + provider == BusyProvider.google + ? { + 'connections': [], + 'contactGroups': [], + 'nextSyncToken': 'complete', + } + : {'value': []}, + ), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + readDavSecret: (_) async => null, + writeDavSecret: (_, value) async {}, + deleteDavSecret: (_) async {}, + readLinkedDavCredential: (_) async => null, + launchBrowser: (_) async => true, + ); + repository = AuthRepository( + oAuth: h.google, + microsoftOAuth: h.microsoft, + database: h.db, + authorizationPersistence: h.persistence, + onAccountRemovalStarting: controller.beginLinkedAccountRemoval, + onAccountRemoving: (id) async { + await controller.prepareLinkedAccountRemoval(id); + await afterPrepare?.call(); + }, + persistLinkedAccountRemoval: controller.persistLinkedAccountRemoval, + onAccountRemovalFailed: (id) async { + await beforeRecovery?.call(); + await controller.restoreLinkedAccountAfterFailedRemoval(id); + }, + onAccountRemoved: controller.linkedAccountRemovalCommitted, + onAccountRemovalFinished: controller.finishLinkedAccountRemoval, + ); + h.beforeRequest = (request) async { + if (request.method != 'POST' || + !request.body.contains('authorization_code')) { + return null; + } + return http.Response( + jsonEncode({ + 'access_token': 'candidate', + 'refresh_token': 'candidate-refresh', + 'expires_in': 3600, + 'scope': h.flow.lastScope, + if (provider == BusyProvider.microsoft) + 'id_token': fixtureIdToken( + Uri.splitQueryString(request.body)['client_id']!, + ), + }), + 200, + ); + }; + } + final BusyProvider provider; + final Harness h; + late final BusyMaxContactsStore store; + late final BusyMaxContactsController controller; + late final AuthRepository repository; + final requests = []; + Future Function()? afterPrepare, beforeRecovery; + String get id => + provider == BusyProvider.google ? 'opaque' : 'microsoft:ms-user'; + String get contactId => 'contacts:$id'; + Future seed({bool granted = true, bool? enabled}) async { + if (provider == BusyProvider.google) { + await h.seed(); + } else { + await h.seedMicrosoft(shared: false); + } + final credential = + await h.secrets.readCredential(id) as BoundOAuthSecretRecord; + final scope = provider == BusyProvider.google + ? googleContactsReadScope + : 'Contacts.Read'; + if (granted) { + await h.secrets.saveCredential( + id, + credential.withTokens( + credential.tokenSet.copyWith( + scopes: {...credential.tokenSet.scopes, scope}, + ), + ), + ); + await h.accounts.upsertSignedInAccount( + id: id, + provider: provider, + providerAccountId: credential.subject, + tenantId: provider == BusyProvider.microsoft ? tenant : null, + grantedScopes: {...credential.tokenSet.scopes, scope}.join(' '), + ); + } + if (granted) { + await controller.authorization.contactsAuthorizationHeader(provider, id); + } + await controller.start(); + if (enabled != null) { + await controller.enableLinkedContacts(id); + await controller.synchronizeAccount(contactId); + if (!enabled) await controller.disableContactsAccount(contactId); + } + } + + Future close() async { + await controller.close().timeout(const Duration(seconds: 3)); + h.staging.dispose(); + await h.db.close(); + } + + Future seedUncertainMutations({bool queued = false}) async { + final source = (await store.read((tx) => tx.sources(accountId: contactId))) + .first; + await store.write((tx) async { + for (final state in [ + ContactMutationState.executing, + ContactMutationState.unknown, + if (queued) ContactMutationState.queued, + ]) { + await tx.putMutation( + ContactMutation( + id: state.name, + accountId: contactId, + sourceKey: source.key, + kind: ContactMutationKind.update, + state: state, + createdAt: DateTime.utc(2026, 10, 11), + payload: const {'fixture': 'unresolved-work'}, + ), + ); + } + }); + } +} + +void main() { + test( + 'revoked Google authorization never restores contacts after local rollback', + () async { + final f = LifecycleFixture(BusyProvider.google); + addTearDown(f.close); + await f.seed(enabled: true); + await f.h.db.customStatement( + "CREATE TRIGGER reject_removal BEFORE DELETE ON accounts BEGIN SELECT RAISE(ABORT, 'failure after remote revocation'); END", + ); + final requests = f.requests.length; + await expectLater( + f.repository.removeAccount(accountId: f.id, revokeAuthorization: true), + throwsA(isA()), + ); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))!.enabled, + isFalse, + ); + expect(f.requests.length, requests); + await expectLater( + f.controller.directory.discover(f.contactId), + throwsA(isA()), + ); + }, + ); + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + '$provider broad credentials and queued writes remain read-only after actual removal rollback', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: true); + final credential = + await f.h.secrets.readCredential(f.id) as BoundOAuthSecretRecord; + final grant = provider == BusyProvider.google + ? googleContactsWriteScope + : 'Contacts.ReadWrite'; + final scopes = {...credential.tokenSet.scopes, grant}; + await f.h.secrets.saveCredential( + f.id, + credential.withTokens(credential.tokenSet.copyWith(scopes: scopes)), + ); + await f.h.accounts.upsertSignedInAccount( + id: f.id, + provider: provider, + providerAccountId: credential.subject, + tenantId: provider == BusyProvider.microsoft ? tenant : null, + grantedScopes: scopes.join(' '), + ); + await f.controller.reconnectContactsAccount(f.contactId); + await f.controller.synchronizeAccount(f.contactId); + await f.seedUncertainMutations(queued: true); + await f.h.db.customStatement( + "CREATE TRIGGER reject_removal BEFORE DELETE ON accounts BEGIN SELECT RAISE(ABORT, 'rollback'); END", + ); + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA( + isA().having( + (error) => error.toString(), + 'reason', + contains('rollback'), + ), + ), + ); + await f.controller.synchronizeAccount(f.contactId); + final account = (await f.store.read((tx) => tx.account(f.contactId)))!; + expect(account.enabled, isTrue); + expect(account.grantedScopes, contains(grant)); + for (final source in await f.store.read( + (tx) => tx.sources(accountId: f.contactId), + )) { + expect(source.capabilities.create, isFalse); + expect(source.capabilities.update, isFalse); + expect(source.capabilities.delete, isFalse); + expect(source.capabilities.photoWrite, isFalse); + expect(source.canRename, isFalse); + expect(source.canRemove, isFalse); + } + final journal = await f.store.read( + (tx) => tx.mutations(accountId: f.contactId), + ); + expect( + journal.singleWhere((m) => m.id == 'queued').state, + ContactMutationState.queued, + ); + expect( + journal.singleWhere((m) => m.id == 'executing').state, + ContactMutationState.unknown, + ); + expect( + journal.singleWhere((m) => m.id == 'unknown').state, + ContactMutationState.unknown, + ); + expect(f.requests.every((request) => request.method == 'GET'), isTrue); + }, + ); + for (final phase in ['disable-commit', 'detach-journal']) { + test( + '$provider preparation $phase failure repairs partial detachment', + () async { + final fault = PreparationFault(); + final f = LifecycleFixture( + provider, + database: AppDatabase(NativeDatabase.memory().interceptWith(fault)), + ); + addTearDown(f.close); + await f.seed(enabled: true); + if (phase == 'detach-journal') await f.seedUncertainMutations(); + final before = (await f.store.read((tx) => tx.account(f.contactId)))! + .toJson(); + final credential = (await f.h.secrets.readCredential(f.id))!.toJson(); + final requests = f.requests.length; + fault + ..contactId = f.contactId + ..phase = phase; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(same(fault.failure)), + ); + expect(fault.hits, 1); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))!.toJson(), + before, + ); + expect( + (await f.h.secrets.readCredential(f.id))!.toJson(), + credential, + ); + expect(await f.h.accounts.accountById(f.id), isNotNull); + await f.controller.synchronizeAccount(f.contactId); + expect(f.requests.length, greaterThan(requests)); + expect(f.h.flow.clients, isEmpty); + if (phase == 'detach-journal') { + final mutations = await f.store.read( + (tx) => tx.mutations(accountId: f.contactId), + ); + expect(mutations, hasLength(2)); + expect( + mutations.map((m) => m.state), + everyElement(ContactMutationState.unknown), + ); + expect( + mutations.singleWhere((m) => m.id == 'executing').errorCode, + 'disconnected-after-dispatch', + ); + expect(f.requests.every((r) => r.method == 'GET'), isTrue); + } + }, + ); + } + test( + '$provider preparation unresolved detach journal retains work and original error', + () async { + final fault = PreparationFault(); + final f = LifecycleFixture( + provider, + database: AppDatabase(NativeDatabase.memory().interceptWith(fault)), + ); + addTearDown(f.close); + await f.seed(enabled: true); + await f.seedUncertainMutations(queued: true); + final requests = f.requests.length; + fault + ..contactId = f.contactId + ..phase = 'detach-journal' + ..persistent = true; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(same(fault.failure)), + ); + expect(fault.hits, 2); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))!.errorCode, + 'contacts-removal-recovery-required', + ); + await expectLater( + f.controller.synchronizeAccount(f.contactId), + throwsA( + isA().having( + (e) => e.code, + 'actionable recovery', + 'contacts-removal-recovery-required', + ), + ), + ); + expect(f.requests.length, requests); + final mutations = await f.store.read( + (tx) => tx.mutations(accountId: f.contactId), + ); + expect( + {for (final m in mutations) m.id: m.state}, + { + 'executing': ContactMutationState.executing, + 'unknown': ContactMutationState.unknown, + 'queued': ContactMutationState.queued, + }, + ); + expect(await f.h.accounts.accountById(f.id), isNotNull); + expect(await f.h.secrets.readCredential(f.id), isNotNull); + fault.phase = null; + await f.controller.close().timeout(const Duration(seconds: 3)); + final restartedStore = BusyMaxContactsStore(f.h.db); + expect( + (await restartedStore.read((tx) => tx.account(f.contactId)))! + .errorCode, + 'contacts-removal-recovery-required', + ); + expect( + await restartedStore.read( + (tx) => tx.mutations(accountId: f.contactId), + ), + hasLength(3), + ); + await restartedStore.close(); + }, + ); + for (final enabled in [false, null]) { + test( + '$provider preparation read failure leaves disabled/absent contacts enabled=$enabled', + () async { + final fault = PreparationFault(); + final f = LifecycleFixture( + provider, + database: AppDatabase(NativeDatabase.memory().interceptWith(fault)), + ); + addTearDown(f.close); + await f.seed(enabled: enabled); + final before = await f.store.read((tx) => tx.account(f.contactId)); + final credential = (await f.h.secrets.readCredential(f.id))!.toJson(); + final requests = f.requests.length; + fault + ..contactId = f.contactId + ..phase = 'account-read'; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(same(fault.failure)), + ); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))?.toJson(), + before?.toJson(), + ); + expect( + (await f.h.secrets.readCredential(f.id))!.toJson(), + credential, + ); + expect(f.requests.length, requests); + expect(f.h.flow.clients, isEmpty); + expect(await f.h.accounts.accountById(f.id), isNotNull); + }, + ); + } + for (final newer in ['disable', 'remove', 'replacement']) { + test('$provider preparation recovery respects newer $newer', () async { + final fault = PreparationFault(); + final f = LifecycleFixture( + provider, + database: AppDatabase(NativeDatabase.memory().interceptWith(fault)), + ); + addTearDown(f.close); + await f.seed(enabled: true); + final waiting = Completer(), resume = Completer(); + f.beforeRecovery = () async { + waiting.complete(); + await resume.future; + }; + fault + ..contactId = f.contactId + ..phase = 'disable-write'; + final removed = f.repository.removeAccount(accountId: f.id); + final observed = expectLater(removed, throwsA(same(fault.failure))); + await waiting.future; + final requests = f.requests.length; + if (newer == 'disable') { + await f.controller.disableContactsAccount(f.contactId); + } else if (newer == 'remove') { + await f.controller.removeContactsAccount(f.contactId); + } else { + await f.store.write((tx) async { + final current = (await tx.account(f.contactId))!; + await tx.putAccount( + ContactAccount( + id: current.id, + provider: current.provider, + displayName: 'replacement', + subject: 'new-subject', + grantedScopes: current.grantedScopes, + enabled: false, + generation: current.generation + 1, + errorCode: 'newer-state', + ), + ); + }); + } + final newerState = (await f.store.read((tx) => tx.account(f.contactId))) + ?.toJson(); + resume.complete(); + await observed; + expect( + (await f.store.read((tx) => tx.account(f.contactId)))?.toJson(), + newerState, + ); + expect(f.requests.length, requests); + expect(await f.h.accounts.accountById(f.id), isNotNull); + }); + } + for (final phase in ['before-write', 'after-write']) { + test('$provider preparation disable failure $phase restores running binding', () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: true); + final parent = (await (f.h.db.select( + f.h.db.accounts, + )..where((row) => row.id.equals(f.id))).getSingle()).toJson(); + final credential = (await f.h.secrets.readCredential(f.id))!.toJson(); + final before = (await f.store.read((tx) => tx.account(f.contactId)))!; + final generation = await f.h.persistence.generation(f.id); + final requests = f.requests.length; + expect(requests, greaterThan(0)); + await f.h.db.customStatement( + 'CREATE TRIGGER reject_contact_disable ' + '${phase == 'before-write' ? 'BEFORE' : 'AFTER'} UPDATE ON bm_contact_accounts ' + "WHEN NEW.id = '${f.contactId}' AND json_extract(NEW.data, '\$.enabled') = 0 " + "BEGIN SELECT RAISE(ABORT, 'preparation-disable-failure'); END", + ); + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA( + predicate( + (error) => + error.toString().contains('preparation-disable-failure'), + ), + ), + ); + expect( + (await (f.h.db.select( + f.h.db.accounts, + )..where((row) => row.id.equals(f.id))).getSingle()).toJson(), + parent, + ); + expect((await f.h.secrets.readCredential(f.id))!.toJson(), credential); + expect(await f.h.persistence.generation(f.id), generation); + final after = (await f.store.read((tx) => tx.account(f.contactId)))!; + expect(after.toJson(), before.toJson()); + await f.h.db.customStatement('DROP TRIGGER reject_contact_disable'); + await f.controller.synchronizeAccount(f.contactId); + expect(f.requests.length, greaterThan(requests)); + expect(f.h.flow.clients, isEmpty); + await f.controller.close().timeout(const Duration(seconds: 3)); + expect(await f.h.accounts.accountById(f.id), isNotNull); + }); + } + test( + '$provider fresh consent positive commits parent grants and contacts together', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(granted: false); + await f.controller.enableLinkedContacts(f.id); + await f.controller.synchronizeAccount(f.contactId); + final contacts = (await f.store.read((tx) => tx.account(f.contactId)))!; + final credential = + await f.h.secrets.readCredential(f.id) as BoundOAuthSecretRecord; + expect(contacts.enabled, isTrue); + expect( + contacts.grantedScopes.map(microsoftGraphScopeName), + containsAll(credential.tokenSet.scopes.map(microsoftGraphScopeName)), + ); + expect( + (await f.h.accounts.accountById(f.id))!.grantedScopes.split(' '), + containsAll(credential.tokenSet.scopes), + ); + expect(f.requests, isNotEmpty); + }, + ); + for (final granted in [true, false]) { + test( + '$provider ${granted ? 'already granted' : 'fresh consent'} enrollment queued behind removal drains', + () async { + final gate = RemovalGate(); + final f = LifecycleFixture(provider, gate: gate); + addTearDown(f.close); + await f.seed(granted: granted); + gate.capture = true; + final enrollment = f.controller.enableLinkedContacts(f.id); + final enrollmentResult = expectLater( + enrollment, + throwsA(isA()), + ); + await gate.readFinished.future; + final removal = f.repository.removeAccount(accountId: f.id); + // Let the empty recovery-journal query enqueue removal while the actual + // credential reader still owns the boundary. No provider work is guessed. + await f.h.persistence.recover(); + await Future.delayed(Duration.zero); + gate.releaseRead.complete(); + await gate.removalEntered.future; + await Future.delayed(Duration.zero); + gate.releaseRemoval.complete(); + await removal.timeout(const Duration(seconds: 3)); + await enrollmentResult; + expect(await f.h.accounts.accountById(f.id), isNull); + expect(await f.h.secrets.readCredential(f.id), isNull); + expect(await f.store.read((tx) => tx.account(f.contactId)), isNull); + await f.controller.close().timeout(const Duration(seconds: 3)); + await f.h.persistence.recover(); + expect(await f.h.secrets.readCredential(f.id), isNull); + }, + ); + } + test( + '$provider fresh consent queues staged commit behind the removal boundary', + () async { + final gate = RemovalGate(); + final f = LifecycleFixture(provider, gate: gate); + addTearDown(f.close); + await f.seed(granted: false); + final ready = Completer(), consent = Completer(); + final response = f.h.beforeRequest; + f.h.beforeRequest = (request) async { + if (request.method == 'GET') { + ready.complete(); + await consent.future; + return null; + } + return response?.call(request); + }; + var staged = 0; + f.h.secrets.beforeSave = (id, record) async { + if (id == f.id && + record is BoundOAuthSecretRecord && + record.generation > 1) { + staged++; + } + }; + final enrolled = f.controller.enableLinkedContacts(f.id); + final observed = expectLater(enrolled, throwsA(isA())); + await ready.future; + gate.capture = true; + gate.calls = 1; + final removed = f.repository.removeAccount(accountId: f.id); + await gate.removalEntered.future; + await expectLater( + f.controller.enableLinkedContacts(f.id), + throwsA(isA()), + ); + consent.complete(); + await Future.delayed(Duration.zero); + gate.releaseRemoval.complete(); + await removed.timeout(const Duration(seconds: 3)); + await observed; + expect(staged, lessThanOrEqualTo(1)); + expect(await f.h.db.select(f.h.db.authorizationCommits).get(), isEmpty); + expect(await f.h.secrets.readCredential(f.id), isNull); + expect(await f.store.read((tx) => tx.account(f.contactId)), isNull); + }, + ); + test( + '$provider cancellation after secure staging rolls back before parent removal', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(granted: false); + final staged = Completer(), release = Completer(); + f.h.secrets.afterSave = (id, record) async { + if (id == f.id && + record is BoundOAuthSecretRecord && + record.generation > 1) { + staged.complete(); + await release.future; + } + }; + final enrolled = f.controller.enableLinkedContacts(f.id); + final observed = expectLater(enrolled, throwsA(isA())); + await staged.future; + final removed = f.repository.removeAccount(accountId: f.id); + final disabled = f.controller.disableContactsAccount(f.contactId); + release.complete(); + await observed; + await disabled; + await removed.timeout(const Duration(seconds: 3)); + expect(await f.h.secrets.readCredential(f.id), isNull); + expect(await f.store.read((tx) => tx.account(f.contactId)), isNull); + expect(await f.h.db.select(f.h.db.authorizationCommits).get(), isEmpty); + }, + ); + test( + '$provider incomplete credential rollback does not grant contacts recovery authority', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: true); + final credential = + await f.h.secrets.readCredential(f.id) as BoundOAuthSecretRecord; + final candidate = provider == BusyProvider.google + ? GoogleDesktopCredential( + registration: + credential.registration as GoogleDesktopRegistration, + tokenSet: credential.tokenSet, + subject: credential.subject, + generation: 2, + transitionEligible: credential.transitionEligible, + ) + : MicrosoftDesktopCredential( + registration: + credential.registration as MicrosoftPublicRegistration, + tokenSet: credential.tokenSet, + subject: credential.subject, + tenantId: tenant, + generation: 2, + transitionEligible: false, + ); + f.afterPrepare = () async { + f.h.secrets.beforeSave = (id, record) async { + if (id == f.id && + record is BoundOAuthSecretRecord && + record.generation == 1) { + throw StateError('credential rollback unavailable'); + } + }; + await expectLater( + f.h.persistence.commit( + accountId: f.id, + expectedGeneration: 1, + candidate: candidate, + requireExisting: true, + persistAccount: () async { + throw StateError('account metadata failure'); + }, + ), + throwsA(isA()), + ); + }; + final requests = f.requests.length; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(isA()), + ); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))!.enabled, + isFalse, + ); + expect(f.requests.length, requests); + expect( + await f.h.db.select(f.h.db.authorizationCommits).get(), + hasLength(1), + ); + f.h.secrets.beforeSave = null; + await f.h.persistence.recover(); + expect( + (await f.h.secrets.readCredential(f.id))!.toJson(), + credential.toJson(), + ); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))!.enabled, + isFalse, + ); + }, + ); + test( + '$provider post-commit credential cleanup failure never restores removed contacts', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: true); + var deletions = 0; + f.h.secrets.beforeDelete = (id) async { + if (id == f.id && ++deletions == 2) { + f.h.secrets.beforeDelete = null; + throw StateError('cleanup failed after durable deletion'); + } + }; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(isA()), + ); + expect(await f.h.accounts.accountById(f.id), isNull); + expect(await f.store.read((tx) => tx.account(f.contactId)), isNull); + await f.controller.restoreLinkedAccountAfterFailedRemoval(f.id); + await f.h.persistence.recover(); + expect(await f.h.secrets.readCredential(f.id), isNull); + }, + ); + for (final newer in [ + 'disable', + 'remove', + 'contacts-replacement', + 'authorization-replacement', + ]) { + test('$provider older recovery cannot overwrite newer $newer', () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: true); + final waiting = Completer(), resume = Completer(); + if (newer == 'authorization-replacement') { + f.afterPrepare = () async { + waiting.complete(); + await resume.future; + }; + } else { + f.beforeRecovery = () async { + waiting.complete(); + await resume.future; + }; + f.h.secrets.beforeDelete = (id) async { + if (id == f.id) { + f.h.secrets.beforeDelete = null; + throw StateError('reversible failure'); + } + }; + } + final removed = f.repository.removeAccount(accountId: f.id); + final observed = expectLater(removed, throwsA(isA())); + await waiting.future; + final requests = f.requests.length; + if (newer == 'disable') { + await f.controller.disableContactsAccount(f.contactId); + } else if (newer == 'remove') { + await f.controller.removeContactsAccount(f.contactId); + } else if (newer == 'contacts-replacement') { + await f.store.write((tx) async { + final current = (await tx.account(f.contactId))!; + await tx.putAccount( + current.copy( + generation: current.generation + 1, + enabled: false, + errorCode: 'newer-state', + ), + ); + }); + } else { + final old = + await f.h.secrets.readCredential(f.id) as BoundOAuthSecretRecord; + final candidate = provider == BusyProvider.google + ? GoogleDesktopCredential( + registration: old.registration as GoogleDesktopRegistration, + tokenSet: old.tokenSet, + subject: old.subject, + generation: old.generation + 1, + transitionEligible: old.transitionEligible, + ) + : MicrosoftDesktopCredential( + registration: old.registration as MicrosoftPublicRegistration, + tokenSet: old.tokenSet, + subject: old.subject, + tenantId: tenant, + generation: old.generation + 1, + transitionEligible: old.transitionEligible, + ); + await f.h.persistence.commit( + accountId: f.id, + expectedGeneration: old.generation, + candidate: candidate, + requireExisting: true, + persistAccount: () async {}, + ); + } + resume.complete(); + await observed; + final current = await f.store.read((tx) => tx.account(f.contactId)); + expect(current?.enabled, newer == 'remove' ? isNull : isFalse); + expect(f.requests.length, requests); + if (current != null) { + await expectLater( + f.controller.directory.discover(f.contactId), + throwsA(isA()), + ); + } + if (newer == 'authorization-replacement') { + expect(await f.h.persistence.generation(f.id), 2); + } + }); + } + for (final enabled in [true, false, null]) { + test( + '$provider successful removal enabled=$enabled never restores and leaves another account usable', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: enabled); + final credential = + await f.h.secrets.readCredential(f.id) as BoundOAuthSecretRecord; + final other = provider == BusyProvider.google + ? GoogleDesktopCredential( + registration: + credential.registration as GoogleDesktopRegistration, + tokenSet: credential.tokenSet, + subject: 'other', + generation: 1, + transitionEligible: false, + ) + : MicrosoftDesktopCredential( + registration: + credential.registration as MicrosoftPublicRegistration, + tokenSet: credential.tokenSet, + subject: 'other', + tenantId: tenant, + generation: 1, + transitionEligible: false, + ); + await f.h.persistence.commit( + accountId: 'unrelated', + expectedGeneration: 0, + candidate: other, + persistAccount: () => f.h.accounts.upsertSignedInAccount( + id: 'unrelated', + provider: provider, + providerAccountId: 'other', + tenantId: provider == BusyProvider.microsoft ? tenant : null, + grantedScopes: credential.tokenSet.scopes.join(' '), + ), + ); + await f.controller.enableLinkedContacts('unrelated'); + await f.controller.synchronizeAccount('contacts:unrelated'); + final removed = f.repository.removeAccount(accountId: f.id); + expect( + identical(removed, f.repository.removeAccount(accountId: f.id)), + isTrue, + ); + await removed; + await f.controller.restoreLinkedAccountAfterFailedRemoval(f.id); + await f.h.persistence.recover(); + expect(await f.h.accounts.accountById(f.id), isNull); + expect(await f.h.secrets.readCredential(f.id), isNull); + expect(await f.store.read((tx) => tx.account(f.contactId)), isNull); + await f.controller.synchronizeAccount('contacts:unrelated'); + expect( + (await f.store.read((tx) => tx.account('contacts:unrelated')))! + .enabled, + isTrue, + ); + }, + ); + } + for (final enabled in [true, false, null]) { + test( + '$provider failed removal preserves settings and provider binding after disk restart enabled=$enabled', + () async { + final profile = await Directory.systemTemp.createTemp( + 'busymax-removal-restart-', + ); + addTearDown(() => profile.delete(recursive: true)); + final secrets = FailureStore(); + LifecycleFixture open() => LifecycleFixture( + provider, + database: AppDatabase( + NativeDatabase(File('${profile.path}/app.db')), + ), + secrets: secrets, + ); + var f = open(); + await f.seed(enabled: enabled); + f.h.secrets.beforeDelete = (id) async { + if (id == f.id) { + f.h.secrets.beforeDelete = null; + throw StateError('reversible storage failure'); + } + }; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(isA()), + ); + await f.close(); + f = open(); + addTearDown(f.close); + await f.h.persistence.recover(); + await f.controller.start(); + final current = await f.store.read((tx) => tx.account(f.contactId)); + expect(current?.enabled, enabled); + if (enabled == true) { + await f.controller.synchronizeAccount(f.contactId); + expect(f.requests, isNotEmpty); + } else { + await Future.delayed(Duration.zero); + expect(f.requests, isEmpty); + if (current != null) { + await expectLater( + f.controller.directory.discover(f.contactId), + throwsA(isA()), + ); + } + } + expect(await f.h.secrets.readCredential(f.id), isNotNull); + }, + ); + } + for (final enabled in [true, false, null]) { + for (final failure in ['secret', 'database']) { + test( + '$provider reversible $failure removal preserves contacts enabled=$enabled', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: enabled); + final before = await f.store.read((tx) => tx.account(f.contactId)); + final credential = (await f.h.secrets.readCredential(f.id))! + .toJson(); + if (failure == 'secret') { + f.h.secrets.beforeDelete = (id) async { + if (id == f.id) { + f.h.secrets.beforeDelete = null; + throw StateError('reversible secret deletion failure'); + } + }; + } else { + await f.h.db.customStatement( + "CREATE TRIGGER reject_removal BEFORE DELETE ON accounts BEGIN SELECT RAISE(ABORT, 'reversible deletion failure'); END", + ); + } + final requests = f.requests.length; + await expectLater( + f.repository.removeAccount(accountId: f.id), + throwsA(isA()), + ); + final after = await f.store.read((tx) => tx.account(f.contactId)); + expect(after?.enabled, before?.enabled); + expect(after?.subject, before?.subject); + expect(after?.grantedScopes, before?.grantedScopes); + expect( + (await f.h.secrets.readCredential(f.id))!.toJson(), + credential, + ); + if (enabled == true) { + await f.controller.synchronizeAccount(f.contactId); + expect(f.requests.length, greaterThan(requests)); + } else { + expect(f.requests.length, requests); + if (after != null) { + await expectLater( + f.controller.directory.discover(f.contactId), + throwsA(isA()), + ); + } + } + await f.controller.close(); + final restartedStore = BusyMaxContactsStore(f.h.db); + expect( + (await restartedStore.read((tx) => tx.account(f.contactId))) + ?.enabled, + enabled, + ); + await restartedStore.close(); + }, + ); + } + } + } +} diff --git a/test/contacts/read_only_contacts_provider_test.dart b/test/contacts/read_only_contacts_provider_test.dart new file mode 100644 index 00000000..b17cf0c2 --- /dev/null +++ b/test/contacts/read_only_contacts_provider_test.dart @@ -0,0 +1,277 @@ +import 'dart:typed_data'; + +import 'package:busystack_contacts/carddav.dart'; +import 'package:busystack_contacts/google.dart'; +import 'package:busystack_contacts/microsoft.dart'; +import 'package:busystack_dav/carddav.dart'; +import 'package:busymax/src/contacts/read_only_contacts_provider.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../support/contacts_fixture.dart'; +import '../support/dav_contacts_fixture.dart'; + +class _Spy extends Mock implements ContactsProvider {} + +void effective(ContactSource source) { + expect(source.capabilities.create, isFalse); + expect(source.capabilities.update, isFalse); + expect(source.capabilities.delete, isFalse); + expect(source.capabilities.photoWrite, isFalse); + expect(source.canRename, isFalse); + expect(source.canRemove, isFalse); +} + +Future rejectMutations( + ContactsProvider provider, + ContactSource source, + ContactRecord record, + List actions, +) async { + final token = ContactsCancellationToken(); + const patch = ContactPatch( + name: FieldEdit.set(ContactName(display: 'Changed')), + ); + final denied = throwsA( + isA().having( + (error) => error.code, + 'code', + 'busymax-contacts-read-only', + ), + ); + for (final attempt in [ + () => provider.create(source, patch, 'id', token, fullVcard: rawCard), + () => provider.update(source, record, patch, token), + () => provider.delete(source, record, token), + () => provider.setPhoto( + source, + record, + Uint8List.fromList([1]), + 'image/png', + token, + ), + for (final action in actions) () => provider.action(action, token), + ]) { + await expectLater(attempt(), denied); + } +} + +void main() { + test( + 'policy preserves unreadable source metadata and delegates read ownership', + () async { + final spy = _Spy(); + final token = ContactsCancellationToken(); + const source = ContactSource( + accountId: 'a', + id: 'book', + provider: ContactProviderKind.carddav, + kind: ContactSourceKind.carddavAddressBook, + name: 'Book', + remotePath: 'https://example.test/book/', + parentId: 'parent', + versions: ['3.0', '4.0'], + supportsSync: true, + maximumResourceBytes: 10000, + canRename: true, + canRemove: true, + capabilities: ContactCapabilities( + read: false, + create: true, + update: true, + delete: true, + photoRead: true, + photoWrite: true, + fields: {ContactField.name}, + limits: {ContactField.emails: 3}, + ), + ); + final identity = ContactIdentity( + accountId: 'a', + provider: source.provider, + sourceId: source.id, + remoteId: 'one.vcf', + ); + final vcard = parseVcard(rawCard); + final record = CardDavContactRecord( + identity: identity, + vcard: vcard, + revision: '"one"', + projection: projectVcard(identity, vcard), + ); + final batch = ContactsPage( + records: [record], + nextPage: 'page2', + cursor: 'cursor', + deleted: [identity], + fullEnumeration: true, + ); + final bytes = Uint8List.fromList([1, 2]); + final mutation = ContactMutation( + id: 'uncertain', + accountId: 'a', + sourceKey: source.key, + kind: ContactMutationKind.create, + state: ContactMutationState.unknown, + createdAt: DateTime.utc(2026), + ); + final action = CardDavBookAction( + CardDavBookOperation.delete, + uri: Uri.parse('https://example.test/book/'), + ); + when(() => spy.accountId).thenReturn('a'); + when(() => spy.kind).thenReturn(source.provider); + when(() => spy.discover(token)).thenAnswer((_) async => [source]); + when( + () => + spy.page(source, cursor: 'cursor', page: 'p', cancellation: token), + ).thenAnswer((_) async => batch); + when(() => spy.get(source, identity, token)) + .thenAnswer((_) async => record); + when(() => spy.photo(source, record, token)) + .thenAnswer((_) async => bytes); + when(() => spy.findCreated(source, mutation, token)) + .thenAnswer((_) async => record); + when(() => spy.decodeAction(action.toJson().cast())).thenReturn(action); + final boundary = ReadOnlyContactsProvider(spy); + expect(boundary.accountId, 'a'); + expect(boundary.kind, source.provider); + final normalized = (await boundary.discover(token)).single; + effective(normalized); + expect(normalized.capabilities.read, isFalse); + expect(normalized.toJson(), { + ...source.toJson(), + 'rename': false, + 'remove': false, + 'capabilities': { + ...source.capabilities.toJson(), + 'create': false, + 'update': false, + 'delete': false, + 'photoWrite': false, + }, + }); + expect( + await boundary.page( + source, + cursor: 'cursor', + page: 'p', + cancellation: token, + ), + same(batch), + ); + expect(await boundary.get(source, identity, token), same(record)); + expect(await boundary.photo(source, record, token), same(bytes)); + expect(await boundary.findCreated(source, mutation, token), same(record)); + expect(boundary.decodeAction(action.toJson().cast()), same(action)); + await rejectMutations(boundary, source, record, [action]); + // The spy has no mutation stubs: any delegate call would fail this contract. + verifyNever(() => spy.delete(source, record, token)); + boundary.close(); + verify(() => spy.close()).called(1); + }, + ); + for (final kind in [ + ContactProviderKind.google, + ContactProviderKind.microsoft, + ContactProviderKind.carddav, + ]) { + test( + 'actual $kind write-capable provider reads and rejects every mutation without transport', + () async { + final fixture = ContactsFixture(AppDatabase.memoryForTests()); + addTearDown(fixture.close); + final dav = DavFixture(); + final account = ContactAccount( + id: 'broad', + provider: kind, + displayName: 'Broad', + subject: 'owner', + grantedScopes: { + kind == ContactProviderKind.google + ? 'https://www.googleapis.com/auth/contacts' + : kind == ContactProviderKind.microsoft + ? 'Contacts.ReadWrite' + : 'carddav:write', + }, + ); + final provider = kind == ContactProviderKind.carddav + ? CardDavContactsProvider( + accountId: account.id, + cachedRecord: (_) async => null, + client: CardDavClient( + DavResourceClient( + accountId: account.id, + authority: server, + transport: DavHttpTransport( + profile: const DavProviderProfile(), + client: MockClient(dav.respond), + accountAuthority: server, + ), + credentialProvider: () async => + DavBasicCredential(username: 'u', password: 'p'), + ), + ), + ) + : fixture.provider(account); + final token = ContactsCancellationToken(); + final rawSources = await provider.discover(token); + expect(rawSources.any((source) => source.capabilities.create), isTrue); + final boundary = ReadOnlyContactsProvider(provider); + addTearDown(boundary.close); + final sources = await boundary.discover(token); + for (final source in sources) { + effective(source); + } + final source = rawSources.firstWhere( + (source) => source.capabilities.create, + ); + final batch = await boundary.page(source, cancellation: token); + final record = batch.records.first; + expect(record.identity.accountId, account.id); + expect(record.revision, isNotEmpty); + final before = fixture.requests.length + dav.requests.length; + await rejectMutations(boundary, source, record, [ + if (kind == ContactProviderKind.google) + boundary.decodeAction({ + 'type': 'googleFields', + 'identity': record.identity.toJson(), + 'revision': record.revision, + 'replacement': {'resourceName': record.identity.remoteId}, + 'fields': ['names'], + }), + if (kind == ContactProviderKind.google) + for (final operation in GoogleGroupOperation.values) + GoogleGroupAction( + operation, + resourceName: 'contactGroups/one', + name: 'Changed', + ), + if (kind == ContactProviderKind.microsoft) ...[ + for (final operation in MicrosoftFolderOperation.values) + MicrosoftFolderAction( + operation, + folderId: 'one', + name: 'Changed', + ), + MicrosoftFieldsAction( + identity: record.identity, + baseRevision: record.revision, + ), + ], + if (kind == ContactProviderKind.carddav) + for (final operation in CardDavBookOperation.values) + CardDavBookAction( + operation, + uri: server.resolve('books/u/book/'), + name: 'Changed', + ), + ]); + expect(fixture.requests.length + dav.requests.length, before); + expect(dav.puts, 0); + }, + ); + } +} diff --git a/test/core/auth/authorization_transition_test.dart b/test/core/auth/authorization_transition_test.dart index 1e9d33a5..f3772791 100644 --- a/test/core/auth/authorization_transition_test.dart +++ b/test/core/auth/authorization_transition_test.dart @@ -1,6 +1,9 @@ import '../../support/desktop_registration_config.dart'; import '../../support/native_registration_reader_fixture.dart'; + import 'package:busymax/src/core/auth/authorization_attempt.dart'; +import 'package:busymax/src/features/sync/account_sync_operations.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -345,9 +348,8 @@ void main() { addTearDown(() => directory.delete(recursive: true)); final selected = File('${directory.path}/selected.json'); final substitute = File('${directory.path}/substitute.json'); - final fixture = await File( - 'test/fixtures/oauth/desktop_synthetic.json', - ).readAsString(); + final fixture = await File('test/fixtures/oauth/desktop_synthetic.json') + .readAsString(); await selected.writeAsString(fixture); await substitute.writeAsString(fixture); final staging = RegistrationStaging( @@ -492,9 +494,8 @@ void main() { 'optional Microsoft consent captures cancellation during binding preparation shared=$shared', () async { await h.seedMicrosoft(shared: shared); - final before = (await h.secrets.readCredential( - 'microsoft:ms-user', - ))!.toJson(); + final before = (await h.secrets.readCredential('microsoft:ms-user'))! + .toJson(); final cancellation = AuthorizationCancellation(); final work = h.microsoft.authorizeCategoryAccess( 'microsoft:ms-user', @@ -520,10 +521,9 @@ void main() { ); await h.microsoft.authorizeCategoryAccess('microsoft:ms-user'); expect( - (await h.secrets.readCredential('microsoft:ms-user') - as MicrosoftDesktopCredential) - .tokenSet - .scopes, + (await h.secrets.readCredential( + 'microsoft:ms-user', + ) as MicrosoftDesktopCredential).tokenSet.scopes, contains(microsoftCategoryScope), ); }, @@ -535,37 +535,34 @@ void main() { expect(h.requests, isEmpty); expect(await h.accounts.listVisibleAccounts(), isEmpty); }); - test( - 'normal legacy binding during consent does not advance authorization generation', - () async { - await h.seed(); - await h.db.delete(h.db.authorizationGenerations).go(); - await h.db.delete(h.db.accountAuthorizations).go(); - await h.secrets.saveCredential( - 'opaque', - OAuthSecretRecord(provider: BusyProvider.google, tokenSet: tokens()), - ); - h.flow.barrier = Completer(); - final migration = h.repository.signIn( - request: AuthorizationRequest.replace('opaque', h.staging.stage(owned)), - ); - await h.flow.started.future; - final originalBound = await h.google.boundCredentialForAccount('opaque'); - expect(originalBound.generation, 0); - expect(await h.persistence.generation('opaque'), 0); - expect( - SecretRecord.fromJson(originalBound.toJson()), - isA(), - ); - h.flow.barrier!.complete(); - await migration; - final migrated = - await h.secrets.readCredential('opaque') as GoogleDesktopCredential; - expect(migrated.generation, 1); - expect(migrated.registration.clientId, owned.clientId); - expect(migrated.transitionEligible, false); - }, - ); + test('normal legacy binding during consent does not advance authorization generation', () async { + await h.seed(); + await h.db.delete(h.db.authorizationGenerations).go(); + await h.db.delete(h.db.accountAuthorizations).go(); + await h.secrets.saveCredential( + 'opaque', + OAuthSecretRecord(provider: BusyProvider.google, tokenSet: tokens()), + ); + h.flow.barrier = Completer(); + final migration = h.repository.signIn( + request: AuthorizationRequest.replace('opaque', h.staging.stage(owned)), + ); + await h.flow.started.future; + final originalBound = await h.google.boundCredentialForAccount('opaque'); + expect(originalBound.generation, 0); + expect(await h.persistence.generation('opaque'), 0); + expect( + SecretRecord.fromJson(originalBound.toJson()), + isA(), + ); + h.flow.barrier!.complete(); + await migration; + final migrated = + await h.secrets.readCredential('opaque') as GoogleDesktopCredential; + expect(migrated.generation, 1); + expect(migrated.registration.clientId, owned.clientId); + expect(migrated.transitionEligible, false); + }); test( 'committed binding receipt recovers without rolling back generation zero', () async { @@ -583,9 +580,8 @@ void main() { final journal = (await h.db.select(h.db.authorizationCommits).get()).single; expect( - AuthorizationRecoveryState.decode( - journal.previousNativeBindingJson, - ).committed, + AuthorizationRecoveryState.decode(journal.previousNativeBindingJson) + .committed, true, ); await h.db.customStatement('DROP TRIGGER retain_receipt'); @@ -651,72 +647,68 @@ void main() { }, ); for (final shared in [true, false]) { - test( - 'cancelled preparation lets durable recovery finish shared=$shared', - () async { - await h.seed(); - if (!shared) await h.makeGoogleUserOwned(); - final originalRecord = (await h.secrets.readCredential('opaque'))!; - await h.secrets.saveCredential( - 'busymax.authorization.rollback:opaque', - originalRecord, - ); - await h.db - .into(h.db.authorizationCommits) - .insert( - AuthorizationCommitsCompanion.insert( - accountId: 'opaque', - generation: await h.persistence.generation('opaque'), - hadCredential: true, - previousNativeBindingJson: Value( - const AuthorizationRecoveryState(committed: false).encode(), - ), + test('cancelled preparation lets durable recovery finish shared=$shared', () async { + await h.seed(); + if (!shared) await h.makeGoogleUserOwned(); + final originalRecord = (await h.secrets.readCredential('opaque'))!; + await h.secrets.saveCredential( + 'busymax.authorization.rollback:opaque', + originalRecord, + ); + await h.db + .into(h.db.authorizationCommits) + .insert( + AuthorizationCommitsCompanion.insert( + accountId: 'opaque', + generation: await h.persistence.generation('opaque'), + hadCredential: true, + previousNativeBindingJson: Value( + const AuthorizationRecoveryState(committed: false).encode(), ), - ); - final entered = Completer(); - final release = Completer(); - h.secrets.beforeSave = (id, _) async { - if (id == 'opaque') { - entered.complete(); - await release.future; - } - }; - final cancellation = AuthorizationCancellation(); - final connection = h.repository.signIn( - request: AuthorizationRequest.reconnect( - 'opaque', - ).withCancellation(cancellation), - ); - final rejected = expectLater( - connection, - throwsA( - isA().having( - (e) => e.classification, - 'kind', - OAuthFailureKind.cancelled, ), + ); + final entered = Completer(); + final release = Completer(); + h.secrets.beforeSave = (id, _) async { + if (id == 'opaque') { + entered.complete(); + await release.future; + } + }; + final cancellation = AuthorizationCancellation(); + final connection = h.repository.signIn( + request: AuthorizationRequest.reconnect('opaque') + .withCancellation(cancellation), + ); + final rejected = expectLater( + connection, + throwsA( + isA().having( + (e) => e.classification, + 'kind', + OAuthFailureKind.cancelled, ), - ); - await entered.future; - cancellation.cancel(); - await rejected; - expect(h.flow.started.isCompleted, false); - expect(h.requests, isEmpty); - h.secrets.beforeSave = null; - release.complete(); - // Recovery retains its serialized owner even after the UI stops waiting. - await h.persistence.run('opaque', () async {}); - expect(await h.db.select(h.db.authorizationCommits).get(), isEmpty); - expect( - (await h.secrets.readCredential('opaque'))!.toJson(), - originalRecord.toJson(), - ); - await h.repository.signIn( - request: AuthorizationRequest.reconnect('opaque'), - ); - expect((await h.summary('opaque')).showRetirementNotice, shared); - }, - ); + ), + ); + await entered.future; + cancellation.cancel(); + await rejected; + expect(h.flow.started.isCompleted, false); + expect(h.requests, isEmpty); + h.secrets.beforeSave = null; + release.complete(); + // Recovery retains its serialized owner even after the UI stops waiting. + await h.persistence.run('opaque', () async {}); + expect(await h.db.select(h.db.authorizationCommits).get(), isEmpty); + expect( + (await h.secrets.readCredential('opaque'))!.toJson(), + originalRecord.toJson(), + ); + await h.repository.signIn( + request: AuthorizationRequest.reconnect('opaque'), + ); + expect((await h.summary('opaque')).showRetirementNotice, shared); + }); test( 'cancel after coherent commit cannot undo accepted connection shared=$shared', () async { @@ -823,9 +815,8 @@ void main() { final cooldown = (await h.db.select(h.db.domainSyncSchedules).get()).single; expect( - DateTime.parse( - cooldown.cooldownUntilUtc!, - ).difference(DateTime.now().toUtc()), + DateTime.parse(cooldown.cooldownUntilUtc!) + .difference(DateTime.now().toUtc()), greaterThan(const Duration(minutes: 119)), ); }, @@ -878,52 +869,49 @@ void main() { }, ); } - test( - 'legacy token-derived Google ID survives binding and blocks duplicate onboarding', - () async { - const id = 'legacy-token-derived-local-key'; - await h.accounts.upsertSignedInAccount( - id: id, - provider: BusyProvider.google, - providerAccountId: id, - grantedScopes: googleBusyMaxOAuthScope, - tasksEnabled: false, - ); - await h.db - .into(h.db.oAuthTransitionAccounts) - .insert(OAuthTransitionAccountsCompanion.insert(accountId: id)); - await h.secrets.saveCredential( - id, - OAuthSecretRecord(provider: BusyProvider.google, tokenSet: tokens()), - ); - final bound = await h.google.boundCredentialForAccount(id); - expect(bound.subject, 'subject'); - expect(bound.registration.origin, RegistrationOrigin.retiringShared); - final account = (await h.accounts.accountById(id))!; - expect(account.id, id); - expect(account.providerAccountId, 'subject'); - expect(account.tasksEnabled, false); - await expectLater( - h.repository.signIn( - request: AuthorizationRequest.newConnection(h.staging.stage(owned)), - ), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'OAuthAccountAlreadyConnected', - ), + test('legacy token-derived Google ID survives binding and blocks duplicate onboarding', () async { + const id = 'legacy-token-derived-local-key'; + await h.accounts.upsertSignedInAccount( + id: id, + provider: BusyProvider.google, + providerAccountId: id, + grantedScopes: googleBusyMaxOAuthScope, + tasksEnabled: false, + ); + await h.db + .into(h.db.oAuthTransitionAccounts) + .insert(OAuthTransitionAccountsCompanion.insert(accountId: id)); + await h.secrets.saveCredential( + id, + OAuthSecretRecord(provider: BusyProvider.google, tokenSet: tokens()), + ); + final bound = await h.google.boundCredentialForAccount(id); + expect(bound.subject, 'subject'); + expect(bound.registration.origin, RegistrationOrigin.retiringShared); + final account = (await h.accounts.accountById(id))!; + expect(account.id, id); + expect(account.providerAccountId, 'subject'); + expect(account.tasksEnabled, false); + await expectLater( + h.repository.signIn( + request: AuthorizationRequest.newConnection(h.staging.stage(owned)), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthAccountAlreadyConnected', ), - ); - expect((await h.accounts.listVisibleAccounts()).map((a) => a.id), [id]); - expect( - (await h.secrets.readCredential(id) as GoogleDesktopCredential) - .registration - .clientId, - original.clientId, - ); - }, - ); + ), + ); + expect((await h.accounts.listVisibleAccounts()).map((a) => a.id), [id]); + expect( + (await h.secrets.readCredential( + id, + ) as GoogleDesktopCredential).registration.clientId, + original.clientId, + ); + }); test( 'new setup stages credentials until identity and repository commit', () async { @@ -939,9 +927,9 @@ void main() { grantedScopes: googleBusyMaxOAuthScope, ), ); - final saved = - await h.secrets.readCredential('google:subject') - as GoogleDesktopCredential; + final saved = await h.secrets.readCredential( + 'google:subject', + ) as GoogleDesktopCredential; expect(saved.registration.clientId, owned.clientId); expect(saved.transitionEligible, false); expect( @@ -950,32 +938,29 @@ void main() { ); }, ); - test( - 'targeted migration preserves opaque ID, data and enabled domains across restart', - () async { - await h.seed(); - await h.repository.signIn( - request: AuthorizationRequest.replace('opaque', h.staging.stage(owned)), - ); - final account = await h.accounts.accountById('opaque'); - expect(account!.providerAccountId, 'subject'); - expect(account.tasksEnabled, false); - final saved = - await h.secrets.readCredential('opaque') as GoogleDesktopCredential; - expect(saved.registration.clientId, owned.clientId); - expect(saved.transitionEligible, false); - expect(await h.accounts.accountById('google:subject'), isNull); - final summary = await h.db.select(h.db.accountAuthorizations).getSingle(); - expect( - decodeRegistrationSummary(summary.summaryJson)!.showRetirementNotice, - false, - ); - expect( - SecretRecord.fromJson(saved.toJson()), - isA(), - ); - }, - ); + test('targeted migration preserves opaque ID, data and enabled domains across restart', () async { + await h.seed(); + await h.repository.signIn( + request: AuthorizationRequest.replace('opaque', h.staging.stage(owned)), + ); + final account = await h.accounts.accountById('opaque'); + expect(account!.providerAccountId, 'subject'); + expect(account.tasksEnabled, false); + final saved = + await h.secrets.readCredential('opaque') as GoogleDesktopCredential; + expect(saved.registration.clientId, owned.clientId); + expect(saved.transitionEligible, false); + expect(await h.accounts.accountById('google:subject'), isNull); + final summary = await h.db.select(h.db.accountAuthorizations).getSingle(); + expect( + decodeRegistrationSummary(summary.summaryJson)!.showRetirementNotice, + false, + ); + expect( + SecretRecord.fromJson(saved.toJson()), + isA(), + ); + }); for (final failure in [ 'wrong-account', 'missing-scope', @@ -1027,9 +1012,9 @@ void main() { h.flow.barrier!.complete(); await pending; expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .tokenSet - .refreshToken, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).tokenSet.refreshToken, 'rotated-latest', ); }, @@ -1054,9 +1039,9 @@ void main() { throwsStateError, ); expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .tokenSet - .refreshToken, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).tokenSet.refreshToken, 'rotated-latest', ); expect(await h.persistence.generation('opaque'), 1); @@ -1078,9 +1063,9 @@ void main() { throwsA(isA()), ); expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .registration - .clientId, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).registration.clientId, original.clientId, ); await h.persistence.recover(); @@ -1125,9 +1110,9 @@ void main() { await restarted.recover(); await restarted.recover(); expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .registration - .clientId, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).registration.clientId, original.clientId, ); }, @@ -1148,30 +1133,27 @@ void main() { expect(await h.accounts.accountById('opaque'), isNull); }, ); - test( - 'new consent resolving to existing identity never replaces its registration', - () async { - await h.seed(); - await expectLater( - h.repository.signIn( - request: AuthorizationRequest.newConnection(h.staging.stage(owned)), - ), - throwsA( - isA().having( - (e) => e.code, - 'code', - 'OAuthAccountAlreadyConnected', - ), + test('new consent resolving to existing identity never replaces its registration', () async { + await h.seed(); + await expectLater( + h.repository.signIn( + request: AuthorizationRequest.newConnection(h.staging.stage(owned)), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'OAuthAccountAlreadyConnected', ), - ); - expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .registration - .clientId, - original.clientId, - ); - }, - ); + ), + ); + expect( + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).registration.clientId, + original.clientId, + ); + }); test('credential readers cannot select a provisional secure write', () async { await h.seed(); final candidate = await h.google.connectGoogle( @@ -1226,9 +1208,9 @@ void main() { ); expect((await h.accounts.accountById('opaque'))!.isSignedIn, true); expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .registration - .clientId, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).registration.clientId, owned.clientId, ); }, @@ -1246,9 +1228,9 @@ void main() { ); expect(await h.persistence.generation('opaque'), 1); expect( - (await h.secrets.readCredential('opaque') as GoogleDesktopCredential) - .tokenSet - .refreshToken, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).tokenSet.refreshToken, 'working-refresh', ); expect((await h.accounts.accountById('opaque'))!.isSignedIn, true); @@ -1279,9 +1261,9 @@ void main() { true, ); expect(posted.any((r) => r.body.contains('client_secret')), false); - final saved = - await h.secrets.readCredential('microsoft:ms-user') - as MicrosoftDesktopCredential; + final saved = await h.secrets.readCredential( + 'microsoft:ms-user', + ) as MicrosoftDesktopCredential; expect(saved.registration.clientId, msOwned); expect(saved.tenantId, tenant); }, @@ -1326,9 +1308,9 @@ void main() { expect(account.calendarsEnabled, false); expect(await h.persistence.generation(id), 2); expect( - (await h.secrets.readCredential(id) as MicrosoftDesktopCredential) - .registration - .clientId, + (await h.secrets.readCredential( + id, + ) as MicrosoftDesktopCredential).registration.clientId, '44444444-4444-4444-4444-444444444444', ); }, @@ -1414,9 +1396,19 @@ void main() { } class Harness { - Harness({this.buildConfig = config}) { + Harness({ + this.buildConfig = config, + CrossEngineAccountGate? gate, + AppDatabase? database, + FailureStore? secretStore, + }) : db = database ?? AppDatabase.memoryForTests(), + secrets = secretStore ?? FailureStore() { staging = RegistrationStaging(buildConfig); - persistence = AuthorizationPersistence(database: db, secrets: secrets); + persistence = AuthorizationPersistence( + database: db, + secrets: secrets, + gate: gate, + ); client = MockClient((request) async { requests.add(request); final injected = await beforeRequest?.call(request); @@ -1487,8 +1479,8 @@ class Harness { ); flow.cancelled = () => mode == 'cancel'; } - final db = AppDatabase.memoryForTests(); - final secrets = FailureStore(); + final AppDatabase db; + final FailureStore secrets; final BuildConfig buildConfig; late final http.Client client; late final RegistrationStaging staging; @@ -1616,11 +1608,12 @@ class FailureStore extends InMemorySecretStore { await super.deleteCredential(id); } - Future Function(String id, SecretRecord record)? beforeSave; + Future Function(String id, SecretRecord record)? beforeSave, afterSave; @override Future saveCredential(String id, SecretRecord record) async { await beforeSave?.call(id, record); await super.saveCredential(id, record); + await afterSave?.call(id, record); } @override diff --git a/test/core/auth/code_exchange_response_test.dart b/test/core/auth/code_exchange_response_test.dart index 252db5a6..5733bd42 100644 --- a/test/core/auth/code_exchange_response_test.dart +++ b/test/core/auth/code_exchange_response_test.dart @@ -1,6 +1,8 @@ import 'package:fake_async/fake_async.dart'; + import 'dart:async'; import 'dart:convert'; + import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/auth/oauth_registration.dart'; diff --git a/test/core/auth/contacts_authorization_requests_test.dart b/test/core/auth/contacts_authorization_requests_test.dart new file mode 100644 index 00000000..48def284 --- /dev/null +++ b/test/core/auth/contacts_authorization_requests_test.dart @@ -0,0 +1,155 @@ +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/auth/oauth_registration.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_loopback_flow.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax/src/microsoft_todo/oauth/microsoft_oauth_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; + +import 'authorization_transition_test.dart' show Harness, owned, msOwned; + +void main() { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final action in [ + 'first-sign-in', + 'first-contacts', + 'reconnect', + 'replace', + if (provider == BusyProvider.microsoft) ...[ + 'category', + 'shared-calendar', + ], + ]) { + test( + '$provider $action inspects the actual outbound PKCE authorization URL', + () async { + final h = Harness(); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + final id = provider == BusyProvider.google + ? 'opaque' + : 'microsoft:ms-user'; + if (action != 'first-sign-in') { + if (provider == BusyProvider.google) { + await h.seed(); + } else { + await h.seedMicrosoft(shared: false); + } + if (action != 'first-contacts') { + final credential = + await h.secrets.readCredential(id) as BoundOAuthSecretRecord; + final scopes = { + ...credential.tokenSet.scopes, + provider == BusyProvider.google + ? googleContactsWriteScope + : 'Contacts.ReadWrite', + if (provider == BusyProvider.microsoft) ...[ + if (action != 'shared-calendar') microsoftSharedCalendarScope, + if (action != 'category') microsoftCategoryScope, + ], + }; + await h.secrets.saveCredential( + id, + credential.withTokens( + credential.tokenSet.copyWith(scopes: scopes), + ), + ); + } + } + Uri? captured; + final flow = OAuthLoopbackFlow( + authorizationLauncher: (uri) async { + captured = uri; + return false; // No provider/account is contacted by this regression. + }, + ); + final google = OAuthService( + config: h.buildConfig, + httpClient: h.client, + tokenStore: h.secrets, + persistence: h.persistence, + registrations: h.staging, + loopbackFlow: flow, + ); + final microsoft = MicrosoftOAuthService( + config: h.buildConfig, + httpClient: h.client, + tokenStore: h.secrets, + persistence: h.persistence, + registrations: h.staging, + loopbackFlow: flow, + ); + Future request() { + if (action == 'first-contacts') { + return provider == BusyProvider.google + ? google.authorizeGoogleContacts(id) + : microsoft.authorizeMicrosoftContacts(id); + } + if (action == 'category') { + return microsoft.authorizeCategoryAccess(id); + } + if (action == 'shared-calendar') { + return microsoft.authorizeSharedCalendarAccess(id); + } + final registration = provider == BusyProvider.google + ? h.staging.stage(owned) + : h.staging.stageMicrosoft( + clientId: msOwned, + audience: MicrosoftAudience.personalAndOrganizations, + ); + final intent = action == 'first-sign-in' + ? AuthorizationRequest.newConnection(registration) + : action == 'replace' + ? AuthorizationRequest.replace(id, registration) + : AuthorizationRequest.reconnect(id); + return provider == BusyProvider.google + ? google.connectGoogle(intent) + : microsoft.connectMicrosoft(intent); + } + + await expectLater(request(), throwsA(isA())); + expect(captured, isNotNull); + final params = captured!.queryParameters; + final requested = params['scope']!.split(' ').toSet(); + expect(params['code_challenge_method'], 'S256'); + expect(params['code_challenge'], isNotEmpty); + expect(params['redirect_uri'], startsWith('http://')); + expect(params, isNot(contains('include_granted_scopes'))); + if (provider == BusyProvider.google) { + expect(requested, containsAll(googleBusyMaxOAuthScope.split(' '))); + expect(requested, isNot(contains(googleContactsWriteScope))); + expect( + requested.contains(googleContactsReadScope), + action != 'first-sign-in', + ); + } else { + expect(requested, containsAll(microsoftTodoOAuthScopes.split(' '))); + expect(requested, isNot(contains(microsoftContactsWriteScope))); + expect(requested, isNot(contains('Contacts.ReadWrite'))); + expect( + requested.contains(microsoftContactsReadScope), + action != 'first-sign-in', + ); + if (action == 'category' || + action == 'shared-calendar' || + action == 'reconnect' || + action == 'replace') { + expect( + requested, + containsAll([ + microsoftSharedCalendarScope, + microsoftCategoryScope, + ]), + ); + } + } + expect(h.requests, isEmpty); + }, + ); + } + } +} diff --git a/test/core/auth/registration_file_reader_test.dart b/test/core/auth/registration_file_reader_test.dart index 42048256..01e2c592 100644 --- a/test/core/auth/registration_file_reader_test.dart +++ b/test/core/auth/registration_file_reader_test.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; + import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/core/auth/authorization_attempt.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/auth/registration_staging.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter_test/flutter_test.dart'; + import '../../support/native_registration_reader_fixture.dart'; void main() { diff --git a/test/core/auth/registration_setup_controller_test.dart b/test/core/auth/registration_setup_controller_test.dart index 7a3722ba..93432622 100644 --- a/test/core/auth/registration_setup_controller_test.dart +++ b/test/core/auth/registration_setup_controller_test.dart @@ -1,4 +1,5 @@ import '../../support/desktop_registration_config.dart'; + import 'dart:async'; import 'dart:io'; @@ -19,27 +20,33 @@ const tenantId = 'aabbccdd-1122-3344-5566-77889900aabb'; void main() { for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { - test('$provider shared connection is explicit, validated and single use', () { - final staging = RegistrationStaging(syntheticDesktopConfig()); - final setup = RegistrationSetupController(staging, provider); - addTearDown(() { - setup.dispose(); - staging.dispose(); - }); - // Remembered retired IDs cannot relabel an explicitly active registration. - staging.rememberRetiringClient( - provider == BusyProvider.google - ? staging.config.busyMaxGoogleOAuthClientId - : staging.config.busyMaxMicrosoftOAuthClientId, - ); - final selected = setup.connectWithBusyMax()!; - expect(selected.summary.origin, RegistrationOrigin.busyMaxManaged); - expect(selected.summary.showRetirementNotice, isFalse); - expect(setup.connectWithBusyMax(), isNull); - final registration = staging.consume(selected); - expect(registration.summary().origin, RegistrationOrigin.busyMaxManaged); - expect(() => staging.consume(selected), throwsA(isA())); - }); + test( + '$provider shared connection is explicit, validated and single use', + () { + final staging = RegistrationStaging(syntheticDesktopConfig()); + final setup = RegistrationSetupController(staging, provider); + addTearDown(() { + setup.dispose(); + staging.dispose(); + }); + // Remembered retired IDs cannot relabel an explicitly active registration. + staging.rememberRetiringClient( + provider == BusyProvider.google + ? staging.config.busyMaxGoogleOAuthClientId + : staging.config.busyMaxMicrosoftOAuthClientId, + ); + final selected = setup.connectWithBusyMax()!; + expect(selected.summary.origin, RegistrationOrigin.busyMaxManaged); + expect(selected.summary.showRetirementNotice, isFalse); + expect(setup.connectWithBusyMax(), isNull); + final registration = staging.consume(selected); + expect( + registration.summary().origin, + RegistrationOrigin.busyMaxManaged, + ); + expect(() => staging.consume(selected), throwsA(isA())); + }, + ); test('$provider originals do not make shared authorization available', () { final staging = RegistrationStaging( syntheticDesktopConfig(managed: false), @@ -62,9 +69,9 @@ void main() { ); final staging = RegistrationStaging(config); addTearDown(staging.dispose); - final registration = - staging.consume(staging.stageBusyMax(BusyProvider.microsoft)) - as MicrosoftPublicRegistration; + final registration = staging.consume( + staging.stageBusyMax(BusyProvider.microsoft), + ) as MicrosoftPublicRegistration; expect(registration.clientId, config.busyMaxMicrosoftOAuthClientId); expect(registration.audience, MicrosoftAudience.personalAndOrganizations); diff --git a/test/core/http/shared_graph_transport_test.dart b/test/core/http/shared_graph_transport_test.dart new file mode 100644 index 00000000..b36e8297 --- /dev/null +++ b/test/core/http/shared_graph_transport_test.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:busystack_graph/busystack_graph.dart'; +import 'package:busymax/src/core/http/shared_graph_transport.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; + +void main() { + final now = DateTime.utc(2026, 10, 11); + final delays = { + const Duration(seconds: 17): '17', + const Duration(seconds: 16, microseconds: 999999): '17', + const Duration(seconds: 17, microseconds: 1): '18', + Duration.zero: '0', + const Duration(microseconds: -1): '0', + const Duration(seconds: -2): '0', + }; + for (final entry in delays.entries) { + test( + 'Graph Retry-After rounds ${entry.key} up without adding a second', + () async { + var requests = 0; + final adapter = BusyMaxGraphTransport( + httpClient: MockClient((_) async { + requests++; + throw GraphException( + GraphFailure.throttled, + 'TooManyRequests', + statusCode: 429, + retryAt: now.add(entry.key), + retryAfterProvided: true, + requestRejected: true, + ); + }), + authorizationHeader: (_) async => 'Bearer fixture', + nowUtc: () => now, + ); + addTearDown(adapter.close); + final response = await adapter.request( + 'POST', + Uri.parse('https://graph.microsoft.com/v1.0/me/events'), + ); + expect(response.statusCode, 429); + expect(response.headers['retry-after'], entry.value); + expect( + (jsonDecode(response.body) as Map)['error']['code'], + 'TooManyRequests', + ); + expect( + requests, + 1, + reason: 'The shared pipeline must not retry a write.', + ); + }, + ); + } + test( + 'Graph fallback retry deadline does not invent a server retry hint', + () async { + final adapter = BusyMaxGraphTransport( + httpClient: MockClient( + (_) async => throw GraphException( + GraphFailure.throttled, + 'TooManyRequests', + statusCode: 429, + retryAt: now.add(const Duration(seconds: 30)), + requestRejected: true, + ), + ), + authorizationHeader: (_) async => 'Bearer fixture', + nowUtc: () => now, + ); + addTearDown(adapter.close); + final response = await adapter.request( + 'GET', + Uri.parse('https://graph.microsoft.com/v1.0/me/events'), + ); + expect(response.statusCode, 429); + expect(response.headers, isNot(contains('retry-after'))); + }, + ); +} diff --git a/test/core/time/provider_date_time_test.dart b/test/core/time/provider_date_time_test.dart index 2b01246e..ee133f0a 100644 --- a/test/core/time/provider_date_time_test.dart +++ b/test/core/time/provider_date_time_test.dart @@ -56,29 +56,26 @@ void main() { }, ); - test( - 'missing and unknown zones retain host-local fallback and date-only behavior', - () { - for (final zone in [null, 'Unknown/Zone']) { - final local = DateTime(2026, 3, 8, 2, 30); - expect(providerDateTimeAsLocal('2026-03-08T02:30:00', zone), local); - expect( - providerDateTimeAsUtcInstant('2026-03-08T02:30:00', zone), - local.toUtc(), - ); - } - expect( - providerDateTimeAsLocal('2026-03-08', 'Asia/Tokyo'), - DateTime(2026, 3, 8), - ); + test('missing and unknown zones retain host-local fallback and date-only behavior', () { + for (final zone in [null, 'Unknown/Zone']) { + final local = DateTime(2026, 3, 8, 2, 30); + expect(providerDateTimeAsLocal('2026-03-08T02:30:00', zone), local); expect( - providerDateTimeAsUtcInstant('2026-03-08', 'Asia/Tokyo'), - DateTime(2026, 3, 8).toUtc(), + providerDateTimeAsUtcInstant('2026-03-08T02:30:00', zone), + local.toUtc(), ); - expect(providerDateTimeAsLocal('invalid', 'Asia/Tokyo'), isNull); - expect(providerDateTimeAsUtcInstant(null, 'Asia/Tokyo'), isNull); - }, - ); + } + expect( + providerDateTimeAsLocal('2026-03-08', 'Asia/Tokyo'), + DateTime(2026, 3, 8), + ); + expect( + providerDateTimeAsUtcInstant('2026-03-08', 'Asia/Tokyo'), + DateTime(2026, 3, 8).toUtc(), + ); + expect(providerDateTimeAsLocal('invalid', 'Asia/Tokyo'), isNull); + expect(providerDateTimeAsUtcInstant(null, 'Asia/Tokyo'), isNull); + }); }, skip: !(Platform.isLinux || Platform.isMacOS)); test('UTC provider dateTime without offset is treated as a UTC instant', () { diff --git a/test/dav/apple_icloud_live_integration_test.dart b/test/dav/apple_icloud_live_integration_test.dart index 7952b613..88c6707a 100644 --- a/test/dav/apple_icloud_live_integration_test.dart +++ b/test/dav/apple_icloud_live_integration_test.dart @@ -287,9 +287,11 @@ final class _LiveICloudFixture { created.add(canonical); _objectsToDelete.add(canonical.requestUri); expect( - IcalSemanticDocument.parse( - canonical.rawIcsBody!, - ).components.single.start!.kind, + IcalSemanticDocument.parse(canonical.rawIcsBody!) + .components + .single + .start! + .kind, variant.$2, ); } diff --git a/test/dav/auth/dav_account_onboarding_service_test.dart b/test/dav/auth/dav_account_onboarding_service_test.dart index dbe43d31..868f1bec 100644 --- a/test/dav/auth/dav_account_onboarding_service_test.dart +++ b/test/dav/auth/dav_account_onboarding_service_test.dart @@ -174,9 +174,9 @@ void main() { expect(account.authority, 'https://canonical.example.test/cloud'); expect(account.providerAccountId, 'canonical-user'); expect(account.tasksEnabled, isTrue); - final credential = - await secrets.readCredential(result.accountId) - as NextcloudSecretRecord; + final credential = await secrets.readCredential( + result.accountId, + ) as NextcloudSecretRecord; expect( credential.canonicalServer, Uri.parse('https://canonical.example.test/cloud'), @@ -187,213 +187,201 @@ void main() { }, ); - test( - 'Nextcloud reconnect replaces only the verified credential and preserves local state', - () async { - final initialService = DavAccountOnboardingService( - database: database, - secretStore: secrets, - nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'old-password'), - idFactory: () => 'nextcloud-id', - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => _discovery(accountId, provider, accountAuthority), - ); - final connected = await initialService.connectNextcloud( - enteredServer: 'cloud.example.test', - ); - await _insertNextcloudLocalState(database, connected.accountId); - await _markPendingAuthenticationBlocked(database); + test('Nextcloud reconnect replaces only the verified credential and preserves local state', () async { + final initialService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'old-password'), + idFactory: () => 'nextcloud-id', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final connected = await initialService.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + await _insertNextcloudLocalState(database, connected.accountId); + await _markPendingAuthenticationBlocked(database); - var verifiedBeforeReplacement = false; - final reconnectService = DavAccountOnboardingService( - database: database, - secretStore: secrets, - nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'new-password'), - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async { - final stored = - await secrets.readCredential(accountId) - as NextcloudSecretRecord; - expect(stored.appPassword, 'old-password'); - expect(credential.password, 'new-password'); - verifiedBeforeReplacement = true; - return _discovery(accountId, provider, accountAuthority); - }, - ); + var verifiedBeforeReplacement = false; + final reconnectService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'new-password'), + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async { + final stored = await secrets.readCredential( + accountId, + ) as NextcloudSecretRecord; + expect(stored.appPassword, 'old-password'); + expect(credential.password, 'new-password'); + verifiedBeforeReplacement = true; + return _discovery(accountId, provider, accountAuthority); + }, + ); - final reconnected = await reconnectService.reconnectNextcloud( - accountId: connected.accountId, - enteredServer: 'cloud.example.test', - ); + final reconnected = await reconnectService.reconnectNextcloud( + accountId: connected.accountId, + enteredServer: 'cloud.example.test', + ); - expect(verifiedBeforeReplacement, isTrue); - expect(reconnected.accountId, connected.accountId); - final stored = - await secrets.readCredential(connected.accountId) - as NextcloudSecretRecord; - expect(stored.appPassword, 'new-password'); - expect(await database.select(database.accounts).get(), hasLength(1)); - expect(await database.select(database.taskLists).get(), hasLength(1)); - final pending = await database.select(database.pendingOps).getSingle(); - expect(pending.state, 'pending'); - expect(pending.retryClassification, 'credential_replaced'); - expect(pending.attemptCount, 0); - expect(pending.nextAttemptAtUtc, isNull); - expect(pending.lastErrorCode, isNull); - expect( - (await database.select(database.accounts).getSingle()).authState, - 'signed_in', - ); - }, - ); + expect(verifiedBeforeReplacement, isTrue); + expect(reconnected.accountId, connected.accountId); + final stored = await secrets.readCredential( + connected.accountId, + ) as NextcloudSecretRecord; + expect(stored.appPassword, 'new-password'); + expect(await database.select(database.accounts).get(), hasLength(1)); + expect(await database.select(database.taskLists).get(), hasLength(1)); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.state, 'pending'); + expect(pending.retryClassification, 'credential_replaced'); + expect(pending.attemptCount, 0); + expect(pending.nextAttemptAtUtc, isNull); + expect(pending.lastErrorCode, isNull); + expect( + (await database.select(database.accounts).getSingle()).authState, + 'signed_in', + ); + }); - test( - 'Nextcloud reconnect rejects a different identity without touching local state', - () async { - final initialService = DavAccountOnboardingService( - database: database, - secretStore: secrets, - nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'old-password'), - idFactory: () => 'nextcloud-id', - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => _discovery(accountId, provider, accountAuthority), - ); - final connected = await initialService.connectNextcloud( + test('Nextcloud reconnect rejects a different identity without touching local state', () async { + final initialService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'old-password'), + idFactory: () => 'nextcloud-id', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final connected = await initialService.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + await _insertNextcloudLocalState(database, connected.accountId); + await _markPendingAuthenticationBlocked(database); + var discoveryCalled = false; + final reconnectService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow( + loginName: 'different-user', + appPassword: 'untrusted-password', + ), + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async { + discoveryCalled = true; + return _discovery(accountId, provider, accountAuthority); + }, + ); + + await expectLater( + reconnectService.reconnectNextcloud( + accountId: connected.accountId, enteredServer: 'cloud.example.test', - ); - await _insertNextcloudLocalState(database, connected.accountId); - await _markPendingAuthenticationBlocked(database); - var discoveryCalled = false; - final reconnectService = DavAccountOnboardingService( - database: database, - secretStore: secrets, - nextcloudLoginFlow: _successfulLoginFlow( - loginName: 'different-user', - appPassword: 'untrusted-password', + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavNextcloudReconnectIdentityMismatch', ), - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async { - discoveryCalled = true; - return _discovery(accountId, provider, accountAuthority); - }, - ); + ), + ); - await expectLater( - reconnectService.reconnectNextcloud( - accountId: connected.accountId, - enteredServer: 'cloud.example.test', - ), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'DavNextcloudReconnectIdentityMismatch', - ), - ), - ); + expect(discoveryCalled, isFalse); + final stored = await secrets.readCredential( + connected.accountId, + ) as NextcloudSecretRecord; + expect(stored.appPassword, 'old-password'); + expect(await database.select(database.accounts).get(), hasLength(1)); + expect(await database.select(database.taskLists).get(), hasLength(1)); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.state, 'auth_blocked'); + expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); + }); - expect(discoveryCalled, isFalse); - final stored = - await secrets.readCredential(connected.accountId) - as NextcloudSecretRecord; - expect(stored.appPassword, 'old-password'); - expect(await database.select(database.accounts).get(), hasLength(1)); - expect(await database.select(database.taskLists).get(), hasLength(1)); - final pending = await database.select(database.pendingOps).getSingle(); - expect(pending.state, 'auth_blocked'); - expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); - }, - ); + test('failed Nextcloud reconnect discovery preserves the old credential and local state', () async { + final initialService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'old-password'), + idFactory: () => 'nextcloud-id', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final connected = await initialService.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + await _insertNextcloudLocalState(database, connected.accountId); + await _markPendingAuthenticationBlocked(database); + final reconnectService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'new-password'), + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => throw const DavException( + kind: DavErrorKind.authentication, + code: 'DavAuthRejected', + safeMessage: 'Credential rejected.', + ), + ); - test( - 'failed Nextcloud reconnect discovery preserves the old credential and local state', - () async { - final initialService = DavAccountOnboardingService( - database: database, - secretStore: secrets, - nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'old-password'), - idFactory: () => 'nextcloud-id', - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => _discovery(accountId, provider, accountAuthority), - ); - final connected = await initialService.connectNextcloud( + await expectLater( + reconnectService.reconnectNextcloud( + accountId: connected.accountId, enteredServer: 'cloud.example.test', - ); - await _insertNextcloudLocalState(database, connected.accountId); - await _markPendingAuthenticationBlocked(database); - final reconnectService = DavAccountOnboardingService( - database: database, - secretStore: secrets, - nextcloudLoginFlow: _successfulLoginFlow(appPassword: 'new-password'), - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => throw const DavException( - kind: DavErrorKind.authentication, - code: 'DavAuthRejected', - safeMessage: 'Credential rejected.', - ), - ); - - await expectLater( - reconnectService.reconnectNextcloud( - accountId: connected.accountId, - enteredServer: 'cloud.example.test', - ), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'DavAuthRejected', - ), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavAuthRejected', ), - ); + ), + ); - final stored = - await secrets.readCredential(connected.accountId) - as NextcloudSecretRecord; - expect(stored.appPassword, 'old-password'); - expect(await database.select(database.accounts).get(), hasLength(1)); - expect(await database.select(database.taskLists).get(), hasLength(1)); - final pending = await database.select(database.pendingOps).getSingle(); - expect(pending.state, 'auth_blocked'); - expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); - }, - ); + final stored = await secrets.readCredential( + connected.accountId, + ) as NextcloudSecretRecord; + expect(stored.appPassword, 'old-password'); + expect(await database.select(database.accounts).get(), hasLength(1)); + expect(await database.select(database.taskLists).get(), hasLength(1)); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.state, 'auth_blocked'); + expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); + }); test('Apple replacement validates before replacing the old secret', () async { var discoveryCount = 0; @@ -469,14 +457,13 @@ void main() { secretStore: secrets, nextcloudLoginFlow: _unusedLoginFlow(), idFactory: () => 'apple-remove-id', - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => _discovery(accountId, provider, accountAuthority), + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), ); final connected = await service.connectAppleICloud( email: 'person@example.test', @@ -517,14 +504,13 @@ void main() { secretStore: secrets, nextcloudLoginFlow: _successfulLoginFlow(), idFactory: () => 'nextcloud-id', - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => _discovery(accountId, provider, accountAuthority), + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), nextcloudCredentialRevoker: ({required accountId, required credential}) async { throw const DavException( diff --git a/test/dav/discovery/dav_discovery_test.dart b/test/dav/discovery/dav_discovery_test.dart index 4cb2044c..1d74ee2d 100644 --- a/test/dav/discovery/dav_discovery_test.dart +++ b/test/dav/discovery/dav_discovery_test.dart @@ -139,9 +139,8 @@ void main() { '', ), ); - await DavDiscoveryRepository( - database: db, - ).commitSuccessfulInventory(discovered); + await DavDiscoveryRepository(database: db) + .commitSuccessfulInventory(discovered); final collection = (await db.select(db.davCollections).get()).firstWhere( (c) => c.displayName == 'Work & Team', ); diff --git a/test/dav/fake_dav_server_integration_test.dart b/test/dav/fake_dav_server_integration_test.dart index 509e6798..61f7a281 100644 --- a/test/dav/fake_dav_server_integration_test.dart +++ b/test/dav/fake_dav_server_integration_test.dart @@ -29,48 +29,45 @@ void main() { tearDown(() => server.close()); - test( - 'socket discovery follows well-known redirect and observes ACL/removal changes', - () async { - var result = await _discover(server); + test('socket discovery follows well-known redirect and observes ACL/removal changes', () async { + var result = await _discover(server); - expect(result.service.principalHref.path, server.principalPath); - expect(result.service.calendarHomeHref.path, server.calendarHomePath); - expect(result.collections, hasLength(1)); - expect(result.collections.single.hrefKey, server.collectionPath); - expect(result.collections.single.eventProjectionEnabled, isTrue); - expect(result.collections.single.taskProjectionEnabled, isTrue); - expect(result.collections.single.capabilities.canCreateEvent, isTrue); - expect(result.collections.single.capabilities.canCreateTask, isTrue); - expect(server.requests.map((request) => request.method).take(6), [ - 'OPTIONS', - 'OPTIONS', - 'PROPFIND', - 'PROPFIND', - 'PROPFIND', - ]); - expect( - server.requests, - everyElement( - isA().having( - (request) => request.hasBasicAuthorization, - 'Basic authorization present', - isTrue, - ), + expect(result.service.principalHref.path, server.principalPath); + expect(result.service.calendarHomeHref.path, server.calendarHomePath); + expect(result.collections, hasLength(1)); + expect(result.collections.single.hrefKey, server.collectionPath); + expect(result.collections.single.eventProjectionEnabled, isTrue); + expect(result.collections.single.taskProjectionEnabled, isTrue); + expect(result.collections.single.capabilities.canCreateEvent, isTrue); + expect(result.collections.single.capabilities.canCreateTask, isTrue); + expect(server.requests.map((request) => request.method).take(6), [ + 'OPTIONS', + 'OPTIONS', + 'PROPFIND', + 'PROPFIND', + 'PROPFIND', + ]); + expect( + server.requests, + everyElement( + isA().having( + (request) => request.hasBasicAuthorization, + 'Basic authorization present', + isTrue, ), - ); + ), + ); - server.collectionReadOnly = true; - result = await _discover(server); - expect(result.collections.single.capabilities.isReadOnly, isTrue); - expect(result.collections.single.capabilities.canCreateEvent, isFalse); - expect(result.collections.single.capabilities.canCreateTask, isFalse); + server.collectionReadOnly = true; + result = await _discover(server); + expect(result.collections.single.capabilities.isReadOnly, isTrue); + expect(result.collections.single.capabilities.canCreateEvent, isFalse); + expect(result.collections.single.capabilities.canCreateTask, isFalse); - server.collectionRemoved = true; - result = await _discover(server); - expect(result.collections, isEmpty); - }, - ); + server.collectionRemoved = true; + result = await _discover(server); + expect(result.collections, isEmpty); + }); test('RFC 6578 pages and multiget commit mixed VEVENT/VTODO data', () async { final database = AppDatabase(NativeDatabase.memory()); @@ -132,9 +129,8 @@ void main() { '1', ); - final inventory = await _collectionClient( - server, - ).listMemberEtags(correlationId: 'fake-inventory'); + final inventory = await _collectionClient(server) + .listMemberEtags(correlationId: 'fake-inventory'); expect(inventory.members, hasLength(2)); }); @@ -221,107 +217,109 @@ void main() { }, ); - test( - 'status, per-resource, malformed, invalid-token, delay, and drop matrix', - () async { - final statusCases = <(int, DavErrorCategory)>[ - (401, DavErrorCategory.davAuthRejected), - (403, DavErrorCategory.davPermissionDenied), - (404, DavErrorCategory.davCollectionRemoved), - (409, DavErrorCategory.davResourceConflict), - (412, DavErrorCategory.davResourceConflict), - (423, DavErrorCategory.davResourceConflict), - (429, DavErrorCategory.davRateLimited), - (507, DavErrorCategory.davQuotaOrSizeLimit), - (500, DavErrorCategory.davServerUnavailable), - (503, DavErrorCategory.davServerUnavailable), - ]; - for (final entry in statusCases) { - server.enqueueFault( - FakeDavFault( - method: 'PROPFIND', - path: server.collectionPath, - statusCode: entry.$1, - headers: entry.$1 == 429 ? const {'retry-after': '17'} : const {}, - ), - ); - final error = await _captureDavError( - _collectionClient( - server, - limits: const DavTransportLimits(maximumReadAttempts: 1), - ).listMemberEtags(correlationId: 'status-${entry.$1}'), - ); - expect(error.category, entry.$2, reason: 'HTTP ${entry.$1}'); - if (entry.$1 == 429) { - expect(error.retryAfter, const Duration(seconds: 17)); - } - } - - server.resources[server.eventPath]!.multistatusStatus = 403; - var error = await _captureDavError( - _collectionClient( - server, - ).listMemberEtags(correlationId: 'member-status'), - ); - expect(error.category, DavErrorCategory.davPermissionDenied); - server.resources[server.eventPath]!.multistatusStatus = null; - + test('status, per-resource, malformed, invalid-token, delay, and drop matrix', () async { + final statusCases = <(int, DavErrorCategory)>[ + (401, DavErrorCategory.davAuthRejected), + (403, DavErrorCategory.davPermissionDenied), + (404, DavErrorCategory.davCollectionRemoved), + (409, DavErrorCategory.davResourceConflict), + (412, DavErrorCategory.davResourceConflict), + (423, DavErrorCategory.davResourceConflict), + (429, DavErrorCategory.davRateLimited), + (507, DavErrorCategory.davQuotaOrSizeLimit), + (500, DavErrorCategory.davServerUnavailable), + (503, DavErrorCategory.davServerUnavailable), + ]; + for (final entry in statusCases) { server.enqueueFault( FakeDavFault( method: 'PROPFIND', path: server.collectionPath, - statusCode: 207, - body: '', + statusCode: entry.$1, + headers: entry.$1 == 429 ? const {'retry-after': '17'} : const {}, ), ); - error = await _captureDavError( - _collectionClient(server).listMemberEtags(correlationId: 'malformed'), - ); - expect(error.category, DavErrorCategory.davProtocolViolation); - - error = await _captureDavError( - _collectionClient(server).syncCollectionPage( - syncToken: 'invalid-token', - correlationId: 'invalid-token', - ), - ); - expect(error.category, DavErrorCategory.davSyncTokenInvalid); - - server.enqueueFault( - FakeDavFault( - method: 'PROPFIND', - path: server.collectionPath, - delay: const Duration(milliseconds: 80), - statusCode: 207, - ), - ); - error = await _captureDavError( + final error = await _captureDavError( _collectionClient( server, - limits: const DavTransportLimits( - connectTimeout: Duration(milliseconds: 10), - maximumReadAttempts: 1, - ), - ).listMemberEtags(correlationId: 'delay'), + limits: const DavTransportLimits(maximumReadAttempts: 1), + ).listMemberEtags(correlationId: 'status-${entry.$1}'), ); - expect(error.category, DavErrorCategory.davTransientNetwork); + expect(error.category, entry.$2, reason: 'HTTP ${entry.$1}'); + if (entry.$1 == 429) { + expect(error.retryAfter, const Duration(seconds: 17)); + } + } - server.enqueueFault( - FakeDavFault( - method: 'PROPFIND', - path: server.collectionPath, - dropConnection: true, + server.resources[server.eventPath]!.multistatusStatus = 403; + var error = await _captureDavError( + _collectionClient(server).listMemberEtags(correlationId: 'member-status'), + ); + expect(error.category, DavErrorCategory.davPermissionDenied); + server.resources[server.eventPath]!.multistatusStatus = null; + + server.enqueueFault( + FakeDavFault( + method: 'PROPFIND', + path: server.collectionPath, + statusCode: 207, + body: '', + ), + ); + error = await _captureDavError( + _collectionClient(server).listMemberEtags(correlationId: 'malformed'), + ); + expect(error.category, DavErrorCategory.davProtocolViolation); + + error = await _captureDavError( + _collectionClient(server).syncCollectionPage( + syncToken: 'invalid-token', + correlationId: 'invalid-token', + ), + ); + expect(error.category, DavErrorCategory.davSyncTokenInvalid); + + // A timeout can expire before its request reaches the server. Keep that + // server separate so an unconsumed delay fault cannot answer the following + // connection-drop request with an empty 207 response. + final timeoutServer = FakeDavServer(); + await timeoutServer.start(); + addTearDown(timeoutServer.close); + timeoutServer.enqueueFault( + FakeDavFault( + method: 'PROPFIND', + path: timeoutServer.collectionPath, + delay: const Duration(milliseconds: 80), + statusCode: 207, + ), + ); + error = await _captureDavError( + _collectionClient( + timeoutServer, + limits: const DavTransportLimits( + connectTimeout: Duration(milliseconds: 10), + maximumReadAttempts: 1, ), - ); - error = await _captureDavError( - _collectionClient( - server, - limits: const DavTransportLimits(maximumReadAttempts: 1), - ).listMemberEtags(correlationId: 'drop'), - ); - expect(error.category, DavErrorCategory.davTransientNetwork); - }, - ); + ).listMemberEtags(correlationId: 'delay'), + ); + expect(error.category, DavErrorCategory.davTransientNetwork); + await timeoutServer.close(); + + server.enqueueFault( + FakeDavFault( + method: 'PROPFIND', + path: server.collectionPath, + dropConnection: true, + ), + ); + error = await _captureDavError( + _collectionClient( + server, + limits: const DavTransportLimits(maximumReadAttempts: 1), + ).listMemberEtags(correlationId: 'drop'), + ); + expect(error.category, DavErrorCategory.davTransientNetwork); + }); } Future _discover(FakeDavServer server) => diff --git a/test/dav/http/dav_http_transport_test.dart b/test/dav/http/dav_http_transport_test.dart index ea4cbce2..76e380d2 100644 --- a/test/dav/http/dav_http_transport_test.dart +++ b/test/dav/http/dav_http_transport_test.dart @@ -89,40 +89,116 @@ void main() { ); test( - 'allows Nextcloud root well-known discovery only until its installation redirect', + 'rejects an operational redirect without replaying credentials', () async { - final requested = []; + var calls = 0; final transport = _nextcloudTransport( - MockClient((request) async { - requested.add(request); - if (requested.length == 1) { - return http.Response( - '', - 302, - headers: { - 'location': - 'https://cloud.example.test/nextcloud/remote.php/dav/', - }, - ); - } - return http.Response('', 200); + MockClient((_) async { + calls++; + return http.Response( + '', + 302, + headers: { + 'location': + 'https://cloud.example.test/nextcloud/remote.php/dav/other', + }, + ); }), ); - final response = await transport.send( - _propfind(Uri.parse('https://cloud.example.test/.well-known/caldav')), - credential: credential, - ); - - expect(response.requestUri.path, '/nextcloud/remote.php/dav/'); - expect(requested, hasLength(2)); - expect( - requested.map((request) => request.headers['authorization']), - everyElement(startsWith('Basic ')), + await expectLater( + transport.send( + DavRequest.xml( + method: 'PROPFIND', + uri: Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/current', + ), + accountId: accountId, + correlationId: correlationId, + body: '', + ), + credential: credential, + ), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + DavErrorKind.redirectRejected, + ) + .having( + (error) => error.code, + 'code', + 'DavOperationalRedirectRejected', + ), + ), ); + expect(calls, 1); }, ); + test('lost mutation response is classified for reconciliation', () async { + final transport = _nextcloudTransport( + MockClient((request) async { + throw http.ClientException('response unavailable', request.url); + }), + ); + + await expectLater( + transport.send( + DavRequest.icalendar( + method: 'PUT', + uri: Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/a.ics', + ), + accountId: accountId, + collectionId: 'collection', + correlationId: correlationId, + body: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', + headers: const {'if-match': '"etag"'}, + ), + credential: credential, + ), + throwsA( + isA() + .having((error) => error.kind, 'kind', DavErrorKind.unknownOutcome) + .having((error) => error.code, 'code', 'DavUnknownMutationOutcome'), + ), + ); + }); + + test('allows Nextcloud root well-known discovery only until its installation redirect', () async { + final requested = []; + final transport = _nextcloudTransport( + MockClient((request) async { + requested.add(request); + if (requested.length == 1) { + return http.Response( + '', + 302, + headers: { + 'location': + 'https://cloud.example.test/nextcloud/remote.php/dav/', + }, + ); + } + return http.Response('', 200); + }), + ); + + final response = await transport.send( + _propfind(Uri.parse('https://cloud.example.test/.well-known/caldav')), + credential: credential, + ); + + expect(response.requestUri.path, '/nextcloud/remote.php/dav/'); + expect(requested, hasLength(2)); + expect( + requested.map((request) => request.headers['authorization']), + everyElement(startsWith('Basic ')), + ); + }); + test( 'allows approved iCloud shards but rejects look-alike domains', () async { @@ -676,4 +752,5 @@ DavRequest _propfind(Uri uri) => DavRequest.xml( correlationId: 'correlation-1', body: '', headers: const {'depth': '0'}, + redirectPolicy: DavRedirectPolicy.discovery, ); diff --git a/test/dav/ical/ical_document_test.dart b/test/dav/ical/ical_document_test.dart index 6d12031e..f79aa12f 100644 --- a/test/dav/ical/ical_document_test.dart +++ b/test/dav/ical/ical_document_test.dart @@ -91,9 +91,9 @@ void main() { }); test('all-day values remain dates with exclusive DTEND', () { - final component = IcalSemanticDocument.parse( - _allDayEvent, - ).components.single; + final component = IcalSemanticDocument.parse(_allDayEvent) + .components + .single; expect(component.start?.kind, IcalTemporalKind.date); expect(component.start?.rawValue, '20260808'); diff --git a/test/dav/ical/ical_recurrence_test.dart b/test/dav/ical/ical_recurrence_test.dart index 16903f9c..a5d4ed0d 100644 --- a/test/dav/ical/ical_recurrence_test.dart +++ b/test/dav/ical/ical_recurrence_test.dart @@ -252,9 +252,8 @@ END:VCALENDAR\r expect(raw['startUtc'], '2026-11-01T08:30:00.000Z'); expect(raw['endUtc'], '2026-11-01T09:30:00.000Z'); expect( - DateTime.parse( - raw['endUtc']! as String, - ).difference(DateTime.parse(raw['startUtc']! as String)), + DateTime.parse(raw['endUtc']! as String) + .difference(DateTime.parse(raw['startUtc']! as String)), const Duration(hours: 1), ); }); diff --git a/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart b/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart index 81b8a587..fce4bf97 100644 --- a/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart +++ b/test/dav/mutation/dav_calendar_collection_mutation_service_test.dart @@ -161,7 +161,7 @@ void main() { isA().having( (error) => error.kind, 'kind', - DavErrorKind.network, + DavErrorKind.unknownOutcome, ), ), ); @@ -187,8 +187,16 @@ void main() { service.createEventCalendar('Malformed probe'), throwsA( isA() - .having((error) => error.kind, 'kind', DavErrorKind.network) - .having((error) => error.code, 'code', 'DavNetworkFailure'), + .having( + (error) => error.kind, + 'kind', + DavErrorKind.unknownOutcome, + ) + .having( + (error) => error.code, + 'code', + 'DavUnknownMutationOutcome', + ), ), ); }, @@ -217,7 +225,7 @@ void main() { isA().having( (error) => error.kind, 'kind', - DavErrorKind.timeout, + DavErrorKind.unknownOutcome, ), ), ); diff --git a/test/dav/mutation/dav_conditional_mutation_service_test.dart b/test/dav/mutation/dav_conditional_mutation_service_test.dart index 519f30ee..8c50e466 100644 --- a/test/dav/mutation/dav_conditional_mutation_service_test.dart +++ b/test/dav/mutation/dav_conditional_mutation_service_test.dart @@ -14,52 +14,48 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { - test( - 'implicit scheduling adopts a confirmed server-modified meeting exactly once', - () async { - var puts = 0; - final intended = _event('Meeting').replaceFirst( - 'END:VEVENT', - 'ORGANIZER:mailto:owner@example.test\r\nATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:guest@example.test\r\nEND:VEVENT', - ); - final canonical = intended.replaceFirst( - 'PARTSTAT=NEEDS-ACTION', - 'PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2', - ); - final remote = _FakeMutationRemote( - put: - ({ - required uri, - required rawIcs, - required ifMatch, - required ifNoneMatch, - }) async { - puts++; - return _success; - }, - fetcher: (href, uri) async => - _live(href, uri, '"scheduled"', canonical), - ); - final result = - await DavConditionalMutationService( - remoteClient: remote, - implicitScheduling: true, - ).create( - collectionUri: _collectionUri, - object: DavNewObject( - uid: 'event@example.test', - initialMemberName: 'meeting.ics', - rawIcs: intended, - componentType: 'VEVENT', - ), - capabilities: _writable, - correlationId: 'meeting', - ); - expect(puts, 1); - expect(result.canonicalObject?.rawIcsBody, canonical); - expect(result.canonicalObject?.etag, '"scheduled"'); - }, - ); + test('implicit scheduling adopts a confirmed server-modified meeting exactly once', () async { + var puts = 0; + final intended = _event('Meeting').replaceFirst( + 'END:VEVENT', + 'ORGANIZER:mailto:owner@example.test\r\nATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:guest@example.test\r\nEND:VEVENT', + ); + final canonical = intended.replaceFirst( + 'PARTSTAT=NEEDS-ACTION', + 'PARTSTAT=ACCEPTED;SCHEDULE-STATUS=1.2', + ); + final remote = _FakeMutationRemote( + put: + ({ + required uri, + required rawIcs, + required ifMatch, + required ifNoneMatch, + }) async { + puts++; + return _success; + }, + fetcher: (href, uri) async => _live(href, uri, '"scheduled"', canonical), + ); + final result = + await DavConditionalMutationService( + remoteClient: remote, + implicitScheduling: true, + ).create( + collectionUri: _collectionUri, + object: DavNewObject( + uid: 'event@example.test', + initialMemberName: 'meeting.ics', + rawIcs: intended, + componentType: 'VEVENT', + ), + capabilities: _writable, + correlationId: 'meeting', + ); + expect(puts, 1); + expect(result.canonicalObject?.rawIcsBody, canonical); + expect(result.canonicalObject?.etag, '"scheduled"'); + }); test( 'restart reconciles scheduling status without resending a meeting update', @@ -537,51 +533,48 @@ void main() { expect(result.canonicalObject?.hrefKey, destinationHref); }); - test( - 'lost MOVE response reconciles destination without repeating MOVE', - () async { - final destinationUri = Uri.parse( - 'https://cloud.example.test/remote.php/dav/calendars/alex/home/event.ics', - ); - final destinationHref = destinationUri.path; - var moves = 0; - final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async { - moves += 1; - throw const DavException( - kind: DavErrorKind.network, - code: 'ConnectionDroppedAfterMove', - safeMessage: 'Connection dropped.', - ); - }, - fetcher: (href, uri) async => href == destinationHref - ? _live(href, uri, '"moved"', _event('Baseline')) - : DavFetchedMember.missing(hrefKey: href, requestUri: uri), - ); + test('lost MOVE response reconciles destination without repeating MOVE', () async { + final destinationUri = Uri.parse( + 'https://cloud.example.test/remote.php/dav/calendars/alex/home/event.ics', + ); + final destinationHref = destinationUri.path; + var moves = 0; + final remote = _FakeMutationRemote( + move: + ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async { + moves += 1; + throw const DavException( + kind: DavErrorKind.network, + code: 'ConnectionDroppedAfterMove', + safeMessage: 'Connection dropped.', + ); + }, + fetcher: (href, uri) async => href == destinationHref + ? _live(href, uri, '"moved"', _event('Baseline')) + : DavFetchedMember.missing(hrefKey: href, requestUri: uri), + ); - final result = await DavConditionalMutationService(remoteClient: remote) - .move( - sourceHrefKey: _href, - sourceUri: _uri, - destinationHrefKey: destinationHref, - destinationUri: destinationUri, - baselineEtag: 'W/"baseline"', - baselineRawIcs: _event('Baseline'), - isEvent: true, - sourceCapabilities: _writable, - destinationCapabilities: _writable, - correlationId: 'unknown-move', - ); + final result = await DavConditionalMutationService(remoteClient: remote) + .move( + sourceHrefKey: _href, + sourceUri: _uri, + destinationHrefKey: destinationHref, + destinationUri: destinationUri, + baselineEtag: 'W/"baseline"', + baselineRawIcs: _event('Baseline'), + isEvent: true, + sourceCapabilities: _writable, + destinationCapabilities: _writable, + correlationId: 'unknown-move', + ); - expect(moves, 1); - expect(result.outcome, DavMutationOutcome.succeeded); - }, - ); + expect(moves, 1); + expect(result.outcome, DavMutationOutcome.succeeded); + }); test('read-only capability blocks mutation before any request', () async { var requested = false; @@ -726,26 +719,25 @@ void main() { ); } -typedef _PutCallback = - Future Function({ - required Uri uri, - required String rawIcs, - required String? ifMatch, - required bool ifNoneMatch, - }); -typedef _DeleteCallback = - Future Function({ - required Uri uri, - required String ifMatch, - }); -typedef _MoveCallback = - Future Function({ - required Uri sourceUri, - required Uri destinationUri, - required String ifMatch, - }); -typedef _FetchCallback = - Future Function(String href, Uri uri); +typedef _PutCallback = Future Function({ + required Uri uri, + required String rawIcs, + required String? ifMatch, + required bool ifNoneMatch, +}); +typedef _DeleteCallback = Future Function({ + required Uri uri, + required String ifMatch, +}); +typedef _MoveCallback = Future Function({ + required Uri sourceUri, + required Uri destinationUri, + required String ifMatch, +}); +typedef _FetchCallback = Future Function( + String href, + Uri uri, +); final class _FakeMutationRemote implements DavMutationRemoteClient { _FakeMutationRemote({this.put, this.delete, this.move, this.fetcher}); diff --git a/test/dav/mutation/dav_mutation_patch_test.dart b/test/dav/mutation/dav_mutation_patch_test.dart index 1ba5b05f..3f977764 100644 --- a/test/dav/mutation/dav_mutation_patch_test.dart +++ b/test/dav/mutation/dav_mutation_patch_test.dart @@ -56,9 +56,9 @@ void main() { operations: [DavPatchOperation.setTaskProgress(percent)], ).applyTo(_task, nowUtc: DateTime.utc(2026, 8, 8, 12, 34, 56)); - final completed = IcalSemanticDocument.parse( - apply(100), - ).components.single; + final completed = IcalSemanticDocument.parse(apply(100)) + .components + .single; expect(completed.status, 'COMPLETED'); expect(completed.percentComplete, 100); expect(completed.completed?.rawValue, '20260808T123456Z'); @@ -194,43 +194,40 @@ void main() { expect(result, contains('X-ALARM-UNKNOWN:keep')); }); - test( - 'editing one DAV reminder preserves the other editable alarm verbatim', - () { - final baseline = _baselineEvent.replaceFirst( - 'BEGIN:VALARM\r\nACTION:AUDIO', - 'BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30M\r\n' - 'DESCRIPTION:Second reminder\r\nX-ALARM-KEEP:second\r\nEND:VALARM\r\n' - 'BEGIN:VALARM\r\nACTION:AUDIO', - ); - final patch = buildDavEventUpdatePatch( - target: target, - baselineRawIcs: baseline, - input: DavEventMutationInput( - title: 'Baseline', - allDay: false, - start: DateTime(2026, 8, 3, 9), - end: DateTime(2026, 8, 3, 10), - startTimeZone: 'America/Vancouver', - endTimeZone: 'America/Vancouver', - description: 'Baseline details', - location: 'Room one', - reminders: const { - 'davEditableRows': [ - {'originalIndex': 0, 'minutes': 15}, - {'originalIndex': 1, 'minutes': 45}, - ], - }, - remindersChanged: true, - ), - ); - final result = patch!.applyTo(baseline, nowUtc: DateTime.utc(2026, 8, 8)); - expect(result, contains('TRIGGER:-PT15M')); - expect(result, contains('X-ALARM-UNKNOWN:keep')); - expect(result, contains('TRIGGER:-PT45M')); - expect(result, contains('ACTION:AUDIO')); - }, - ); + test('editing one DAV reminder preserves the other editable alarm verbatim', () { + final baseline = _baselineEvent.replaceFirst( + 'BEGIN:VALARM\r\nACTION:AUDIO', + 'BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30M\r\n' + 'DESCRIPTION:Second reminder\r\nX-ALARM-KEEP:second\r\nEND:VALARM\r\n' + 'BEGIN:VALARM\r\nACTION:AUDIO', + ); + final patch = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baseline, + input: DavEventMutationInput( + title: 'Baseline', + allDay: false, + start: DateTime(2026, 8, 3, 9), + end: DateTime(2026, 8, 3, 10), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + description: 'Baseline details', + location: 'Room one', + reminders: const { + 'davEditableRows': [ + {'originalIndex': 0, 'minutes': 15}, + {'originalIndex': 1, 'minutes': 45}, + ], + }, + remindersChanged: true, + ), + ); + final result = patch!.applyTo(baseline, nowUtc: DateTime.utc(2026, 8, 8)); + expect(result, contains('TRIGGER:-PT15M')); + expect(result, contains('X-ALARM-UNKNOWN:keep')); + expect(result, contains('TRIGGER:-PT45M')); + expect(result, contains('ACTION:AUDIO')); + }); test('DAV reminder removal and addition preserve unrelated alarms', () { final baseline = _baselineEvent.replaceFirst( @@ -622,9 +619,8 @@ END:VCALENDAR\r baseline, nowUtc: DateTime.utc(2026, 8, 8, 12), ); - final range = IcalSemanticDocument.parse( - candidate, - ).components.singleWhere((component) => component.recurrenceRange != null); + final range = IcalSemanticDocument.parse(candidate).components + .singleWhere((component) => component.recurrenceRange != null); expect(range.recurrenceRange, 'THISANDFUTURE'); expect(range.recurrenceId?.rawValue, '20260817T090000'); @@ -677,9 +673,8 @@ END:VCALENDAR\r occurrenceKey: 'TZID=America/Vancouver:20260810T090000', baselineRawIcs: _baselineEvent, ).applyTo(_baselineEvent, nowUtc: DateTime.utc(2026, 8, 8, 12)); - final exception = IcalSemanticDocument.parse( - existing, - ).components.singleWhere((component) => component.recurrenceIdKey != null); + final exception = IcalSemanticDocument.parse(existing).components + .singleWhere((component) => component.recurrenceIdKey != null); expect(exception.status, 'CANCELLED'); expect('BEGIN:VEVENT'.allMatches(existing), hasLength(2)); }); diff --git a/test/dav/mutation/dav_pending_operations_test.dart b/test/dav/mutation/dav_pending_operations_test.dart index 9f2e85f7..a268ef89 100644 --- a/test/dav/mutation/dav_pending_operations_test.dart +++ b/test/dav/mutation/dav_pending_operations_test.dart @@ -231,9 +231,9 @@ void main() { ).replayDueOperations(); expect(replay.conflictCount, 1); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; expect(conflict.single.conflictCode, 'DavConflictMoveDestinationExists'); expect(conflict.single.canKeepServer, isTrue); expect(conflict.single.canReapplyLocal, isFalse); @@ -278,120 +278,114 @@ void main() { }, ); - test( - 'Keep server accepts a remotely changed MOVE destination under its identity', - () async { - await _seedDestination(database); - final source = await database.select(database.davObjects).getSingle(); - final operationId = await queue.enqueueMove( - accountId: 'account', - sourceCollectionId: 'collection', - destinationCollectionId: 'destination', - objectId: source.id, - target: _target, - postMovePatch: _patch('SUMMARY', 'Intended title'), - ); - const destinationHref = '/remote.php/dav/calendars/alex/home/event.ics'; - var sourceExists = true; - String? destinationRaw; - var destinationEtag = '"moved"'; - var moves = 0; - var puts = 0; - final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async { - moves += 1; - sourceExists = false; - destinationRaw = _event('Baseline'); - return _success; - }, - put: ({required rawIcs, required ifMatch, required ifNoneMatch}) async { - puts += 1; - expect(rawIcs, contains('SUMMARY:Intended title')); - throw const DavException( - kind: DavErrorKind.invalidCalendarData, - code: 'DavMalformedResource', - safeMessage: 'The destination patch was rejected.', - statusCode: 415, - ); - }, - fetcher: (href) async { - if (href == destinationHref && destinationRaw != null) { - return _live(href, destinationEtag, destinationRaw!); - } - if (href == _eventHref && sourceExists) { - return _live(href, 'W/"baseline"', _event('Baseline')); - } - return _missing(href); - }, - ); - final replayer = _replayer(database, objectRepository, remote); - - await replayer.replayDueOperations(); - final failed = await database.pendingOpsDao.getOp(operationId); - expect(failed?.state, 'failed'); - expect(failed?.retryClassification, davPartialMoveRetryClassification); - expect(moves, 1); - expect(puts, 1); + test('Keep server accepts a remotely changed MOVE destination under its identity', () async { + await _seedDestination(database); + final source = await database.select(database.davObjects).getSingle(); + final operationId = await queue.enqueueMove( + accountId: 'account', + sourceCollectionId: 'collection', + destinationCollectionId: 'destination', + objectId: source.id, + target: _target, + postMovePatch: _patch('SUMMARY', 'Intended title'), + ); + const destinationHref = '/remote.php/dav/calendars/alex/home/event.ics'; + var sourceExists = true; + String? destinationRaw; + var destinationEtag = '"moved"'; + var moves = 0; + var puts = 0; + final remote = _FakeMutationRemote( + move: + ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async { + moves += 1; + sourceExists = false; + destinationRaw = _event('Baseline'); + return _success; + }, + put: ({required rawIcs, required ifMatch, required ifNoneMatch}) async { + puts += 1; + expect(rawIcs, contains('SUMMARY:Intended title')); + throw const DavException( + kind: DavErrorKind.invalidCalendarData, + code: 'DavMalformedResource', + safeMessage: 'The destination patch was rejected.', + statusCode: 415, + ); + }, + fetcher: (href) async { + if (href == destinationHref && destinationRaw != null) { + return _live(href, destinationEtag, destinationRaw!); + } + if (href == _eventHref && sourceExists) { + return _live(href, 'W/"baseline"', _event('Baseline')); + } + return _missing(href); + }, + ); + final replayer = _replayer(database, objectRepository, remote); - destinationRaw = _event('Changed by another client'); - destinationEtag = '"changed-remotely"'; - await PendingOpResolutionService( - database: database, - accountId: 'account', - syncTasks: () async => fail('An event move must not sync tasks.'), - syncCalendar: () async { - final result = await replayer.replayDueOperations(); - expect(result.conflictCount, 1); - }, - nowUtc: () => _now, - ).retryNow(operationId); + await replayer.replayDueOperations(); + final failed = await database.pendingOpsDao.getOp(operationId); + expect(failed?.state, 'failed'); + expect(failed?.retryClassification, davPartialMoveRetryClassification); + expect(moves, 1); + expect(puts, 1); + + destinationRaw = _event('Changed by another client'); + destinationEtag = '"changed-remotely"'; + await PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async => fail('An event move must not sync tasks.'), + syncCalendar: () async { + final result = await replayer.replayDueOperations(); + expect(result.conflictCount, 1); + }, + nowUtc: () => _now, + ).retryNow(operationId); - final conflicts = await DavConflictRepository( - database: database, - ).watchUnresolved().first; - expect(conflicts, hasLength(1)); - expect( - conflicts.single.conflictCode, - 'DavConflictMoveDestinationChanged', - ); - expect(conflicts.single.canKeepServer, isTrue); - expect(conflicts.single.canDuplicate, isTrue); + final conflicts = await DavConflictRepository(database: database) + .watchUnresolved() + .first; + expect(conflicts, hasLength(1)); + expect(conflicts.single.conflictCode, 'DavConflictMoveDestinationChanged'); + expect(conflicts.single.canKeepServer, isTrue); + expect(conflicts.single.canDuplicate, isTrue); - await DavConflictResolutionService( - database: database, - objectRepository: objectRepository, - nowUtc: () => _now, - ).resolve(conflicts.single.id, DavConflictResolution.keepServer); + await DavConflictResolutionService( + database: database, + objectRepository: objectRepository, + nowUtc: () => _now, + ).resolve(conflicts.single.id, DavConflictResolution.keepServer); - expect(moves, 1); - expect(puts, 1); - expect(await database.pendingOpsDao.getOp(operationId), isNull); - final sourceAfter = await (database.select( - database.davObjects, - )..where((row) => row.id.equals(source.id))).getSingle(); - expect(sourceAfter.serverDeleted, isTrue); - final destination = await (database.select( - database.davObjects, - )..where((row) => row.collectionId.equals('destination'))).getSingle(); - expect(destination.hrefKey, destinationHref); - expect( - destination.requestUri, - 'https://cloud.example.test$destinationHref', - ); - expect(destination.primaryUid, 'event@example.test'); - expect(destination.etag, '"changed-remotely"'); - expect(destination.rawIcsBody, _event('Changed by another client')); - final events = await database.select(database.calendarEvents).get(); - expect(events, hasLength(1)); - expect(events.single.calendarSourceId, 'dav-calendar-destination'); - expect(events.single.title, 'Changed by another client'); - }, - ); + expect(moves, 1); + expect(puts, 1); + expect(await database.pendingOpsDao.getOp(operationId), isNull); + final sourceAfter = await (database.select( + database.davObjects, + )..where((row) => row.id.equals(source.id))).getSingle(); + expect(sourceAfter.serverDeleted, isTrue); + final destination = await (database.select( + database.davObjects, + )..where((row) => row.collectionId.equals('destination'))).getSingle(); + expect(destination.hrefKey, destinationHref); + expect( + destination.requestUri, + 'https://cloud.example.test$destinationHref', + ); + expect(destination.primaryUid, 'event@example.test'); + expect(destination.etag, '"changed-remotely"'); + expect(destination.rawIcsBody, _event('Changed by another client')); + final events = await database.select(database.calendarEvents).get(); + expect(events, hasLength(1)); + expect(events.single.calendarSourceId, 'dav-calendar-destination'); + expect(events.single.title, 'Changed by another client'); + }); test( 'Keep server resolves a MOVE whose source and destination are absent', @@ -426,9 +420,9 @@ void main() { ).replayDueOperations(); expect(result.conflictCount, 1); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; expect(conflict.single.conflictCode, 'DavConflictMoveSourceRemoved'); expect(conflict.single.canKeepServer, isTrue); expect(conflict.single.canDuplicate, isTrue); @@ -492,9 +486,9 @@ void main() { ).replayDueOperations(); expect(result.conflictCount, 1); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; expect(conflict.single.conflictCode, 'DavConflictStaleMove'); expect(conflict.single.canKeepServer, isTrue); expect(conflict.single.canDuplicate, isTrue); @@ -575,9 +569,9 @@ void main() { expect(replay.conflictCount, 1); expect(moves, 1); expect(puts, 1); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; expect(conflict.single.conflictCode, 'DavConflictMoveDestinationChanged'); expect(conflict.single.canKeepServer, isTrue); expect(conflict.single.canDuplicate, isTrue); @@ -676,9 +670,9 @@ void main() { expect(replay.conflictCount, 1); expect(moves, 1); expect(puts, 3); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; expect(conflict.single.conflictCode, 'DavConflictMoveDestinationChanged'); expect(conflict.single.canKeepServer, isTrue); expect(conflict.single.canDuplicate, isTrue); @@ -717,182 +711,174 @@ void main() { }, ); - test( - 'Keep server cancels an unattempted task subtree after child MOVE collision', - () async { - await _seedDestination(database); - const parentUid = 'parent@example.test'; - const childUid = 'child@example.test'; - const parentHref = '${_collectionHref}parent-task.ics'; - const childHref = '${_collectionHref}child-task.ics'; - const destinationChildHref = - '/remote.php/dav/calendars/alex/home/child-task.ics'; - final parentRaw = _task( - uid: parentUid, - start: 'DTSTART:20260809T090000Z', - due: 'DUE:20260809T100000Z', - ); - final childRaw = _taskWithParent(childUid, 'Child task'); - await _commitMembers( - objectRepository, - collectionId: 'collection', - objects: [ - _preparedMember(href: parentHref, etag: '"parent"', body: parentRaw), - _preparedMember(href: childHref, etag: '"child"', body: childRaw), - ], - ); - final sourceTasks = await database.tasksDao.listTasks( - 'account', - 'dav-task-list-collection', - ); - final parent = sourceTasks.singleWhere( - (task) => task.icalUid == parentUid, - ); - final child = sourceTasks.singleWhere((task) => task.icalUid == childUid); - await TasksRepository( - database: database, - accountId: 'account', - nowUtc: () => _now, - ).moveTask( - TaskMoveInput( - sourceTaskListId: 'dav-task-list-collection', - destinationTaskListId: 'dav-task-list-destination', - taskId: parent.id, - ), - ); - final operations = await database.select(database.pendingOps).get(); - final childMove = operations.singleWhere( - (operation) => operation.taskId == child.id, - ); - final parentMove = operations.singleWhere( - (operation) => operation.taskId == parent.id, - ); - expect(parentMove.dependsOnOpId, childMove.id); - expect(parentMove.createdAtUtc, childMove.createdAtUtc); - - final unrelatedRaw = _task( - uid: 'unrelated-destination@example.test', - start: 'DTSTART:20260810T090000Z', - due: 'DUE:20260810T100000Z', - ); - var moves = 0; - final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async { - moves += 1; - expect(sourceUri.path, childHref); - expect(destinationUri.path, destinationChildHref); - return _precondition; - }, - fetcher: (href) async { - if (href == destinationChildHref) { - return _live(href, '"unrelated"', unrelatedRaw); - } - if (href == childHref) { - return _live(href, '"child"', childRaw); - } - return _missing(href); - }, - ); - final replayer = _replayer(database, objectRepository, remote); - final firstReplay = await replayer.replayDueOperations(); - expect(firstReplay.conflictCount, 1); - expect(moves, 1); - expect( - (await database.pendingOpsDao.getOp(childMove.id))?.state, - 'conflict', - ); - expect( - (await database.pendingOpsDao.getOp(parentMove.id))?.state, - 'pending', - ); + test('Keep server cancels an unattempted task subtree after child MOVE collision', () async { + await _seedDestination(database); + const parentUid = 'parent@example.test'; + const childUid = 'child@example.test'; + const parentHref = '${_collectionHref}parent-task.ics'; + const childHref = '${_collectionHref}child-task.ics'; + const destinationChildHref = + '/remote.php/dav/calendars/alex/home/child-task.ics'; + final parentRaw = _task( + uid: parentUid, + start: 'DTSTART:20260809T090000Z', + due: 'DUE:20260809T100000Z', + ); + final childRaw = _taskWithParent(childUid, 'Child task'); + await _commitMembers( + objectRepository, + collectionId: 'collection', + objects: [ + _preparedMember(href: parentHref, etag: '"parent"', body: parentRaw), + _preparedMember(href: childHref, etag: '"child"', body: childRaw), + ], + ); + final sourceTasks = await database.tasksDao.listTasks( + 'account', + 'dav-task-list-collection', + ); + final parent = sourceTasks.singleWhere((task) => task.icalUid == parentUid); + final child = sourceTasks.singleWhere((task) => task.icalUid == childUid); + await TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => _now, + ).moveTask( + TaskMoveInput( + sourceTaskListId: 'dav-task-list-collection', + destinationTaskListId: 'dav-task-list-destination', + taskId: parent.id, + ), + ); + final operations = await database.select(database.pendingOps).get(); + final childMove = operations.singleWhere( + (operation) => operation.taskId == child.id, + ); + final parentMove = operations.singleWhere( + (operation) => operation.taskId == parent.id, + ); + expect(parentMove.dependsOnOpId, childMove.id); + expect(parentMove.createdAtUtc, childMove.createdAtUtc); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; - expect(conflict.single.conflictCode, 'DavConflictMoveDestinationExists'); - final resolutionService = DavConflictResolutionService( - database: database, - objectRepository: objectRepository, - nowUtc: () => _now, - ); - await (database.update(database.pendingOps) - ..where((row) => row.id.equals(parentMove.id))) - .write(const PendingOpsCompanion(attemptCount: Value(1))); - await expectLater( - resolutionService.resolve( - conflict.single.id, - DavConflictResolution.keepServer, - ), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'DavConflictResolutionUnavailable', - ), - ), - ); - expect(await database.select(database.pendingOps).get(), hasLength(2)); - expect( - await (database.select( - database.davObjects, - )..where((row) => row.collectionId.equals('destination'))).get(), - isEmpty, - ); + final unrelatedRaw = _task( + uid: 'unrelated-destination@example.test', + start: 'DTSTART:20260810T090000Z', + due: 'DUE:20260810T100000Z', + ); + var moves = 0; + final remote = _FakeMutationRemote( + move: + ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async { + moves += 1; + expect(sourceUri.path, childHref); + expect(destinationUri.path, destinationChildHref); + return _precondition; + }, + fetcher: (href) async { + if (href == destinationChildHref) { + return _live(href, '"unrelated"', unrelatedRaw); + } + if (href == childHref) { + return _live(href, '"child"', childRaw); + } + return _missing(href); + }, + ); + final replayer = _replayer(database, objectRepository, remote); + final firstReplay = await replayer.replayDueOperations(); + expect(firstReplay.conflictCount, 1); + expect(moves, 1); + expect( + (await database.pendingOpsDao.getOp(childMove.id))?.state, + 'conflict', + ); + expect( + (await database.pendingOpsDao.getOp(parentMove.id))?.state, + 'pending', + ); - await (database.update(database.pendingOps) - ..where((row) => row.id.equals(parentMove.id))) - .write(const PendingOpsCompanion(attemptCount: Value(0))); - await resolutionService.resolve( + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; + expect(conflict.single.conflictCode, 'DavConflictMoveDestinationExists'); + final resolutionService = DavConflictResolutionService( + database: database, + objectRepository: objectRepository, + nowUtc: () => _now, + ); + await (database.update(database.pendingOps) + ..where((row) => row.id.equals(parentMove.id))) + .write(const PendingOpsCompanion(attemptCount: Value(1))); + await expectLater( + resolutionService.resolve( conflict.single.id, DavConflictResolution.keepServer, - ); - - expect(await database.select(database.pendingOps).get(), isEmpty); - final secondReplay = await replayer.replayDueOperations(); - expect(secondReplay.appliedCount, 0); - expect(moves, 1); - final restoredSourceTasks = await database.tasksDao.listTasks( - 'account', - 'dav-task-list-collection', - ); - expect(restoredSourceTasks.map((task) => task.icalUid).toSet(), { - parentUid, - childUid, - }); - final restoredParent = restoredSourceTasks.singleWhere( - (task) => task.icalUid == parentUid, - ); - final restoredChild = restoredSourceTasks.singleWhere( - (task) => task.icalUid == childUid, - ); - expect(restoredChild.parent, restoredParent.id); - expect(restoredChild.parentUid, parentUid); - final destinationTasks = await database.tasksDao.listTasks( - 'account', - 'dav-task-list-destination', - ); - expect(destinationTasks, hasLength(1)); - expect( - destinationTasks.single.icalUid, - 'unrelated-destination@example.test', - ); - final destinationObject = await (database.select( + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavConflictResolutionUnavailable', + ), + ), + ); + expect(await database.select(database.pendingOps).get(), hasLength(2)); + expect( + await (database.select( database.davObjects, - )..where((row) => row.hrefKey.equals(destinationChildHref))).getSingle(); - expect(destinationObject.collectionId, 'destination'); - expect( - destinationObject.primaryUid, - 'unrelated-destination@example.test', - ); - expect(destinationObject.etag, '"unrelated"'); - expect(destinationObject.rawIcsBody, unrelatedRaw); - }, - ); + )..where((row) => row.collectionId.equals('destination'))).get(), + isEmpty, + ); + + await (database.update(database.pendingOps) + ..where((row) => row.id.equals(parentMove.id))) + .write(const PendingOpsCompanion(attemptCount: Value(0))); + await resolutionService.resolve( + conflict.single.id, + DavConflictResolution.keepServer, + ); + + expect(await database.select(database.pendingOps).get(), isEmpty); + final secondReplay = await replayer.replayDueOperations(); + expect(secondReplay.appliedCount, 0); + expect(moves, 1); + final restoredSourceTasks = await database.tasksDao.listTasks( + 'account', + 'dav-task-list-collection', + ); + expect(restoredSourceTasks.map((task) => task.icalUid).toSet(), { + parentUid, + childUid, + }); + final restoredParent = restoredSourceTasks.singleWhere( + (task) => task.icalUid == parentUid, + ); + final restoredChild = restoredSourceTasks.singleWhere( + (task) => task.icalUid == childUid, + ); + expect(restoredChild.parent, restoredParent.id); + expect(restoredChild.parentUid, parentUid); + final destinationTasks = await database.tasksDao.listTasks( + 'account', + 'dav-task-list-destination', + ); + expect(destinationTasks, hasLength(1)); + expect( + destinationTasks.single.icalUid, + 'unrelated-destination@example.test', + ); + final destinationObject = await (database.select( + database.davObjects, + )..where((row) => row.hrefKey.equals(destinationChildHref))).getSingle(); + expect(destinationObject.collectionId, 'destination'); + expect(destinationObject.primaryUid, 'unrelated-destination@example.test'); + expect(destinationObject.etag, '"unrelated"'); + expect(destinationObject.rawIcsBody, unrelatedRaw); + }); test('revoked credential pauses replay and preserves pending work', () async { final object = await database.select(database.davObjects).getSingle(); @@ -2127,12 +2113,11 @@ END ); const destinationHref = '/remote.php/dav/calendars/alex/home/event.ics'; final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async => _success, + move: ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async => _success, fetcher: (href) async => _live(href, '"moved"', recurring), ); @@ -2244,12 +2229,11 @@ END ), ); final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async => _success, + move: ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async => _success, fetcher: (href) async => _live(href, '"future-moved"', futureEvent), ); @@ -2372,12 +2356,11 @@ END ), ); final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async => _success, + move: ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async => _success, fetcher: (href) async => _live(href, '"range-moved"', recurring), ); @@ -2484,12 +2467,11 @@ END END '''); final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async => _success, + move: ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async => _success, fetcher: (href) async => _live(href, '"moved"', _event('Baseline')), ); @@ -2519,9 +2501,8 @@ END hasLength(1), ); expect( - await LocationResolutionRepository( - database, - ).load(sourceIdentity, sourceEvent.location!), + await LocationResolutionRepository(database) + .load(sourceIdentity, sourceEvent.location!), remembered, ); }, @@ -2685,12 +2666,11 @@ END var serverRaw = recurringTask; final remote = _FakeMutationRemote( - move: - ({ - required sourceUri, - required destinationUri, - required ifMatch, - }) async => _success, + move: ({ + required sourceUri, + required destinationUri, + required ifMatch, + }) async => _success, put: ({required rawIcs, required ifMatch, required ifNoneMatch}) async { serverRaw = rawIcs; return _success; @@ -2815,20 +2795,19 @@ DavPendingOperationsReplayer _replayer( random: Random(1), ); -typedef _Put = - Future Function({ - required String rawIcs, - required String? ifMatch, - required bool ifNoneMatch, - }); -typedef _Delete = - Future Function({required String ifMatch}); -typedef _Move = - Future Function({ - required Uri sourceUri, - required Uri destinationUri, - required String ifMatch, - }); +typedef _Put = Future Function({ + required String rawIcs, + required String? ifMatch, + required bool ifNoneMatch, +}); +typedef _Delete = Future Function({ + required String ifMatch, +}); +typedef _Move = Future Function({ + required Uri sourceUri, + required Uri destinationUri, + required String ifMatch, +}); typedef _Fetch = Future Function(String href); final class _FakeMutationRemote implements DavMutationRemoteClient { diff --git a/test/dav/mutation/dav_repository_mutation_integration_test.dart b/test/dav/mutation/dav_repository_mutation_integration_test.dart index 3822a96a..bfe68d02 100644 --- a/test/dav/mutation/dav_repository_mutation_integration_test.dart +++ b/test/dav/mutation/dav_repository_mutation_integration_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/dav/dav_errors.dart'; @@ -110,68 +111,63 @@ void main() { ); } - test( - 'timing-only occurrence exception retains guests, alarms and opaque provider fields', - () async { - await _seedScheduling(database, 'owner@example.test'); - final baseline = _recurringEventResource().replaceFirst( - 'X-SERIES-KEEP:opaque', - 'X-SERIES-KEEP:opaque\r\nATTENDEE;CN=Guest:mailto:guest@example.test\r\n' - 'ORGANIZER:mailto:owner@example.test\r\nATTACH:https://example.test/agenda.pdf\r\n' - 'X-ALT-DESC;FMTTYPE=text/html:

Full HTML

\r\nBEGIN:VALARM\r\n' - 'ACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nDESCRIPTION:Reminder\r\nEND:VALARM', - ); - await _commitObjects(objectRepository, [ - _prepared( - href: '${_collectionHref}timing-series.ics', - etag: '"timing"', - body: baseline, - ), - ]); - final row = (await database.select(database.calendarEvents).get()) - .singleWhere( - (row) => row.occurrenceKey!.contains('2026-08-10T09:00:00'), - ); - final detail = (await calendarRepository.loadEventDetail(row.id))!; - await calendarRepository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail).copyWith( - start: DateTime.utc(2026, 8, 10, 13), - end: DateTime.utc(2026, 8, 10, 14), - recurringMutationScope: RecurringEventMutationScope.singleOccurrence, - ), - timingBaseline: EventTimingBaseline.fromDetail(detail), - ); - final operation = await database.select(database.pendingOps).getSingle(); - final candidate = DavMutationPatch.fromJsonString( - operation.mutationPatchJson!, - ).applyTo(baseline, nowUtc: _now); - final exception = IcalSemanticDocument.parse(candidate).components - .singleWhere( - (component) => - component.recurrenceId?.rawValue == '20260810T090000Z', - ); - expect(exception.start!.rawValue, '20260810T130000Z'); - expect(exception.attendees, hasLength(1)); - expect(exception.organizers, hasLength(1)); - expect(exception.alarms, hasLength(1)); - expect( - exception.documentComponent.firstProperty('ATTACH')!.rawValue, - 'https://example.test/agenda.pdf', - ); - expect( - exception.documentComponent.firstProperty('X-ALT-DESC')!.rawValue, - '

Full HTML

', - ); - expect(exception.documentComponent.firstProperty('RRULE'), isNull); - expect(candidate, contains('SUMMARY:Existing exception')); - expect( - (await calendarRepository.loadEventDetail( - row.id, - ))!.providerOriginalStartKey, - detail.providerOriginalStartKey, - ); - }, - ); + test('timing-only occurrence exception retains guests, alarms and opaque provider fields', () async { + await _seedScheduling(database, 'owner@example.test'); + final baseline = _recurringEventResource().replaceFirst( + 'X-SERIES-KEEP:opaque', + 'X-SERIES-KEEP:opaque\r\nATTENDEE;CN=Guest:mailto:guest@example.test\r\n' + 'ORGANIZER:mailto:owner@example.test\r\nATTACH:https://example.test/agenda.pdf\r\n' + 'X-ALT-DESC;FMTTYPE=text/html:

Full HTML

\r\nBEGIN:VALARM\r\n' + 'ACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + await _commitObjects(objectRepository, [ + _prepared( + href: '${_collectionHref}timing-series.ics', + etag: '"timing"', + body: baseline, + ), + ]); + final row = (await database.select(database.calendarEvents).get()) + .singleWhere( + (row) => row.occurrenceKey!.contains('2026-08-10T09:00:00'), + ); + final detail = (await calendarRepository.loadEventDetail(row.id))!; + await calendarRepository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + start: DateTime.utc(2026, 8, 10, 13), + end: DateTime.utc(2026, 8, 10, 14), + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + timingBaseline: EventTimingBaseline.fromDetail(detail), + ); + final operation = await database.select(database.pendingOps).getSingle(); + final candidate = DavMutationPatch.fromJsonString( + operation.mutationPatchJson!, + ).applyTo(baseline, nowUtc: _now); + final exception = IcalSemanticDocument.parse(candidate).components + .singleWhere( + (component) => component.recurrenceId?.rawValue == '20260810T090000Z', + ); + expect(exception.start!.rawValue, '20260810T130000Z'); + expect(exception.attendees, hasLength(1)); + expect(exception.organizers, hasLength(1)); + expect(exception.alarms, hasLength(1)); + expect( + exception.documentComponent.firstProperty('ATTACH')!.rawValue, + 'https://example.test/agenda.pdf', + ); + expect( + exception.documentComponent.firstProperty('X-ALT-DESC')!.rawValue, + '

Full HTML

', + ); + expect(exception.documentComponent.firstProperty('RRULE'), isNull); + expect(candidate, contains('SUMMARY:Existing exception')); + expect( + (await calendarRepository.loadEventDetail(row.id))! + .providerOriginalStartKey, + detail.providerOriginalStartKey, + ); + }); for (final floating in [false, true]) { test( @@ -289,9 +285,8 @@ void main() { expect(_createRaw(operations.single), contains('CATEGORIES:Work')); await calendarRepository.updateLocalEvent( - _draftForEvent( - events.single, - ).copyWith(title: 'Edited before upload', location: 'Room 4'), + _draftForEvent(events.single) + .copyWith(title: 'Edited before upload', location: 'Room 4'), ); events = await database.select(database.calendarEvents).get(); @@ -493,9 +488,8 @@ void main() { ); final operation = await database.select(database.pendingOps).getSingle(); final candidate = IcalSemanticDocument.parse( - DavMutationPatch.fromJsonString( - operation.mutationPatchJson!, - ).applyTo(baseline, nowUtc: _now), + DavMutationPatch.fromJsonString(operation.mutationPatchJson!) + .applyTo(baseline, nowUtc: _now), ); final exception = candidate.components.singleWhere( (c) => c.recurrenceId?.rawValue == '20260810T090000Z', @@ -569,9 +563,8 @@ void main() { final pending = await database.select(database.pendingOps).getSingle(); expect(pending.baselineEtag, '"series"'); final saved = IcalSemanticDocument.parse( - DavMutationPatch.fromJsonString( - pending.mutationPatchJson!, - ).applyTo(baseline, nowUtc: _now), + DavMutationPatch.fromJsonString(pending.mutationPatchJson!) + .applyTo(baseline, nowUtc: _now), ); expect( saved.components @@ -751,164 +744,106 @@ void main() { }, ); - test( - 'clearing a sparse existing exception blocks inherited location', - () async { - final baseline = _recurringEventResource().replaceFirst( - 'SUMMARY:Server series', - 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', - ); - await _commitObjects(objectRepository, [ - _prepared( - href: '${_collectionHref}sparse-located-exception.ics', - etag: '"sparse-located"', - body: baseline, - ), - ]); - final row = (await database.select(database.calendarEvents).get()) - .singleWhere((event) => event.recurrenceIdKey != null); - final detail = (await calendarRepository.loadEventDetail(row.id))!; - expect(detail.location, 'Series room'); - expect(detail.locationPoint?.latitude, 49.2827); - - await calendarRepository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail).copyWith( - clearLocation: true, - recurringMutationScope: RecurringEventMutationScope.singleOccurrence, - ), - ); - - final serialized = await _serializedPendingDavMutation( - database, - baseline, - ); - final document = IcalSemanticDocument.parse(serialized); - final exception = document.components.singleWhere( - (component) => component.recurrenceIdKey != null, - ); - expect(exception.documentComponent.firstProperty('LOCATION'), isNotNull); - expect(exception.location, ''); - expect(exception.documentComponent.firstProperty('GEO'), isNull); - final projected = IcalRecurrenceExpander().expand( - document, - rangeStartUtc: DateTime.utc(2026, 8, 9), - rangeEndUtc: DateTime.utc(2026, 8, 10), - ); - expect(projected.single.location, ''); - expect(projected.single.locationPoint, isNull); - }, - ); - - test( - 'title-only sparse exception edit keeps location inheritance live', - () async { - final baseline = _recurringEventResource().replaceFirst( - 'SUMMARY:Server series', - 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', - ); - await _commitObjects(objectRepository, [ - _prepared( - href: '${_collectionHref}sparse-title-location.ics', - etag: '"sparse-title"', - body: baseline, - ), - ]); - final row = (await database.select(database.calendarEvents).get()) - .singleWhere((event) => event.recurrenceIdKey != null); - final detail = (await calendarRepository.loadEventDetail(row.id))!; - expect(detail.location, 'Series room'); + test('clearing a sparse existing exception blocks inherited location', () async { + final baseline = _recurringEventResource().replaceFirst( + 'SUMMARY:Server series', + 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', + ); + await _commitObjects(objectRepository, [ + _prepared( + href: '${_collectionHref}sparse-located-exception.ics', + etag: '"sparse-located"', + body: baseline, + ), + ]); + final row = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + final detail = (await calendarRepository.loadEventDetail(row.id))!; + expect(detail.location, 'Series room'); + expect(detail.locationPoint?.latitude, 49.2827); - await calendarRepository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail).copyWith( - title: 'Renamed exception', - recurringMutationScope: RecurringEventMutationScope.singleOccurrence, - ), - ); + await calendarRepository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + clearLocation: true, + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); - final serialized = await _serializedPendingDavMutation( - database, - baseline, - ); - final editedDocument = IcalSemanticDocument.parse(serialized); - final exception = editedDocument.components.singleWhere( - (component) => component.recurrenceIdKey != null, - ); - expect(exception.summary, 'Renamed exception'); - expect(exception.documentComponent.firstProperty('LOCATION'), isNull); - expect(exception.documentComponent.firstProperty('GEO'), isNull); + final serialized = await _serializedPendingDavMutation(database, baseline); + final document = IcalSemanticDocument.parse(serialized); + final exception = document.components.singleWhere( + (component) => component.recurrenceIdKey != null, + ); + expect(exception.documentComponent.firstProperty('LOCATION'), isNotNull); + expect(exception.location, ''); + expect(exception.documentComponent.firstProperty('GEO'), isNull); + final projected = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 8, 9), + rangeEndUtc: DateTime.utc(2026, 8, 10), + ); + expect(projected.single.location, ''); + expect(projected.single.locationPoint, isNull); + }); - final masterChanged = serialized.replaceFirst( - 'LOCATION:Series room\r\nGEO:49.2827;-123.1207', - 'LOCATION:New series room\r\nGEO:48.4284;-123.3656', - ); - final projected = IcalRecurrenceExpander().expand( - IcalSemanticDocument.parse(masterChanged), - rangeStartUtc: DateTime.utc(2026, 8, 9), - rangeEndUtc: DateTime.utc(2026, 8, 10), - ); - expect(projected.single.location, 'New series room'); - expect( - projected.single.locationPoint, - GeographicPoint(latitude: 48.4284, longitude: -123.3656), - ); - }, - ); + test('title-only sparse exception edit keeps location inheritance live', () async { + final baseline = _recurringEventResource().replaceFirst( + 'SUMMARY:Server series', + 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', + ); + await _commitObjects(objectRepository, [ + _prepared( + href: '${_collectionHref}sparse-title-location.ics', + etag: '"sparse-title"', + body: baseline, + ), + ]); + final row = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + final detail = (await calendarRepository.loadEventDetail(row.id))!; + expect(detail.location, 'Series room'); - test( - 'new occurrence clear creates an explicit inheritance boundary', - () async { - final baseline = _recurringEventResource().replaceFirst( - 'SUMMARY:Server series', - 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', - ); - await _commitObjects(objectRepository, [ - _prepared( - href: '${_collectionHref}new-clear-exception.ics', - etag: '"new-clear"', - body: baseline, - ), - ]); - final row = (await database.select(database.calendarEvents).get()) - .singleWhere( - (event) => event.occurrenceKey!.contains('2026-08-10T09:00:00'), - ); - final detail = (await calendarRepository.loadEventDetail(row.id))!; + await calendarRepository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + title: 'Renamed exception', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); - await calendarRepository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail).copyWith( - clearLocation: true, - recurringMutationScope: RecurringEventMutationScope.singleOccurrence, - ), - ); + final serialized = await _serializedPendingDavMutation(database, baseline); + final editedDocument = IcalSemanticDocument.parse(serialized); + final exception = editedDocument.components.singleWhere( + (component) => component.recurrenceIdKey != null, + ); + expect(exception.summary, 'Renamed exception'); + expect(exception.documentComponent.firstProperty('LOCATION'), isNull); + expect(exception.documentComponent.firstProperty('GEO'), isNull); - final document = IcalSemanticDocument.parse( - await _serializedPendingDavMutation(database, baseline), - ); - final exception = document.components.singleWhere( - (component) => component.recurrenceId?.rawValue == '20260810T090000Z', - ); - expect(exception.location, ''); - expect(exception.documentComponent.firstProperty('LOCATION'), isNotNull); - expect(exception.documentComponent.firstProperty('GEO'), isNull); - final projected = IcalRecurrenceExpander().expand( - document, - rangeStartUtc: DateTime.utc(2026, 8, 10), - rangeEndUtc: DateTime.utc(2026, 8, 11), - ); - expect(projected.single.location, ''); - expect(projected.single.locationPoint, isNull); - }, - ); + final masterChanged = serialized.replaceFirst( + 'LOCATION:Series room\r\nGEO:49.2827;-123.1207', + 'LOCATION:New series room\r\nGEO:48.4284;-123.3656', + ); + final projected = IcalRecurrenceExpander().expand( + IcalSemanticDocument.parse(masterChanged), + rangeStartUtc: DateTime.utc(2026, 8, 9), + rangeEndUtc: DateTime.utc(2026, 8, 10), + ); + expect(projected.single.location, 'New series room'); + expect( + projected.single.locationPoint, + GeographicPoint(latitude: 48.4284, longitude: -123.3656), + ); + }); - test('this-and-future clear keeps an empty ranged LOCATION', () async { + test('new occurrence clear creates an explicit inheritance boundary', () async { final baseline = _recurringEventResource().replaceFirst( 'SUMMARY:Server series', 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', ); await _commitObjects(objectRepository, [ _prepared( - href: '${_collectionHref}range-clear-location.ics', - etag: '"range-clear"', + href: '${_collectionHref}new-clear-exception.ics', + etag: '"new-clear"', body: baseline, ), ]); @@ -921,74 +856,126 @@ void main() { await calendarRepository.updateLocalEvent( EventEditorDraft.fromEventDetail(detail).copyWith( clearLocation: true, - recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, ), ); final document = IcalSemanticDocument.parse( await _serializedPendingDavMutation(database, baseline), ); - final range = document.components.singleWhere( - (component) => component.recurrenceRange == 'THISANDFUTURE', + final exception = document.components.singleWhere( + (component) => component.recurrenceId?.rawValue == '20260810T090000Z', ); - expect(range.location, ''); - expect(range.documentComponent.firstProperty('LOCATION'), isNotNull); - expect(range.documentComponent.firstProperty('GEO'), isNull); + expect(exception.location, ''); + expect(exception.documentComponent.firstProperty('LOCATION'), isNotNull); + expect(exception.documentComponent.firstProperty('GEO'), isNull); + final projected = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 8, 10), + rangeEndUtc: DateTime.utc(2026, 8, 11), + ); + expect(projected.single.location, ''); + expect(projected.single.locationPoint, isNull); }); - test('replacing an exception writes the new venue and raw GEO point', () async { - final baseline = _recurringEventResource() - .replaceFirst( - 'SUMMARY:Server series', - 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', - ) - .replaceFirst( - 'SUMMARY:Existing exception', - 'SUMMARY:Existing exception\r\nLOCATION:Old exception\r\nGEO:48.4284;-123.3656', - ); + test('this-and-future clear keeps an empty ranged LOCATION', () async { + final baseline = _recurringEventResource().replaceFirst( + 'SUMMARY:Server series', + 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', + ); await _commitObjects(objectRepository, [ _prepared( - href: '${_collectionHref}replace-exception-location.ics', - etag: '"replace-location"', + href: '${_collectionHref}range-clear-location.ics', + etag: '"range-clear"', body: baseline, ), ]); final row = (await database.select(database.calendarEvents).get()) - .singleWhere((event) => event.recurrenceIdKey != null); + .singleWhere( + (event) => event.occurrenceKey!.contains('2026-08-10T09:00:00'), + ); final detail = (await calendarRepository.loadEventDetail(row.id))!; - final selection = LocationResult( - label: 'New exception', - point: GeographicPoint(latitude: 0, longitude: -77.0365), - ); await calendarRepository.updateLocalEvent( EventEditorDraft.fromEventDetail(detail).copyWith( - location: selection.label, - locationChange: LocationChange.replace(selection), - recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + clearLocation: true, + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, ), ); - final serialized = await _serializedPendingDavMutation(database, baseline); - final document = IcalSemanticDocument.parse(serialized); - final exception = document.components.singleWhere( - (component) => component.recurrenceIdKey != null, - ); - expect(exception.location, 'New exception'); - expect( - exception.documentComponent.firstProperty('GEO')?.rawValue, - '0.0;-77.0365', + final document = IcalSemanticDocument.parse( + await _serializedPendingDavMutation(database, baseline), ); - final projected = IcalRecurrenceExpander().expand( - document, - rangeStartUtc: DateTime.utc(2026, 8, 9), - rangeEndUtc: DateTime.utc(2026, 8, 10), + final range = document.components.singleWhere( + (component) => component.recurrenceRange == 'THISANDFUTURE', ); - expect(projected.single.location, 'New exception'); - expect(projected.single.locationPoint, selection.point); - expect(await database.select(database.locationResolutions).get(), isEmpty); + expect(range.location, ''); + expect(range.documentComponent.firstProperty('LOCATION'), isNotNull); + expect(range.documentComponent.firstProperty('GEO'), isNull); }); + test( + 'replacing an exception writes the new venue and raw GEO point', + () async { + final baseline = _recurringEventResource() + .replaceFirst( + 'SUMMARY:Server series', + 'SUMMARY:Server series\r\nLOCATION:Series room\r\nGEO:49.2827;-123.1207', + ) + .replaceFirst( + 'SUMMARY:Existing exception', + 'SUMMARY:Existing exception\r\nLOCATION:Old exception\r\nGEO:48.4284;-123.3656', + ); + await _commitObjects(objectRepository, [ + _prepared( + href: '${_collectionHref}replace-exception-location.ics', + etag: '"replace-location"', + body: baseline, + ), + ]); + final row = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.recurrenceIdKey != null); + final detail = (await calendarRepository.loadEventDetail(row.id))!; + final selection = LocationResult( + label: 'New exception', + point: GeographicPoint(latitude: 0, longitude: -77.0365), + ); + + await calendarRepository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + location: selection.label, + locationChange: LocationChange.replace(selection), + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); + + final serialized = await _serializedPendingDavMutation( + database, + baseline, + ); + final document = IcalSemanticDocument.parse(serialized); + final exception = document.components.singleWhere( + (component) => component.recurrenceIdKey != null, + ); + expect(exception.location, 'New exception'); + expect( + exception.documentComponent.firstProperty('GEO')?.rawValue, + '0.0;-77.0365', + ); + final projected = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 8, 9), + rangeEndUtc: DateTime.utc(2026, 8, 10), + ); + expect(projected.single.location, 'New exception'); + expect(projected.single.locationPoint, selection.point); + expect( + await database.select(database.locationResolutions).get(), + isEmpty, + ); + }, + ); + test('timing-only exception edit preserves inherited location data', () async { final baseline = _recurringEventResource().replaceFirst( 'SUMMARY:Server series', @@ -1054,9 +1041,8 @@ void main() { operation.mutationPatchJson!, ); final candidate = patch.applyTo(baseline, nowUtc: _now); - final range = IcalSemanticDocument.parse( - candidate, - ).components.singleWhere((component) => component.recurrenceId != null); + final range = IcalSemanticDocument.parse(candidate).components + .singleWhere((component) => component.recurrenceId != null); expect(range.recurrenceRange, 'THISANDFUTURE'); final projected = await database.select(database.calendarEvents).get(); @@ -1177,9 +1163,8 @@ void main() { final candidate = DavMutationPatch.fromJsonString( operation.mutationPatchJson!, ).applyTo(baseline, nowUtc: _now); - final exception = IcalSemanticDocument.parse( - candidate, - ).components.singleWhere((component) => component.recurrenceId != null); + final exception = IcalSemanticDocument.parse(candidate).components + .singleWhere((component) => component.recurrenceId != null); expect(exception.status, 'CANCELLED'); expect(exception.recurrenceRange, 'THISANDFUTURE'); @@ -1702,9 +1687,10 @@ void main() { expect(supplements, hasLength(1)); expect(supplements.single.itemId, sourceParent.id); expect( - IcalSemanticDocument.parse( - _createRaw(childCreate), - ).components.single.parentUid, + IcalSemanticDocument.parse(_createRaw(childCreate)) + .components + .single + .parentUid, duplicateParent.icalUid, ); }, @@ -1961,9 +1947,8 @@ Future _serializedPendingDavMutation( String baseline, ) async { final operation = await database.select(database.pendingOps).getSingle(); - return DavMutationPatch.fromJsonString( - operation.mutationPatchJson!, - ).applyTo(baseline, nowUtc: _now); + return DavMutationPatch.fromJsonString(operation.mutationPatchJson!) + .applyTo(baseline, nowUtc: _now); } DavPreparedObject _prepared({ diff --git a/test/dav/nextcloud/nextcloud_administration_test.dart b/test/dav/nextcloud/nextcloud_administration_test.dart index b6b594cc..adcb2183 100644 --- a/test/dav/nextcloud/nextcloud_administration_test.dart +++ b/test/dav/nextcloud/nextcloud_administration_test.dart @@ -419,40 +419,37 @@ void main() { expect((await fixture.sharing.load('collection')).publishUrl, isNull); }, ); - test( - 'trash queries events and tasks and restores the returned VTODO href', - () async { - final listing = await fixture.trash.list(); - expect(listing.items, hasLength(2)); - final task = listing.items.singleWhere( - (i) => i.kind == NextcloudTrashKind.task, - ); - expect(task.href.path, endsWith('/objects/42.ics')); - await fixture.trash.restore(task); - final move = fixture.requests.singleWhere((r) => r.method == 'MOVE'); - expect(move.url, task.href); - expect( - move.headers['destination'], - '${NextcloudAdminFixture.origin}${NextcloudAdminFixture.bin}restore/file', - ); - expect(move.headers['if-match'], '"exact-trash-etag"'); - expect(move.headers['overwrite'], 'F'); - expect(move.headers['x-nc-caldav-no-trashbin'], isNull); - expect((await fixture.trash.list()).items, hasLength(1)); - expect( - await fixture.database.select(fixture.database.pendingOps).get(), - isEmpty, - ); - expect( - await fixture.database.select(fixture.database.calendarEvents).get(), - isEmpty, - ); - expect( - await fixture.database.select(fixture.database.tasks).get(), - isEmpty, - ); - }, - ); + test('trash queries events and tasks and restores the returned VTODO href', () async { + final listing = await fixture.trash.list(); + expect(listing.items, hasLength(2)); + final task = listing.items.singleWhere( + (i) => i.kind == NextcloudTrashKind.task, + ); + expect(task.href.path, endsWith('/objects/42.ics')); + await fixture.trash.restore(task); + final move = fixture.requests.singleWhere((r) => r.method == 'MOVE'); + expect(move.url, task.href); + expect( + move.headers['destination'], + '${NextcloudAdminFixture.origin}${NextcloudAdminFixture.bin}restore/file', + ); + expect(move.headers['if-match'], '"exact-trash-etag"'); + expect(move.headers['overwrite'], 'F'); + expect(move.headers['x-nc-caldav-no-trashbin'], isNull); + expect((await fixture.trash.list()).items, hasLength(1)); + expect( + await fixture.database.select(fixture.database.pendingOps).get(), + isEmpty, + ); + expect( + await fixture.database.select(fixture.database.calendarEvents).get(), + isEmpty, + ); + expect( + await fixture.database.select(fixture.database.tasks).get(), + isEmpty, + ); + }); test( 'expired trash entries cannot produce a second restore request', () async { diff --git a/test/dav/nextcloud/nextcloud_dialogs_test.dart b/test/dav/nextcloud/nextcloud_dialogs_test.dart index 64fb2cf1..31bdf0d7 100644 --- a/test/dav/nextcloud/nextcloud_dialogs_test.dart +++ b/test/dav/nextcloud/nextcloud_dialogs_test.dart @@ -159,9 +159,8 @@ void main() { ProviderScope( overrides: [ localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), - nextcloudSharingServiceProvider( - 'account', - ).overrideWithValue(fixture.sharing), + nextcloudSharingServiceProvider('account') + .overrideWithValue(fixture.sharing), ], child: localizedTestApp( child: const AndroidNextcloudSharingScreen( @@ -427,9 +426,8 @@ Future _pump( ProviderScope( overrides: [ localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), - nextcloudSharingServiceProvider( - 'account', - ).overrideWithValue(fixture.sharing), + nextcloudSharingServiceProvider('account') + .overrideWithValue(fixture.sharing), ], child: windows ? fluent.FluentApp( @@ -462,9 +460,8 @@ Future _pumpTrash( ProviderScope( overrides: [ localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), - nextcloudTrashServiceProvider( - 'account', - ).overrideWithValue(fixture.trash), + nextcloudTrashServiceProvider('account') + .overrideWithValue(fixture.trash), ], child: localizedTestApp( theme: BusyMaxYaruTheme.build( diff --git a/test/dav/nextcloud/nextcloud_native_import_test.dart b/test/dav/nextcloud/nextcloud_native_import_test.dart index 3d97dd6d..508ee2ad 100644 --- a/test/dav/nextcloud/nextcloud_native_import_test.dart +++ b/test/dav/nextcloud/nextcloud_native_import_test.dart @@ -150,54 +150,49 @@ void main() { expect(fixture.requests, isEmpty); }, ); - test( - 'silent native import retains rich pending resources and duplicate identities', - () async { - final results = await import(); - expect( - results.every((r) => r.status == NativeImportItemStatus.queued), - isTrue, - ); - final db = fixture.database; - final operations = await db.select(db.pendingOps).get(); - expect(operations, hasLength(3)); - expect( - operations.every( - (op) => - (jsonDecode(op.requestJson) as Map)['suppressScheduling'] == - true && - op.davObjectId != null, - ), - isTrue, - ); - expect( - (await db.select(db.tasks).get()).map((t) => t.parentUid), - contains('parent'), - ); - final events = await db.select(db.calendarEvents).get(); - expect(events, hasLength(2)); - expect(events.every((e) => e.syncStatus == 'pending'), isTrue); - final exported = await CalendarRepository( - database: db, - ).nativeEventExport(events.last.id); - expect( - exported, - allOf( - contains('RECURRENCE-ID'), - contains('UID:meeting'), - contains('VALARM'), - contains('VTIMEZONE'), - ), - ); - expect( - (await import()).every( - (r) => r.status == NativeImportItemStatus.duplicate, - ), - isTrue, - ); - expect(fixture.requests, isEmpty); - }, - ); + test('silent native import retains rich pending resources and duplicate identities', () async { + final results = await import(); + expect( + results.every((r) => r.status == NativeImportItemStatus.queued), + isTrue, + ); + final db = fixture.database; + final operations = await db.select(db.pendingOps).get(); + expect(operations, hasLength(3)); + expect( + operations.every( + (op) => + (jsonDecode(op.requestJson) as Map)['suppressScheduling'] == true && + op.davObjectId != null, + ), + isTrue, + ); + expect( + (await db.select(db.tasks).get()).map((t) => t.parentUid), + contains('parent'), + ); + final events = await db.select(db.calendarEvents).get(); + expect(events, hasLength(2)); + expect(events.every((e) => e.syncStatus == 'pending'), isTrue); + final exported = await CalendarRepository(database: db) + .nativeEventExport(events.last.id); + expect( + exported, + allOf( + contains('RECURRENCE-ID'), + contains('UID:meeting'), + contains('VALARM'), + contains('VTIMEZONE'), + ), + ); + expect( + (await import()).every( + (r) => r.status == NativeImportItemStatus.duplicate, + ), + isTrue, + ); + expect(fixture.requests, isEmpty); + }); test('new copies rewrite task parent identities together', () async { await import(copies: true); final tasks = await fixture.database.select(fixture.database.tasks).get(); @@ -217,38 +212,33 @@ void main() { ); expect(childOp.dependsOnOpId, parentOp.id); }); - test( - 'native collection export snapshots pending raw resources without network or mutations', - () async { - await import(); - final db = fixture.database; - final before = await db.select(db.pendingOps).get(); - final snapshot = await NextcloudNativeExportService( - db, - ).collection('account', 'collection'); - expect(snapshot, hasLength(3)); - expect( - snapshot.singleWhere((r) => r.uid == 'meeting').rawIcs, - allOf( - contains('X-UNKNOWN;X-PARAM=keep:opaque'), - contains('RECURRENCE-ID'), - contains('VTIMEZONE'), - ), - ); - expect( - snapshot.singleWhere((r) => r.uid == 'child').rawIcs, - contains('RELATED-TO:parent'), - ); - expect(await db.select(db.pendingOps).get(), before); - expect(fixture.requests, isEmpty); - await expectLater( - NextcloudNativeExportService( - db, - ).collection('another-account', 'collection'), - throwsStateError, - ); - }, - ); + test('native collection export snapshots pending raw resources without network or mutations', () async { + await import(); + final db = fixture.database; + final before = await db.select(db.pendingOps).get(); + final snapshot = await NextcloudNativeExportService(db) + .collection('account', 'collection'); + expect(snapshot, hasLength(3)); + expect( + snapshot.singleWhere((r) => r.uid == 'meeting').rawIcs, + allOf( + contains('X-UNKNOWN;X-PARAM=keep:opaque'), + contains('RECURRENCE-ID'), + contains('VTIMEZONE'), + ), + ); + expect( + snapshot.singleWhere((r) => r.uid == 'child').rawIcs, + contains('RELATED-TO:parent'), + ); + expect(await db.select(db.pendingOps).get(), before); + expect(fixture.requests, isEmpty); + await expectLater( + NextcloudNativeExportService(db) + .collection('another-account', 'collection'), + throwsStateError, + ); + }); test( 'collection export uses the final update then move task candidate', () async { @@ -318,18 +308,16 @@ void main() { final tasksBefore = await db.select(db.tasks).get(); final objectsBefore = await db.select(db.davObjects).get(); - final source = await NextcloudNativeExportService( - db, - ).collection('account', 'collection'); - final destination = await NextcloudNativeExportService( - db, - ).collection('account', 'destination'); + final source = await NextcloudNativeExportService(db) + .collection('account', 'collection'); + final destination = await NextcloudNativeExportService(db) + .collection('account', 'destination'); expect(source, isEmpty); final exported = destination.single; - final component = IcalSemanticDocument.parse( - exported.rawIcs, - ).components.single; + final component = IcalSemanticDocument.parse(exported.rawIcs) + .components + .single; expect(exported.uid, 'moved-task@example.test'); expect(component.summary, 'Final offline title'); expect(component.parentUid, isNull); @@ -340,58 +328,54 @@ void main() { expect(fixture.requests, isEmpty); }, ); - test( - 'collection export retains an editor event after permanent create rejection', - () async { - final db = fixture.database; - await CalendarRepository( - database: db, - now: () => now, - localTimeZone: 'UTC', - ).createLocalEvent( - EventEditorDraft.newEvent( - accountId: 'account', - sourceId: 'dav-calendar-collection', - providerCalendarId: NextcloudAdminFixture.collection, - start: DateTime.utc(2026, 9, 6, 10), - end: DateTime.utc(2026, 9, 6, 11), - ).copyWith( - title: 'Locally retained event', - description: 'Must survive export', - ), - ); - final queued = await db.select(db.pendingOps).getSingle(); - final request = jsonDecode(queued.requestJson) as Map; - final uid = request['uid']! as String; - expect(queued.davObjectId, isNull); - expect(request.containsKey('suppressScheduling'), isFalse); + test('collection export retains an editor event after permanent create rejection', () async { + final db = fixture.database; + await CalendarRepository( + database: db, + now: () => now, + localTimeZone: 'UTC', + ).createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'dav-calendar-collection', + providerCalendarId: NextcloudAdminFixture.collection, + start: DateTime.utc(2026, 9, 6, 10), + end: DateTime.utc(2026, 9, 6, 11), + ).copyWith( + title: 'Locally retained event', + description: 'Must survive export', + ), + ); + final queued = await db.select(db.pendingOps).getSingle(); + final request = jsonDecode(queued.requestJson) as Map; + final uid = request['uid']! as String; + expect(queued.davObjectId, isNull); + expect(request.containsKey('suppressScheduling'), isFalse); - final providerRequests = await permanentlyRejectPendingMutation(); - final failedBefore = await db.select(db.pendingOps).getSingle(); - final eventBefore = await db.select(db.calendarEvents).getSingle(); - expect(failedBefore.state, 'failed'); - expect(failedBefore.lastErrorCode, 'DavMalformedResource'); - expect(providerRequests, hasLength(1)); + final providerRequests = await permanentlyRejectPendingMutation(); + final failedBefore = await db.select(db.pendingOps).getSingle(); + final eventBefore = await db.select(db.calendarEvents).getSingle(); + expect(failedBefore.state, 'failed'); + expect(failedBefore.lastErrorCode, 'DavMalformedResource'); + expect(providerRequests, hasLength(1)); - final snapshot = await NextcloudNativeExportService( - db, - ).collection('account', 'collection'); + final snapshot = await NextcloudNativeExportService(db) + .collection('account', 'collection'); - final resource = snapshot.single; - final component = IcalSemanticDocument.parse( - resource.rawIcs, - ).components.single; - expect(resource.uid, uid); - expect(resource.componentType, 'VEVENT'); - expect(component.uid, uid); - expect(component.summary, 'Locally retained event'); - expect(component.description, 'Must survive export'); - expect(await db.select(db.pendingOps).getSingle(), failedBefore); - expect(await db.select(db.calendarEvents).getSingle(), eventBefore); - expect(await db.select(db.davObjects).get(), isEmpty); - expect(providerRequests, hasLength(1)); - }, - ); + final resource = snapshot.single; + final component = IcalSemanticDocument.parse(resource.rawIcs) + .components + .single; + expect(resource.uid, uid); + expect(resource.componentType, 'VEVENT'); + expect(component.uid, uid); + expect(component.summary, 'Locally retained event'); + expect(component.description, 'Must survive export'); + expect(await db.select(db.pendingOps).getSingle(), failedBefore); + expect(await db.select(db.calendarEvents).getSingle(), eventBefore); + expect(await db.select(db.davObjects).get(), isEmpty); + expect(providerRequests, hasLength(1)); + }); test( 'collection export retains an editor task after permanent create rejection', () async { @@ -420,14 +404,13 @@ void main() { expect(failedBefore.lastErrorCode, 'DavMalformedResource'); expect(providerRequests, hasLength(1)); - final snapshot = await NextcloudNativeExportService( - db, - ).collection('account', 'collection'); + final snapshot = await NextcloudNativeExportService(db) + .collection('account', 'collection'); final resource = snapshot.single; - final component = IcalSemanticDocument.parse( - resource.rawIcs, - ).components.single; + final component = IcalSemanticDocument.parse(resource.rawIcs) + .components + .single; expect(resource.uid, uid); expect(resource.componentType, 'VTODO'); expect(component.uid, uid); @@ -489,9 +472,8 @@ void main() { final event = await db.select(db.calendarEvents).getSingle(); final detail = await repository.loadEventDetail(event.id); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail!, - ).copyWith(title: 'Retained local event edit'), + EventEditorDraft.fromEventDetail(detail!) + .copyWith(title: 'Retained local event edit'), ); final providerRequests = await permanentlyRejectPendingMutation(); @@ -512,9 +494,8 @@ void main() { collectionId: 'collection', objectId: objectBefore.id, ); - final collection = await NextcloudNativeExportService( - db, - ).collection('account', 'collection'); + final collection = await NextcloudNativeExportService(db) + .collection('account', 'collection'); expect( IcalSemanticDocument.parse(individual).components.single.summary, @@ -523,9 +504,10 @@ void main() { expect(collection, hasLength(1)); expect(collection.single.uid, 'existing-event@example.test'); expect( - IcalSemanticDocument.parse( - collection.single.rawIcs, - ).components.single.summary, + IcalSemanticDocument.parse(collection.single.rawIcs) + .components + .single + .summary, 'Retained local event edit', ); expect(await db.select(db.pendingOps).getSingle(), operationBefore); @@ -574,9 +556,8 @@ void main() { collectionId: 'collection', objectId: objectBefore.id, ); - final collection = await NextcloudNativeExportService( - db, - ).collection('account', 'collection'); + final collection = await NextcloudNativeExportService(db) + .collection('account', 'collection'); expect( IcalSemanticDocument.parse(individual).components.single.summary, @@ -585,9 +566,10 @@ void main() { expect(collection, hasLength(1)); expect(collection.single.uid, 'existing-task@example.test'); expect( - IcalSemanticDocument.parse( - collection.single.rawIcs, - ).components.single.summary, + IcalSemanticDocument.parse(collection.single.rawIcs) + .components + .single + .summary, 'Retained local task edit', ); expect(await db.select(db.pendingOps).getSingle(), operationBefore); @@ -638,51 +620,44 @@ void main() { ); }, ); - test( - 'unsent native resources can be edited and exported while replay is pending', - () async { - await import(); - final db = fixture.database; - final eventObject = (await db.select(db.davObjects).get()).singleWhere( - (o) => o.primaryUid == 'meeting', - ); - final queue = DavPendingOperationQueue(database: db, nowUtc: () => now); - await queue.enqueueUpdate( - accountId: 'account', - collectionId: 'collection', - objectId: eventObject.id, - patch: DavMutationPatch( - target: const IcalComponentKey( - componentType: 'VEVENT', - uid: 'meeting', - ), - scope: DavMutationScope.recurrenceMaster, - operations: [ - DavPatchOperation.setText('SUMMARY', 'Edited before upload'), - ], - ), - ); - await (db.update(db.pendingOps) - ..where((r) => r.davObjectId.equals(eventObject.id))) - .write(const PendingOpsCompanion(state: Value('in_progress'))); - final raw = await queue.exportRawIcsForObject( - accountId: 'account', - collectionId: 'collection', - objectId: eventObject.id, - ); - expect(raw, contains('SUMMARY:Edited before upload')); - expect( - (jsonDecode( - (await (db.select(db.pendingOps) - ..where((r) => r.davObjectId.equals(eventObject.id))) - .getSingle()) - .requestJson, - ) - as Map)['suppressScheduling'], - isTrue, - ); - }, - ); + test('unsent native resources can be edited and exported while replay is pending', () async { + await import(); + final db = fixture.database; + final eventObject = (await db.select(db.davObjects).get()).singleWhere( + (o) => o.primaryUid == 'meeting', + ); + final queue = DavPendingOperationQueue(database: db, nowUtc: () => now); + await queue.enqueueUpdate( + accountId: 'account', + collectionId: 'collection', + objectId: eventObject.id, + patch: DavMutationPatch( + target: const IcalComponentKey(componentType: 'VEVENT', uid: 'meeting'), + scope: DavMutationScope.recurrenceMaster, + operations: [ + DavPatchOperation.setText('SUMMARY', 'Edited before upload'), + ], + ), + ); + await (db.update(db.pendingOps) + ..where((r) => r.davObjectId.equals(eventObject.id))) + .write(const PendingOpsCompanion(state: Value('in_progress'))); + final raw = await queue.exportRawIcsForObject( + accountId: 'account', + collectionId: 'collection', + objectId: eventObject.id, + ); + expect(raw, contains('SUMMARY:Edited before upload')); + expect( + (jsonDecode( + (await (db.select( + db.pendingOps, + )..where((r) => r.davObjectId.equals(eventObject.id))).getSingle()) + .requestJson, + ) as Map)['suppressScheduling'], + isTrue, + ); + }); test( 'a normal inventory cannot erase unuploaded imported resources', () async { @@ -715,89 +690,85 @@ void main() { ); }, ); - test( - 'a reconstructed replayer sends the per-import header and exact create condition', - () async { - await import(); - final stored = {}; - final puts = []; - // Use the production HTTP mutation client with an injected fake transport. - final client = MockClient((request) async { - if (request.method == 'PUT') { - puts.add(request); - stored[request.url.path] = request.body; - return http.Response('', 201, headers: {'etag': '"server"'}); - } - if (request.method == 'GET') { - final body = stored[request.url.path]; - return http.Response( - body ?? '', - body == null ? 404 : 200, - headers: {'etag': '"server"'}, - ); - } - throw StateError('Unexpected test request'); - }); - addTearDown(client.close); - final context = await fixture.collections.openContext(); - // Reuse authenticated transport construction, without owning the sync client. - final remote = DavMutationHttpClient( - transport: DavHttpTransport( - client: client, - profile: context.profile, - accountAuthority: context.authority, - ), - accountId: 'account', - collectionId: 'collection', - credential: context.credential, - ); - final result = await DavPendingOperationsReplayer( - database: fixture.database, - accountId: 'account', - objectRepository: DavObjectRepository(database: fixture.database), - serviceFactory: ({required account, required collection}) async => - DavConditionalMutationService(remoteClient: remote), - nowUtc: () => now, - ).replayDueOperations(); - expect( - result.appliedCount, - 3, - reason: - (await fixture.database.select(fixture.database.pendingOps).get()) - .map((op) => '${op.state}: ${op.lastErrorCode}') - .join(', '), - ); - expect(puts, hasLength(3)); - expect( - puts.every( - (r) => - r.headers['x-nc-scheduling'] == 'false' && - r.headers['if-none-match'] == '*', - ), - isTrue, - ); - expect( - await fixture.database.select(fixture.database.pendingOps).get(), - isEmpty, - ); - final confirmed = await fixture.database - .select(fixture.database.davObjects) - .get(); - expect(confirmed, hasLength(3)); - expect(confirmed.every((o) => o.etag == '"server"'), isTrue); - await remote.conditionalPut( - uri: Uri.parse(confirmed.first.requestUri), - rawIcs: confirmed.first.rawIcsBody, - correlationId: 'ordinary-edit', - ifMatch: '"server"', - ); - expect( - puts.last.headers['x-nc-scheduling'], - isNull, - reason: 'Import suppression must never leak to ordinary edits.', - ); - }, - ); + test('a reconstructed replayer sends the per-import header and exact create condition', () async { + await import(); + final stored = {}; + final puts = []; + // Use the production HTTP mutation client with an injected fake transport. + final client = MockClient((request) async { + if (request.method == 'PUT') { + puts.add(request); + stored[request.url.path] = request.body; + return http.Response('', 201, headers: {'etag': '"server"'}); + } + if (request.method == 'GET') { + final body = stored[request.url.path]; + return http.Response( + body ?? '', + body == null ? 404 : 200, + headers: {'etag': '"server"'}, + ); + } + throw StateError('Unexpected test request'); + }); + addTearDown(client.close); + final context = await fixture.collections.openContext(); + // Reuse authenticated transport construction, without owning the sync client. + final remote = DavMutationHttpClient( + transport: DavHttpTransport( + client: client, + profile: context.profile, + accountAuthority: context.authority, + ), + accountId: 'account', + collectionId: 'collection', + credential: context.credential, + ); + final result = await DavPendingOperationsReplayer( + database: fixture.database, + accountId: 'account', + objectRepository: DavObjectRepository(database: fixture.database), + serviceFactory: ({required account, required collection}) async => + DavConditionalMutationService(remoteClient: remote), + nowUtc: () => now, + ).replayDueOperations(); + expect( + result.appliedCount, + 3, + reason: (await fixture.database.select(fixture.database.pendingOps).get()) + .map((op) => '${op.state}: ${op.lastErrorCode}') + .join(', '), + ); + expect(puts, hasLength(3)); + expect( + puts.every( + (r) => + r.headers['x-nc-scheduling'] == 'false' && + r.headers['if-none-match'] == '*', + ), + isTrue, + ); + expect( + await fixture.database.select(fixture.database.pendingOps).get(), + isEmpty, + ); + final confirmed = await fixture.database + .select(fixture.database.davObjects) + .get(); + expect(confirmed, hasLength(3)); + expect(confirmed.every((o) => o.etag == '"server"'), isTrue); + await remote.conditionalPut( + uri: Uri.parse(confirmed.first.requestUri), + rawIcs: confirmed.first.rawIcsBody, + correlationId: 'ordinary-edit', + ifMatch: '"server"', + ); + expect( + puts.last.headers['x-nc-scheduling'], + isNull, + reason: 'Import suppression must never leak to ordinary edits.', + ); + }); } Future _seedConfirmedResource( diff --git a/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart b/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart index 52907176..f2788d0d 100644 --- a/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart +++ b/test/dav/nextcloud/nextcloud_scheduling_dialogs_test.dart @@ -14,6 +14,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaru/yaru.dart'; + import '../../support/memory_settings_store.dart'; import '../../test_localized_app.dart'; import 'nextcloud_scheduling_fixture.dart'; @@ -271,9 +272,8 @@ Future _pump( ProviderScope( overrides: [ localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), - nextcloudSchedulingServiceProvider( - 'account', - ).overrideWithValue(NextcloudSchedulingService(fixture.collections)), + nextcloudSchedulingServiceProvider('account') + .overrideWithValue(NextcloudSchedulingService(fixture.collections)), ], child: windows ? fluent.FluentApp( diff --git a/test/dav/nextcloud/nextcloud_scheduling_fixture.dart b/test/dav/nextcloud/nextcloud_scheduling_fixture.dart index b41520da..9da05082 100644 --- a/test/dav/nextcloud/nextcloud_scheduling_fixture.dart +++ b/test/dav/nextcloud/nextcloud_scheduling_fixture.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:convert'; + import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:drift/drift.dart'; import 'package:http/http.dart' as http; import 'package:xml/xml.dart'; + import 'nextcloud_admin_fixture.dart'; class SchedulingFixture extends NextcloudAdminFixture { diff --git a/test/dav/nextcloud/nextcloud_scheduling_policy_test.dart b/test/dav/nextcloud/nextcloud_scheduling_policy_test.dart index 3c9384ec..90f621c3 100644 --- a/test/dav/nextcloud/nextcloud_scheduling_policy_test.dart +++ b/test/dav/nextcloud/nextcloud_scheduling_policy_test.dart @@ -26,9 +26,9 @@ void main() { () { final edited = _raw.replaceFirst('SUMMARY:Meeting', 'SUMMARY:Changed'); expect( - () => policy( - 'mailto:owner@example.test', - ).validateChange(baseline: _raw, candidate: edited), + () => + policy('mailto:owner@example.test') + .validateChange(baseline: _raw, candidate: edited), throwsA(isA()), ); expect( @@ -98,9 +98,10 @@ void main() { test( 'attendee role changes preserve server parameters and participation', () { - final baseline = IcalSemanticDocument.parse( - _raw, - ).components.single.documentComponent; + final baseline = IcalSemanticDocument.parse(_raw) + .components + .single + .documentComponent; final values = nextcloudAttendeeValues([ const EventAttendeeDraft( email: 'guest@example.test', @@ -124,22 +125,19 @@ void main() { ); }, ); - test( - 'calendar addresses and native participant labels are decoded, not login names', - () { - expect( - normalizeCalendarAddress('mailto:GUEST%40example.test'), - 'mailto:guest@example.test', - ); - final semantic = IcalSemanticDocument.parse(_raw); - final draft = EventAttendeeDraft.fromJson( - semantic.components.single.attendees.single, - ); - expect(draft.email, 'guest@example.test'); - expect(draft.displayName, 'Guest'); - expect(draft.responseStatus, 'NEEDS-ACTION'); - }, - ); + test('calendar addresses and native participant labels are decoded, not login names', () { + expect( + normalizeCalendarAddress('mailto:GUEST%40example.test'), + 'mailto:guest@example.test', + ); + final semantic = IcalSemanticDocument.parse(_raw); + final draft = EventAttendeeDraft.fromJson( + semantic.components.single.attendees.single, + ); + expect(draft.email, 'guest@example.test'); + expect(draft.displayName, 'Guest'); + expect(draft.responseStatus, 'NEEDS-ACTION'); + }); } const _raw = '''BEGIN:VCALENDAR diff --git a/test/dav/nextcloud/nextcloud_scheduling_service_test.dart b/test/dav/nextcloud/nextcloud_scheduling_service_test.dart index a6e3384b..0616fc85 100644 --- a/test/dav/nextcloud/nextcloud_scheduling_service_test.dart +++ b/test/dav/nextcloud/nextcloud_scheduling_service_test.dart @@ -2,6 +2,7 @@ import 'package:busymax/src/dav/nextcloud/nextcloud_scheduling_service.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:drift/drift.dart' hide isNull; import 'package:flutter_test/flutter_test.dart'; + import 'nextcloud_scheduling_fixture.dart'; import 'nextcloud_admin_fixture.dart'; @@ -21,31 +22,28 @@ void main() { recipients: ['guest@example.test', 'unknown@example.test'], ); - test( - 'availability uses the discovered outbox and reports missing recipients as unknown', - () async { - final results = await query(); - expect(results.first.availability, NextcloudAvailability.known); - expect( - results.first.intervals.single.startUtc, - DateTime.utc(2026, 9, 6, 10), - ); - expect(results.last.availability, NextcloudAvailability.unknown); - final post = fixture.requests.singleWhere((r) => r.method == 'POST'); - expect(post.url.path, '${NextcloudAdminFixture.home}outbox/'); - expect(post.body, contains('BEGIN:VFREEBUSY')); - expect(post.body, contains('ORGANIZER:mailto:alex@example.test')); - expect(post.body, isNot(contains('BEGIN:VEVENT'))); - expect( - await fixture.database.select(fixture.database.pendingOps).get(), - isEmpty, - ); - expect( - await fixture.database.select(fixture.database.calendarEvents).get(), - isEmpty, - ); - }, - ); + test('availability uses the discovered outbox and reports missing recipients as unknown', () async { + final results = await query(); + expect(results.first.availability, NextcloudAvailability.known); + expect( + results.first.intervals.single.startUtc, + DateTime.utc(2026, 9, 6, 10), + ); + expect(results.last.availability, NextcloudAvailability.unknown); + final post = fixture.requests.singleWhere((r) => r.method == 'POST'); + expect(post.url.path, '${NextcloudAdminFixture.home}outbox/'); + expect(post.body, contains('BEGIN:VFREEBUSY')); + expect(post.body, contains('ORGANIZER:mailto:alex@example.test')); + expect(post.body, isNot(contains('BEGIN:VEVENT'))); + expect( + await fixture.database.select(fixture.database.pendingOps).get(), + isEmpty, + ); + expect( + await fixture.database.select(fixture.database.calendarEvents).get(), + isEmpty, + ); + }); test( 'denied, duplicate and malformed recipient results are not free', () async { @@ -67,66 +65,61 @@ void main() { expect(fixture.requests, isEmpty); }, ); - test( - 'inbox acknowledgement deletes only the returned inbox href with its exact ETag', - () async { - await fixture.database - .into(fixture.database.calendarSources) - .insert( - CalendarSourcesCompanion.insert( - id: 'source', - accountId: 'account', - provider: 'nextcloud', - providerCalendarId: NextcloudAdminFixture.collection, - davCollectionId: const Value('collection'), - summary: 'Work', - createdAtLocal: 1, - updatedAtLocal: 1, - ), - ); + test('inbox acknowledgement deletes only the returned inbox href with its exact ETag', () async { + await fixture.database + .into(fixture.database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'source', + accountId: 'account', + provider: 'nextcloud', + providerCalendarId: NextcloudAdminFixture.collection, + davCollectionId: const Value('collection'), + summary: 'Work', + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await fixture.database + .into(fixture.database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: 'event', + accountId: 'account', + provider: 'nextcloud', + providerCalendarId: NextcloudAdminFixture.collection, + calendarSourceId: 'source', + providerEventId: 'same-uid', + icalUid: const Value('same-uid'), + title: 'Stored event', + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + final before = await fixture.database + .select(fixture.database.calendarEvents) + .getSingle(); + final messages = await service.inbox('collection'); + expect(messages.single.method, 'REQUEST'); + await service.acknowledge('collection', messages.single); + final deletion = fixture.requests.singleWhere((r) => r.method == 'DELETE'); + expect( + deletion.url.path, + '${NextcloudAdminFixture.home}inbox/server-message.ics', + ); + expect(deletion.headers['if-match'], '"message-1"'); + expect( await fixture.database - .into(fixture.database.calendarEvents) - .insert( - CalendarEventsCompanion.insert( - id: 'event', - accountId: 'account', - provider: 'nextcloud', - providerCalendarId: NextcloudAdminFixture.collection, - calendarSourceId: 'source', - providerEventId: 'same-uid', - icalUid: const Value('same-uid'), - title: 'Stored event', - createdAtLocal: 1, - updatedAtLocal: 1, - ), - ); - final before = await fixture.database .select(fixture.database.calendarEvents) - .getSingle(); - final messages = await service.inbox('collection'); - expect(messages.single.method, 'REQUEST'); - await service.acknowledge('collection', messages.single); - final deletion = fixture.requests.singleWhere( - (r) => r.method == 'DELETE', - ); - expect( - deletion.url.path, - '${NextcloudAdminFixture.home}inbox/server-message.ics', - ); - expect(deletion.headers['if-match'], '"message-1"'); - expect( - await fixture.database - .select(fixture.database.calendarEvents) - .getSingle(), - before, - ); - expect( - await fixture.database.select(fixture.database.pendingOps).get(), - isEmpty, - ); - expect(await service.inbox('collection'), isEmpty); - }, - ); + .getSingle(), + before, + ); + expect( + await fixture.database.select(fixture.database.pendingOps).get(), + isEmpty, + ); + expect(await service.inbox('collection'), isEmpty); + }); test( 'changed inbox message is not acknowledged after confirmation', () async { diff --git a/test/dav/nextcloud_live_integration_test.dart b/test/dav/nextcloud_live_integration_test.dart index 10f53302..c2204e93 100644 --- a/test/dav/nextcloud_live_integration_test.dart +++ b/test/dav/nextcloud_live_integration_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'support/nextcloud_live_versions.dart'; import 'package:busymax/src/dav/dav_errors.dart'; @@ -338,9 +339,11 @@ final class _LiveNextcloudFixture { expect(variantResult.outcome, DavMutationOutcome.succeeded); final canonical = variantResult.canonicalObject!; expect( - IcalSemanticDocument.parse( - canonical.rawIcsBody!, - ).components.single.start!.kind, + IcalSemanticDocument.parse(canonical.rawIcsBody!) + .components + .single + .start! + .kind, variant.$2, ); variants.add(canonical); @@ -599,9 +602,9 @@ final class _LiveNextcloudFixture { ); expect(result.outcome, DavMutationOutcome.succeeded); child = result.canonicalObject!; - final semantic = IcalSemanticDocument.parse( - child.rawIcsBody!, - ).components.single; + final semantic = IcalSemanticDocument.parse(child.rawIcsBody!) + .components + .single; expect(semantic.percentComplete, percent); if (percent == 100) { expect(semantic.status, 'COMPLETED'); @@ -736,9 +739,8 @@ final class _LiveNextcloudFixture { updatedAtUtc: seededAt, ), ); - await DavDiscoveryRepository( - database: database, - ).commitSuccessfulInventory(discovery); + await DavDiscoveryRepository(database: database) + .commitSuccessfulInventory(discovery); final storedCollection = await (database.select( database.davCollections, )..where((row) => row.hrefKey.equals(collection.hrefKey))).getSingle(); diff --git a/test/dav/nextcloud_login_flow_live_test.dart b/test/dav/nextcloud_login_flow_live_test.dart index bea5f74b..9a7094a8 100644 --- a/test/dav/nextcloud_login_flow_live_test.dart +++ b/test/dav/nextcloud_login_flow_live_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; + import 'support/nextcloud_live_versions.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; diff --git a/test/dav/nextcloud_sharing_live_test.dart b/test/dav/nextcloud_sharing_live_test.dart index 2e7d628a..fc5b47d6 100644 --- a/test/dav/nextcloud_sharing_live_test.dart +++ b/test/dav/nextcloud_sharing_live_test.dart @@ -1,5 +1,7 @@ import 'dart:io'; + import 'support/nextcloud_live_versions.dart'; + import 'package:busymax/src/dav/xml/dav_xml.dart'; import 'package:busymax/src/dav/dav_errors.dart'; @@ -307,8 +309,7 @@ final class _SharingUser { accountId: 'sharing-$username', correlationId: 'sharing-reader-metadata', retryClass: DavRetryClass.never, - body: - '#123456FF', + body: '#123456FF', ), credential: credential, ); diff --git a/test/dav/storage/dav_object_repository_test.dart b/test/dav/storage/dav_object_repository_test.dart index 562381a8..a12a29fb 100644 --- a/test/dav/storage/dav_object_repository_test.dart +++ b/test/dav/storage/dav_object_repository_test.dart @@ -79,61 +79,55 @@ void main() { }); } - test( - 'remote cancellation removes unchanged alarm, displayed toast, and summary eligibility', - () async { - await _importNotificationTask(repository, due: 'DUE;VALUE=DATE:20260809'); - var now = DateTime.utc(2026, 8, 9, 7); - final schedules = NotificationScheduleService( - database: database, - nowUtc: () => now, - ); - await schedules.rebuildUpcomingTaskNotifications('account'); - final original = await database.select(database.tasks).getSingle(); - final backend = RecordingNotificationBackend(); - final notifications = DesktopNotificationService( - backend: backend, - settings: AppSettings.defaults().copyWith(notifyDueToday: true), - ); - final scheduler = NotificationScheduler( - database: database, - notifications: notifications, - nowUtc: () => now, - ); - addTearDown(scheduler.stop); - now = DateTime.utc(2026, 8, 9, 8); - await scheduler.checkNow(); - expect(backend.requests, hasLength(1)); - await _importNotificationTask( - repository, - due: 'DUE;VALUE=DATE:20260809', - status: 'CANCELLED', - ); - final cancelled = await database.select(database.tasks).getSingle(); - expect(cancelled.status, 'cancelled'); - expect(cancelled.taskAlarmsJson, original.taskAlarmsJson); - await schedules.rebuildUpcomingTaskNotifications('account'); - expect( - await database.select(database.notificationSchedule).get(), - isEmpty, - ); - await scheduler.checkNow(); - expect(backend.cancelledIds, contains(backend.requests.single.stableId)); - final marked = []; - final summary = DueTodayNotificationScheduler( - database: database, - settings: () => AppSettings.defaults().copyWith(notifyDueToday: true), - activeAccountId: () => 'account', - notifications: () => notifications, - markNotified: (date) async => marked.add(date), - now: () => DateTime(2026, 8, 9, 9), - ); - addTearDown(summary.stop); - await summary.checkNow(); - expect(marked, isEmpty); - expect(backend.requests, hasLength(1)); - }, - ); + test('remote cancellation removes unchanged alarm, displayed toast, and summary eligibility', () async { + await _importNotificationTask(repository, due: 'DUE;VALUE=DATE:20260809'); + var now = DateTime.utc(2026, 8, 9, 7); + final schedules = NotificationScheduleService( + database: database, + nowUtc: () => now, + ); + await schedules.rebuildUpcomingTaskNotifications('account'); + final original = await database.select(database.tasks).getSingle(); + final backend = RecordingNotificationBackend(); + final notifications = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + ); + final scheduler = NotificationScheduler( + database: database, + notifications: notifications, + nowUtc: () => now, + ); + addTearDown(scheduler.stop); + now = DateTime.utc(2026, 8, 9, 8); + await scheduler.checkNow(); + expect(backend.requests, hasLength(1)); + await _importNotificationTask( + repository, + due: 'DUE;VALUE=DATE:20260809', + status: 'CANCELLED', + ); + final cancelled = await database.select(database.tasks).getSingle(); + expect(cancelled.status, 'cancelled'); + expect(cancelled.taskAlarmsJson, original.taskAlarmsJson); + await schedules.rebuildUpcomingTaskNotifications('account'); + expect(await database.select(database.notificationSchedule).get(), isEmpty); + await scheduler.checkNow(); + expect(backend.cancelledIds, contains(backend.requests.single.stableId)); + final marked = []; + final summary = DueTodayNotificationScheduler( + database: database, + settings: () => AppSettings.defaults().copyWith(notifyDueToday: true), + activeAccountId: () => 'account', + notifications: () => notifications, + markNotified: (date) async => marked.add(date), + now: () => DateTime(2026, 8, 9, 9), + ); + addTearDown(summary.stop); + await summary.checkNow(); + expect(marked, isEmpty); + expect(backend.requests, hasLength(1)); + }); test( 'stores exact raw event and atomically builds recurrence projections', @@ -188,11 +182,9 @@ void main() { ), isTrue, ); - final movedProjection = - jsonDecode( - events.singleWhere((event) => event.title == 'Moved').rawJson!, - ) - as Map; + final movedProjection = jsonDecode( + events.singleWhere((event) => event.title == 'Moved').rawJson!, + ) as Map; expect(movedProjection['startUtc'], '2026-08-10T18:00:00.000Z'); final cursor = await database.select(database.syncCursors).getSingle(); expect(cursor.cursorValue, 'https://cloud.example.test/sync/1'); @@ -670,107 +662,96 @@ void main() { }, ); - test( - 'previous projection version rebuilds a cached overlap from pending raw data', - () async { - final baseline = _embeddedAutumnDurationEvent; - final prepared = DavPreparedObject.parse( - hrefKey: _eventHref, - requestUri: Uri.parse('https://cloud.example.test$_eventHref'), - etag: '"overlap"', - contentType: 'text/calendar', - rawIcsBody: baseline, - ); - await repository.commit( - _commit( - objects: [prepared], - membership: {_eventHref}, - cursor: 'token-1', - ), - ); - final object = await database.select(database.davObjects).getSingle(); - final patch = DavMutationPatch( - target: const IcalComponentKey( - componentType: 'VEVENT', - uid: 'cached-overlap@example.test', - ), - scope: DavMutationScope.object, - operations: [DavPatchOperation.setText('SUMMARY', 'Edited overlap')], - ); - final queue = DavPendingOperationQueue( - database: database, - idFactory: () => 'pending-overlap-edit', - nowUtc: () => _now, - ); - await queue.enqueueUpdate( - accountId: 'account', - collectionId: 'collection', - objectId: object.id, - patch: patch, - ); - await repository.projectLocalMutationCandidate( - accountId: 'account', - collectionId: 'collection', - provider: BusyProvider.nextcloud, - objectId: object.id, - candidateRawIcs: patch.applyTo(baseline, nowUtc: _now), - projectedAtUtc: _now, - ); - final operationBefore = await database - .select(database.pendingOps) - .getSingle(); - final cachedEvent = await database - .select(database.calendarEvents) - .getSingle(); - final staleRaw = Map.from( - jsonDecode(cachedEvent.rawJson!) as Map, - )..['endUtc'] = '2026-11-01T08:30:00.000Z'; - await (database.update( - database.calendarEvents, - )..where((row) => row.id.equals(cachedEvent.id))).write( - CalendarEventsCompanion( - projectionVersion: Value(davProjectionVersion - 1), - rawJson: Value(jsonEncode(staleRaw)), - ), - ); - final cursor = await database.select(database.syncCursors).getSingle(); - final staleState = Map.from( - jsonDecode(cursor.stateJson!) as Map, - )..['projectionVersion'] = davProjectionVersion - 1; - await (database.update( - database.syncCursors, - )..where((row) => row.id.equals(cursor.id))).write( - SyncCursorsCompanion(stateJson: Value(jsonEncode(staleState))), - ); - - await DavProjectionCoverageService( - database: database, - objectRepository: repository, - nowUtc: () => _now, - ).ensureProjectionCoverage( - rangeStartUtc: DateTime.utc(2026, 11, 1), - rangeEndUtc: DateTime.utc(2026, 11, 2), - ); + test('previous projection version rebuilds a cached overlap from pending raw data', () async { + final baseline = _embeddedAutumnDurationEvent; + final prepared = DavPreparedObject.parse( + hrefKey: _eventHref, + requestUri: Uri.parse('https://cloud.example.test$_eventHref'), + etag: '"overlap"', + contentType: 'text/calendar', + rawIcsBody: baseline, + ); + await repository.commit( + _commit(objects: [prepared], membership: {_eventHref}, cursor: 'token-1'), + ); + final object = await database.select(database.davObjects).getSingle(); + final patch = DavMutationPatch( + target: const IcalComponentKey( + componentType: 'VEVENT', + uid: 'cached-overlap@example.test', + ), + scope: DavMutationScope.object, + operations: [DavPatchOperation.setText('SUMMARY', 'Edited overlap')], + ); + final queue = DavPendingOperationQueue( + database: database, + idFactory: () => 'pending-overlap-edit', + nowUtc: () => _now, + ); + await queue.enqueueUpdate( + accountId: 'account', + collectionId: 'collection', + objectId: object.id, + patch: patch, + ); + await repository.projectLocalMutationCandidate( + accountId: 'account', + collectionId: 'collection', + provider: BusyProvider.nextcloud, + objectId: object.id, + candidateRawIcs: patch.applyTo(baseline, nowUtc: _now), + projectedAtUtc: _now, + ); + final operationBefore = await database + .select(database.pendingOps) + .getSingle(); + final cachedEvent = await database + .select(database.calendarEvents) + .getSingle(); + final staleRaw = Map.from( + jsonDecode(cachedEvent.rawJson!) as Map, + )..['endUtc'] = '2026-11-01T08:30:00.000Z'; + await (database.update( + database.calendarEvents, + )..where((row) => row.id.equals(cachedEvent.id))).write( + CalendarEventsCompanion( + projectionVersion: Value(davProjectionVersion - 1), + rawJson: Value(jsonEncode(staleRaw)), + ), + ); + final cursor = await database.select(database.syncCursors).getSingle(); + final staleState = Map.from( + jsonDecode(cursor.stateJson!) as Map, + )..['projectionVersion'] = davProjectionVersion - 1; + await (database.update(database.syncCursors) + ..where((row) => row.id.equals(cursor.id))) + .write(SyncCursorsCompanion(stateJson: Value(jsonEncode(staleState)))); + + await DavProjectionCoverageService( + database: database, + objectRepository: repository, + nowUtc: () => _now, + ).ensureProjectionCoverage( + rangeStartUtc: DateTime.utc(2026, 11, 1), + rangeEndUtc: DateTime.utc(2026, 11, 2), + ); - final corrected = await database - .select(database.calendarEvents) - .getSingle(); - final correctedRaw = jsonDecode(corrected.rawJson!) as Map; - expect(correctedRaw['startUtc'], '2026-11-01T08:30:00.000Z'); - expect(correctedRaw['endUtc'], '2026-11-01T09:30:00.000Z'); - expect(corrected.projectionVersion, davProjectionVersion); - expect(corrected.syncStatus, 'pending'); - expect( - await database.select(database.pendingOps).getSingle(), - operationBefore, - ); - final objectAfter = await database - .select(database.davObjects) - .getSingle(); - expect(objectAfter.rawIcsBody, baseline); - expect(objectAfter.etag, '"overlap"'); - }, - ); + final corrected = await database + .select(database.calendarEvents) + .getSingle(); + final correctedRaw = jsonDecode(corrected.rawJson!) as Map; + expect(correctedRaw['startUtc'], '2026-11-01T08:30:00.000Z'); + expect(correctedRaw['endUtc'], '2026-11-01T09:30:00.000Z'); + expect(corrected.projectionVersion, davProjectionVersion); + expect(corrected.syncStatus, 'pending'); + expect( + await database.select(database.pendingOps).getSingle(), + operationBefore, + ); + final objectAfter = await database.select(database.davObjects).getSingle(); + expect(objectAfter.rawIcsBody, baseline); + expect(objectAfter.etag, '"overlap"'); + }); test( 'failed reprojection does not mark an uncovered range as covered', diff --git a/test/dav/storage/dav_task_capabilities_watch_test.dart b/test/dav/storage/dav_task_capabilities_watch_test.dart index fa42abc6..19bf27e2 100644 --- a/test/dav/storage/dav_task_capabilities_watch_test.dart +++ b/test/dav/storage/dav_task_capabilities_watch_test.dart @@ -48,212 +48,195 @@ void main() { database.davCollections, )..where((row) => row.id.equals(collection.id))).write(changes); - test( - 'watched lookup and provider follow mapping removal, rebinding, and task-list deletion', - () async { - await database - .into(database.davCollections) - .insert( - collection - .toCompanion(false) - .copyWith( - id: const Value('read-only'), - hrefKey: const Value('/other-tasks/'), - currentUserPrivilegesJson: const Value('["{DAV:}read"]'), - ), - ); - await inventory.seedAccount(id: 'nextcloud:other'); - await inventory.commit(writable: true, account: 'nextcloud:other'); - final other = await (database.select( - database.davCollections, - )..where((row) => row.accountId.equals('nextcloud:other'))).getSingle(); - final entities = StreamIterator( - repository.watchCollectionByTaskListId(list.accountId, list.id), - ); - addTearDown(entities.cancel); - final values = probe(); - Future check(String? id, bool? canCreate) async { - expect( - await entities.moveNext().timeout(const Duration(seconds: 3)), - isTrue, + test('watched lookup and provider follow mapping removal, rebinding, and task-list deletion', () async { + await database + .into(database.davCollections) + .insert( + collection + .toCompanion(false) + .copyWith( + id: const Value('read-only'), + hrefKey: const Value('/other-tasks/'), + currentUserPrivilegesJson: const Value('["{DAV:}read"]'), + ), ); - expect(entities.current?.id, id); - expect((await values.next())?.canCreateTasks, canCreate); - expect( - (await repository.collectionByTaskListId( - list.accountId, - list.id, - ))?.id, - id, - ); - expect( - (await container.read( - davTaskCollectionCapabilitiesProvider(( - accountId: list.accountId, - taskListId: list.id, - )).future, - ))?.canCreateTasks, - canCreate, - ); - } - - Future bind(String? id) async { - await (database.update(database.taskLists)..where( - (row) => - row.accountId.equals(list.accountId) & row.id.equals(list.id), - )) - .write(TaskListsCompanion(davCollectionId: Value(id))); - } - - await check(collection.id, true); - await bind(null); - await check(null, null); - await bind('read-only'); - await check('read-only', false); - await bind(other.id); - await check( - null, - null, - ); // A valid foreign key must not leak another account's collection. - await bind(collection.id); - await check(collection.id, true); - await (database.delete(database.taskLists)..where( - (row) => - row.accountId.equals(list.accountId) & row.id.equals(list.id), - )) - .go(); - await check(null, null); - await database.into(database.taskLists).insert(list.toCompanion(false)); - await check(collection.id, true); + await inventory.seedAccount(id: 'nextcloud:other'); + await inventory.commit(writable: true, account: 'nextcloud:other'); + final other = await (database.select( + database.davCollections, + )..where((row) => row.accountId.equals('nextcloud:other'))).getSingle(); + final entities = StreamIterator( + repository.watchCollectionByTaskListId(list.accountId, list.id), + ); + addTearDown(entities.cancel); + final values = probe(); + Future check(String? id, bool? canCreate) async { expect( - await repository - .watchCollectionByTaskListId('nextcloud:other', list.id) - .first, - isNull, + await entities.moveNext().timeout(const Duration(seconds: 3)), + isTrue, ); + expect(entities.current?.id, id); + expect((await values.next())?.canCreateTasks, canCreate); expect( - await repository - .watchCollectionByTaskListId(list.accountId, 'missing') - .first, - isNull, - ); - }, - ); - - test( - 'collection deletion and restoration update the existing provider subscription', - () async { - final values = probe(); - expect((await values.next())!.canCreateTasks, isTrue); - await updateCollection( - const DavCollectionsCompanion(deleted: Value(true)), + (await repository.collectionByTaskListId(list.accountId, list.id))?.id, + id, ); - expect(await values.next(), isNull); - await updateCollection( - const DavCollectionsCompanion(deleted: Value(false)), - ); - expect((await values.next())!.canCreateTasks, isTrue); - await inventory.commit(writable: true, missing: true); - expect(await values.next(), isNull); - await inventory.commit(writable: false); - expect((await values.next())!.canCreateTasks, isFalse); expect( - (await database.select(database.davCollections).getSingle()).id, - collection.id, + (await container.read( + davTaskCollectionCapabilitiesProvider(( + accountId: list.accountId, + taskListId: list.id, + )).future, + ))?.canCreateTasks, + canCreate, ); + } - await (database.delete( - database.davCollections, - )..where((row) => row.id.equals(collection.id))).go(); - expect(await values.next(), isNull); - expect( - (await database.select(database.taskLists).getSingle()).davCollectionId, - isNull, - ); - await database - .into(database.davCollections) - .insert(collection.toCompanion(false)); - expect( - await values.next(), - isNull, - ); // Physical restoration does not invent a missing mapping. + Future bind(String? id) async { await (database.update(database.taskLists)..where( (row) => row.accountId.equals(list.accountId) & row.id.equals(list.id), )) - .write(TaskListsCompanion(davCollectionId: Value(collection.id))); - expect((await values.next())!.canCreateTasks, isTrue); - }, - ); + .write(TaskListsCompanion(davCollectionId: Value(id))); + } - test( - 'provider observes component, projection, resource-type and member-privilege changes', - () async { - final values = probe(); - expect((await values.next())!.canCreateTasks, isTrue); - for (final changes in [ - const DavCollectionsCompanion( - supportedComponentMask: Value(davComponentEvent), - ), - const DavCollectionsCompanion(taskProjectionEnabled: Value(false)), - DavCollectionsCompanion( - resourceTypesJson: Value( - jsonEncode(['{http://calendarserver.org/ns/}subscribed']), - ), - ), - const DavCollectionsCompanion( - currentUserPrivilegesJson: Value( - '["{DAV:}read","{DAV:}write-content"]', - ), - ), - ]) { - await updateCollection(changes); - expect((await values.next())!.canCreateTasks, isFalse); - await database - .into(database.davCollections) - .insertOnConflictUpdate(collection.toCompanion(false)); - expect((await values.next())!.canCreateTasks, isTrue); - } - }, - ); + await check(collection.id, true); + await bind(null); + await check(null, null); + await bind('read-only'); + await check('read-only', false); + await bind(other.id); + await check( + null, + null, + ); // A valid foreign key must not leak another account's collection. + await bind(collection.id); + await check(collection.id, true); + await (database.delete(database.taskLists)..where( + (row) => + row.accountId.equals(list.accountId) & row.id.equals(list.id), + )) + .go(); + await check(null, null); + await database.into(database.taskLists).insert(list.toCompanion(false)); + await check(collection.id, true); + expect( + await repository + .watchCollectionByTaskListId('nextcloud:other', list.id) + .first, + isNull, + ); + expect( + await repository + .watchCollectionByTaskListId(list.accountId, 'missing') + .first, + isNull, + ); + }); - test( - 'provider uses current metadata, owner and joined service principal for task capabilities', - () async { - final values = probe(); - final initial = (await values.next())!; - expect(initial.canUpdateClassification, isTrue); - expect(initial.supportsListDelete, isFalse); - await updateCollection( - const DavCollectionsCompanion( - safeDisplayMetadataJson: Value( - '{"shared":true,"parentPrivileges":["{DAV:}unbind"]}', - ), + test('collection deletion and restoration update the existing provider subscription', () async { + final values = probe(); + expect((await values.next())!.canCreateTasks, isTrue); + await updateCollection(const DavCollectionsCompanion(deleted: Value(true))); + expect(await values.next(), isNull); + await updateCollection( + const DavCollectionsCompanion(deleted: Value(false)), + ); + expect((await values.next())!.canCreateTasks, isTrue); + await inventory.commit(writable: true, missing: true); + expect(await values.next(), isNull); + await inventory.commit(writable: false); + expect((await values.next())!.canCreateTasks, isFalse); + expect( + (await database.select(database.davCollections).getSingle()).id, + collection.id, + ); + + await (database.delete( + database.davCollections, + )..where((row) => row.id.equals(collection.id))).go(); + expect(await values.next(), isNull); + expect( + (await database.select(database.taskLists).getSingle()).davCollectionId, + isNull, + ); + await database + .into(database.davCollections) + .insert(collection.toCompanion(false)); + expect( + await values.next(), + isNull, + ); // Physical restoration does not invent a missing mapping. + await (database.update(database.taskLists)..where( + (row) => + row.accountId.equals(list.accountId) & row.id.equals(list.id), + )) + .write(TaskListsCompanion(davCollectionId: Value(collection.id))); + expect((await values.next())!.canCreateTasks, isTrue); + }); + + test('provider observes component, projection, resource-type and member-privilege changes', () async { + final values = probe(); + expect((await values.next())!.canCreateTasks, isTrue); + for (final changes in [ + const DavCollectionsCompanion( + supportedComponentMask: Value(davComponentEvent), + ), + const DavCollectionsCompanion(taskProjectionEnabled: Value(false)), + DavCollectionsCompanion( + resourceTypesJson: Value( + jsonEncode(['{http://calendarserver.org/ns/}subscribed']), ), - ); - final shared = (await values.next())!; - expect(shared.canUpdateClassification, isFalse); - expect(shared.supportsListDelete, isTrue); - await updateCollection( - const DavCollectionsCompanion(safeDisplayMetadataJson: Value('{}')), - ); - expect((await values.next())!.canUpdateClassification, isTrue); - await updateCollection( - const DavCollectionsCompanion( - ownerHref: Value('https://cloud.example.test/principals/other/'), + ), + const DavCollectionsCompanion( + currentUserPrivilegesJson: Value( + '["{DAV:}read","{DAV:}write-content"]', ), - ); - expect((await values.next())!.canUpdateClassification, isFalse); - await (database.update( - database.davAccountServices, - )..where((row) => row.accountId.equals(list.accountId))).write( - const DavAccountServicesCompanion( - principalHref: Value('https://cloud.example.test/principals/other/'), + ), + ]) { + await updateCollection(changes); + expect((await values.next())!.canCreateTasks, isFalse); + await database + .into(database.davCollections) + .insertOnConflictUpdate(collection.toCompanion(false)); + expect((await values.next())!.canCreateTasks, isTrue); + } + }); + + test('provider uses current metadata, owner and joined service principal for task capabilities', () async { + final values = probe(); + final initial = (await values.next())!; + expect(initial.canUpdateClassification, isTrue); + expect(initial.supportsListDelete, isFalse); + await updateCollection( + const DavCollectionsCompanion( + safeDisplayMetadataJson: Value( + '{"shared":true,"parentPrivileges":["{DAV:}unbind"]}', ), - ); - expect((await values.next())!.canUpdateClassification, isTrue); - }, - ); + ), + ); + final shared = (await values.next())!; + expect(shared.canUpdateClassification, isFalse); + expect(shared.supportsListDelete, isTrue); + await updateCollection( + const DavCollectionsCompanion(safeDisplayMetadataJson: Value('{}')), + ); + expect((await values.next())!.canUpdateClassification, isTrue); + await updateCollection( + const DavCollectionsCompanion( + ownerHref: Value('https://cloud.example.test/principals/other/'), + ), + ); + expect((await values.next())!.canUpdateClassification, isFalse); + await (database.update( + database.davAccountServices, + )..where((row) => row.accountId.equals(list.accountId))).write( + const DavAccountServicesCompanion( + principalHref: Value('https://cloud.example.test/principals/other/'), + ), + ); + expect((await values.next())!.canUpdateClassification, isTrue); + }); } class _CapabilityProbe { diff --git a/test/dav/sync/dav_account_sync_engine_test.dart b/test/dav/sync/dav_account_sync_engine_test.dart index c4d65b63..228a4f7a 100644 --- a/test/dav/sync/dav_account_sync_engine_test.dart +++ b/test/dav/sync/dav_account_sync_engine_test.dart @@ -40,34 +40,32 @@ void main() { tearDown(() => database.close()); - test( - 'account sequence syncs, conditionally replays, follows up, then pauses on 401', - () async { - var syncReports = 0; - var unauthorized = false; - String serverBody = _event('Initial'); - var serverEtag = '"v1"'; - final requests = []; - final client = MockClient((request) async { - requests.add(request); - expect(request.headers['authorization'], startsWith('Basic ')); - if (unauthorized) return http.Response('', 401); - if (request.method == 'OPTIONS') { - return http.Response( - '', - 200, - headers: {'dav': '1, calendar-access, sync-collection'}, - ); + test('account sequence syncs, conditionally replays, follows up, then pauses on 401', () async { + var syncReports = 0; + var unauthorized = false; + String serverBody = _event('Initial'); + var serverEtag = '"v1"'; + final requests = []; + final client = MockClient((request) async { + requests.add(request); + expect(request.headers['authorization'], startsWith('Basic ')); + if (unauthorized) return http.Response('', 401); + if (request.method == 'OPTIONS') { + return http.Response( + '', + 200, + headers: {'dav': '1, calendar-access, sync-collection'}, + ); + } + if (request.method == 'PROPFIND') { + if (request.body.contains('')) { + return _discoveryMultistatus(_currentPrincipalResponse); } - if (request.method == 'PROPFIND') { - if (request.body.contains('')) { - return _discoveryMultistatus(_currentPrincipalResponse); - } - if (request.body.contains('')) { - return _discoveryMultistatus(_principalPropertiesResponse); - } - return _discoveryMultistatus( - ''' + if (request.body.contains('')) { + return _discoveryMultistatus(_principalPropertiesResponse); + } + return _discoveryMultistatus( + ''' $_collectionHref Work @@ -75,144 +73,140 @@ void main() { HTTP/1.1 200 OK''', - ); - } - if (request.method == 'REPORT' && - request.body.contains('sync-collection')) { - syncReports += 1; - if (syncReports == 1) { - expect(request.body, contains('')); - return http.Response( - _syncResponse(token: 'token-1', etag: serverEtag), - 207, - ); - } - if (syncReports == 2) { - expect( - request.body, - contains('token-1'), - ); - return http.Response(_syncResponse(token: 'token-2'), 207); - } - expect( - request.body, - contains('token-2'), - ); + ); + } + if (request.method == 'REPORT' && + request.body.contains('sync-collection')) { + syncReports += 1; + if (syncReports == 1) { + expect(request.body, contains('')); return http.Response( - _syncResponse(token: 'token-3', etag: serverEtag), + _syncResponse(token: 'token-1', etag: serverEtag), 207, ); } - if (request.method == 'REPORT' && - request.body.contains('calendar-multiget')) { - return http.Response(_multigetResponse(serverBody, serverEtag), 207); - } - if (request.method == 'PUT') { - expect(request.headers['if-match'], '"v1"'); - serverBody = request.body; - serverEtag = '"v2"'; - return http.Response('', 204); - } - if (request.method == 'GET') { - return http.Response( - serverBody, - 200, - headers: {'etag': serverEtag, 'content-type': 'text/calendar'}, + if (syncReports == 2) { + expect( + request.body, + contains('token-1'), ); + return http.Response(_syncResponse(token: 'token-2'), 207); } - fail('Unexpected ${request.method} ${request.url}'); - }); - final notificationObjects = {}; - DavAccountSyncEngine engine() => DavAccountSyncEngine( - database: database, - secretStore: secrets, - httpClient: client, - accountId: 'account', - policy: const DavAccountSyncPolicy( - discoveryMaxAge: Duration(days: 30), - inventoryMaxAge: Duration(days: 30), - ), - correlationIdFactory: () => 'safe-correlation', - nowUtc: () => _now, - rebuildNotifications: (accountId, ids) async { - expect(accountId, 'account'); - notificationObjects.addAll(ids); - }, - ); + expect(request.body, contains('token-2')); + return http.Response( + _syncResponse(token: 'token-3', etag: serverEtag), + 207, + ); + } + if (request.method == 'REPORT' && + request.body.contains('calendar-multiget')) { + return http.Response(_multigetResponse(serverBody, serverEtag), 207); + } + if (request.method == 'PUT') { + expect(request.headers['if-match'], '"v1"'); + serverBody = request.body; + serverEtag = '"v2"'; + return http.Response('', 204); + } + if (request.method == 'GET') { + return http.Response( + serverBody, + 200, + headers: {'etag': serverEtag, 'content-type': 'text/calendar'}, + ); + } + fail('Unexpected ${request.method} ${request.url}'); + }); + final notificationObjects = {}; + DavAccountSyncEngine engine() => DavAccountSyncEngine( + database: database, + secretStore: secrets, + httpClient: client, + accountId: 'account', + policy: const DavAccountSyncPolicy( + discoveryMaxAge: Duration(days: 30), + inventoryMaxAge: Duration(days: 30), + ), + correlationIdFactory: () => 'safe-correlation', + nowUtc: () => _now, + rebuildNotifications: (accountId, ids) async { + expect(accountId, 'account'); + notificationObjects.addAll(ids); + }, + ); - final initial = await engine().synchronize(); - expect(initial.collectionsSynchronized, 1); - expect(initial.discoveryRefreshed, isFalse); - final raw = await database.select(database.davObjects).getSingle(); - expect(raw.rawIcsBody, serverBody); - expect(raw.etag, '"v1"'); - expect( - (await database.select(database.calendarEvents).getSingle()).title, - 'Initial', - ); - expect(notificationObjects, {raw.id}); + final initial = await engine().synchronize(); + expect(initial.collectionsSynchronized, 1); + expect(initial.discoveryRefreshed, isFalse); + final raw = await database.select(database.davObjects).getSingle(); + expect(raw.rawIcsBody, serverBody); + expect(raw.etag, '"v1"'); + expect( + (await database.select(database.calendarEvents).getSingle()).title, + 'Initial', + ); + expect(notificationObjects, {raw.id}); - await DavPendingOperationQueue( - database: database, - idFactory: () => 'pending-update', - nowUtc: () => _now, - ).enqueueUpdate( - accountId: 'account', - collectionId: 'collection', - objectId: raw.id, - patch: DavMutationPatch( - target: const IcalComponentKey( - componentType: 'VEVENT', - uid: 'event@example.test', - ), - scope: DavMutationScope.object, - operations: [DavPatchOperation.setText('SUMMARY', 'Offline edit')], + await DavPendingOperationQueue( + database: database, + idFactory: () => 'pending-update', + nowUtc: () => _now, + ).enqueueUpdate( + accountId: 'account', + collectionId: 'collection', + objectId: raw.id, + patch: DavMutationPatch( + target: const IcalComponentKey( + componentType: 'VEVENT', + uid: 'event@example.test', ), - ); - notificationObjects.clear(); - - final replayed = await engine().synchronize(); - expect(replayed.discoveryRefreshed, isTrue); - expect(replayed.pendingOperationsApplied, 1); - expect(replayed.followUpCollectionsSynchronized, 1); - expect(await database.select(database.pendingOps).get(), isEmpty); - expect(serverBody, contains('SUMMARY:Offline edit')); - expect( - (await database.select(database.calendarEvents).getSingle()).title, - 'Offline edit', - ); - expect( - (await database.select(database.syncCursors).getSingle()).cursorValue, - 'token-3', - ); - expect(notificationObjects, isNotEmpty); + scope: DavMutationScope.object, + operations: [DavPatchOperation.setText('SUMMARY', 'Offline edit')], + ), + ); + notificationObjects.clear(); + + final replayed = await engine().synchronize(); + expect(replayed.discoveryRefreshed, isTrue); + expect(replayed.pendingOperationsApplied, 1); + expect(replayed.followUpCollectionsSynchronized, 1); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect(serverBody, contains('SUMMARY:Offline edit')); + expect( + (await database.select(database.calendarEvents).getSingle()).title, + 'Offline edit', + ); + expect( + (await database.select(database.syncCursors).getSingle()).cursorValue, + 'token-3', + ); + expect(notificationObjects, isNotEmpty); - unauthorized = true; - await expectLater( - engine().synchronize(), - throwsA( - isA().having( - (error) => error.failures.single.kind, - 'failure kind', - DavErrorKind.authentication, - ), + unauthorized = true; + await expectLater( + engine().synchronize(), + throwsA( + isA().having( + (error) => error.failures.single.kind, + 'failure kind', + DavErrorKind.authentication, ), - ); - expect( - (await database.select(database.accounts).getSingle()).authState, - 'reauth_required', - ); - expect(await database.select(database.davObjects).get(), hasLength(1)); - expect( - (await database.select(database.calendarEvents).getSingle()).title, - 'Offline edit', - ); - expect( - requests.every((request) => !request.url.toString().contains('secret')), - isTrue, - ); - }, - ); + ), + ); + expect( + (await database.select(database.accounts).getSingle()).authState, + 'reauth_required', + ); + expect(await database.select(database.davObjects).get(), hasLength(1)); + expect( + (await database.select(database.calendarEvents).getSingle()).title, + 'Offline edit', + ); + expect( + requests.every((request) => !request.url.toString().contains('secret')), + isTrue, + ); + }); test( 'missing credential marks reauthentication without deleting cache', @@ -285,9 +279,8 @@ void main() { .getSingle(); final detail = await repository.loadEventDetail(initial.id); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail!, - ).copyWith(title: 'Local title'), + EventEditorDraft.fromEventDetail(detail!) + .copyWith(title: 'Local title'), ); expect( (await database.select(database.calendarEvents).getSingle()).title, @@ -308,9 +301,9 @@ void main() { expect(projectedLocal.title, 'Local title'); expect(projectedLocal.syncStatus, 'pending'); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; final rebuiltObjects = {}; await DavConflictResolutionService( database: database, @@ -408,9 +401,9 @@ void main() { expect(projectedLocal.title, 'Local task'); expect(projectedLocal.localDirty, isTrue); - final conflict = await DavConflictRepository( - database: database, - ).watchUnresolved().first; + final conflict = await DavConflictRepository(database: database) + .watchUnresolved() + .first; final rebuiltObjects = {}; await DavConflictResolutionService( database: database, @@ -450,238 +443,232 @@ void main() { }, ); - test( - 'Diagnostics retry completes a remotely moved task after source sync deletion', - () async { - await _seedDestination(database); - const uid = 'partial-move-task@example.test'; - const sourceHref = '${_collectionHref}partial-move-task.ics'; - const destinationHref = - '${_destinationCollectionHref}partial-move-task.ics'; - final sourceRaw = _taskWithParent(uid, 'Parented task'); - final objectRepository = DavObjectRepository( - database: database, - idFactory: () => 'source-object', - ); - await objectRepository.commitConfirmedMutation( - accountId: 'account', - collectionId: 'collection', - provider: BusyProvider.nextcloud, - canonicalObject: _preparedMember( - href: sourceHref, - etag: '"source-1"', - body: sourceRaw, - ), - completedAtUtc: _now, - ); - final sourceObject = await (database.select( - database.davObjects, - )..where((row) => row.hrefKey.equals(sourceHref))).getSingle(); - final sourceTask = await (database.select( - database.tasks, - )..where((row) => row.davObjectId.equals(sourceObject.id))).getSingle(); - final operationId = - await DavPendingOperationQueue( - database: database, - idFactory: () => 'pending-partial-move', - nowUtc: () => _now, - ).enqueueMove( - accountId: 'account', - sourceCollectionId: 'collection', - destinationCollectionId: 'destination', - objectId: sourceObject.id, - localProjectionId: sourceTask.id, + test('Diagnostics retry completes a remotely moved task after source sync deletion', () async { + await _seedDestination(database); + const uid = 'partial-move-task@example.test'; + const sourceHref = '${_collectionHref}partial-move-task.ics'; + const destinationHref = + '${_destinationCollectionHref}partial-move-task.ics'; + final sourceRaw = _taskWithParent(uid, 'Parented task'); + final objectRepository = DavObjectRepository( + database: database, + idFactory: () => 'source-object', + ); + await objectRepository.commitConfirmedMutation( + accountId: 'account', + collectionId: 'collection', + provider: BusyProvider.nextcloud, + canonicalObject: _preparedMember( + href: sourceHref, + etag: '"source-1"', + body: sourceRaw, + ), + completedAtUtc: _now, + ); + final sourceObject = await (database.select( + database.davObjects, + )..where((row) => row.hrefKey.equals(sourceHref))).getSingle(); + final sourceTask = await (database.select( + database.tasks, + )..where((row) => row.davObjectId.equals(sourceObject.id))).getSingle(); + final operationId = + await DavPendingOperationQueue( + database: database, + idFactory: () => 'pending-partial-move', + nowUtc: () => _now, + ).enqueueMove( + accountId: 'account', + sourceCollectionId: 'collection', + destinationCollectionId: 'destination', + objectId: sourceObject.id, + localProjectionId: sourceTask.id, + target: const IcalComponentKey(componentType: 'VTODO', uid: uid), + postMovePatch: DavMutationPatch( target: const IcalComponentKey(componentType: 'VTODO', uid: uid), - postMovePatch: DavMutationPatch( - target: const IcalComponentKey(componentType: 'VTODO', uid: uid), - scope: DavMutationScope.object, - operations: [DavPatchOperation.setTaskParent(null)], - ), - ); + scope: DavMutationScope.object, + operations: [DavPatchOperation.setTaskParent(null)], + ), + ); - var sourceExists = true; - String? destinationRaw; - var destinationEtag = '"destination-1"'; - var moves = 0; - var destinationPatchPuts = 0; - var destinationGets = 0; - var token = 0; - final requests = []; - addTearDown(() => printOnFailure(requests.join('\n'))); - final client = MockClient((request) async { - requests.add('${request.method} ${request.url.path} ${request.body}'); - if (request.method == 'OPTIONS') { + var sourceExists = true; + String? destinationRaw; + var destinationEtag = '"destination-1"'; + var moves = 0; + var destinationPatchPuts = 0; + var destinationGets = 0; + var token = 0; + final requests = []; + addTearDown(() => printOnFailure(requests.join('\n'))); + final client = MockClient((request) async { + requests.add('${request.method} ${request.url.path} ${request.body}'); + if (request.method == 'OPTIONS') { + return http.Response( + '', + 200, + headers: {'dav': '1, calendar-access, sync-collection'}, + ); + } + if (request.method == 'PROPFIND') { + if (request.body.contains('')) { + return _discoveryMultistatus(_currentPrincipalResponse); + } + if (request.body.contains('')) { + return _discoveryMultistatus(_principalPropertiesResponse); + } + return _discoveryMultistatus(_twoCollectionInventoryResponse); + } + if (request.method == 'REPORT' && + request.body.contains('sync-collection')) { + token += 1; + if (request.url.path == _collectionHref) { return http.Response( - '', - 200, - headers: {'dav': '1, calendar-access, sync-collection'}, + _syncMemberResponse( + token: 'source-$token', + href: sourceHref, + etag: sourceExists ? '"source-1"' : null, + deleted: !sourceExists, + ), + 207, ); } - if (request.method == 'PROPFIND') { - if (request.body.contains('')) { - return _discoveryMultistatus(_currentPrincipalResponse); - } - if (request.body.contains('')) { - return _discoveryMultistatus(_principalPropertiesResponse); - } - return _discoveryMultistatus(_twoCollectionInventoryResponse); + if (request.url.path == _destinationCollectionHref) { + return http.Response( + _syncMemberResponse( + token: 'destination-$token', + href: destinationHref, + etag: destinationRaw == null ? null : destinationEtag, + ), + 207, + ); } - if (request.method == 'REPORT' && - request.body.contains('sync-collection')) { - token += 1; - if (request.url.path == _collectionHref) { - return http.Response( - _syncMemberResponse( - token: 'source-$token', - href: sourceHref, - etag: sourceExists ? '"source-1"' : null, - deleted: !sourceExists, - ), - 207, - ); - } - if (request.url.path == _destinationCollectionHref) { - return http.Response( - _syncMemberResponse( - token: 'destination-$token', - href: destinationHref, - etag: destinationRaw == null ? null : destinationEtag, - ), - 207, - ); - } + } + if (request.method == 'REPORT' && + request.body.contains('calendar-multiget')) { + if (request.url.path == _collectionHref && sourceExists) { + return http.Response( + _multigetMemberResponse( + href: sourceHref, + body: sourceRaw, + etag: '"source-1"', + ), + 207, + ); } - if (request.method == 'REPORT' && - request.body.contains('calendar-multiget')) { - if (request.url.path == _collectionHref && sourceExists) { - return http.Response( - _multigetMemberResponse( - href: sourceHref, - body: sourceRaw, - etag: '"source-1"', - ), - 207, - ); - } - if (request.url.path == _destinationCollectionHref && - destinationRaw != null) { - return http.Response( - _multigetMemberResponse( - href: destinationHref, - body: destinationRaw!, - etag: destinationEtag, - ), - 207, - ); - } + if (request.url.path == _destinationCollectionHref && + destinationRaw != null) { + return http.Response( + _multigetMemberResponse( + href: destinationHref, + body: destinationRaw!, + etag: destinationEtag, + ), + 207, + ); } - if (request.method == 'MOVE') { - moves += 1; - expect(request.url.path, sourceHref); - expect(request.headers['destination'], endsWith(destinationHref)); - expect(request.headers['if-match'], '"source-1"'); - sourceExists = false; - destinationRaw = sourceRaw; - return http.Response('', 204); + } + if (request.method == 'MOVE') { + moves += 1; + expect(request.url.path, sourceHref); + expect(request.headers['destination'], endsWith(destinationHref)); + expect(request.headers['if-match'], '"source-1"'); + sourceExists = false; + destinationRaw = sourceRaw; + return http.Response('', 204); + } + if (request.method == 'GET') { + if (request.url.path == sourceHref && !sourceExists) { + return http.Response('', 404); } - if (request.method == 'GET') { - if (request.url.path == sourceHref && !sourceExists) { - return http.Response('', 404); - } - if (request.url.path == destinationHref && destinationRaw != null) { - destinationGets += 1; - if (destinationGets == 2) { - final synchronizedSource = await (database.select( - database.davObjects, - )..where((row) => row.id.equals(sourceObject.id))).getSingle(); - expect(synchronizedSource.serverDeleted, isTrue); - expect( - (await database.pendingOpsDao.getOp(operationId))?.id, - operationId, - ); - } - return http.Response( - destinationRaw!, - 200, - headers: { - 'etag': destinationEtag, - 'content-type': 'text/calendar', - }, + if (request.url.path == destinationHref && destinationRaw != null) { + destinationGets += 1; + if (destinationGets == 2) { + final synchronizedSource = await (database.select( + database.davObjects, + )..where((row) => row.id.equals(sourceObject.id))).getSingle(); + expect(synchronizedSource.serverDeleted, isTrue); + expect( + (await database.pendingOpsDao.getOp(operationId))?.id, + operationId, ); } + return http.Response( + destinationRaw!, + 200, + headers: {'etag': destinationEtag, 'content-type': 'text/calendar'}, + ); } - if (request.method == 'PUT' && request.url.path == destinationHref) { - destinationPatchPuts += 1; - expect(request.headers['if-match'], destinationEtag); - expect(request.body, isNot(contains('RELATED-TO'))); - if (destinationPatchPuts == 1) { - return http.Response('Rejected', 415); - } - destinationRaw = request.body; - destinationEtag = '"destination-2"'; - return http.Response('', 204); + } + if (request.method == 'PUT' && request.url.path == destinationHref) { + destinationPatchPuts += 1; + expect(request.headers['if-match'], destinationEtag); + expect(request.body, isNot(contains('RELATED-TO'))); + if (destinationPatchPuts == 1) { + return http.Response('Rejected', 415); } - fail('Unexpected ${request.method} ${request.url}'); - }); - var now = _now; - DavAccountSyncEngine engine() => DavAccountSyncEngine( - database: database, - secretStore: secrets, - httpClient: client, - accountId: 'account', - policy: const DavAccountSyncPolicy( - discoveryMaxAge: Duration(days: 30), - inventoryMaxAge: Duration(days: 30), - maximumConcurrentCollections: 1, - ), - correlationIdFactory: () => 'partial-move-recovery', - nowUtc: () => now, - ); - - final failedReplay = await engine().synchronize(); - expect(failedReplay.pendingOperationsApplied, 0); - final failed = await database.pendingOpsDao.getOp(operationId); - expect(failed?.state, 'failed'); - expect(failed?.retryClassification, davPartialMoveRetryClassification); - expect(isDavPartiallyCompletedMove(failed!), isTrue); - expect(moves, 1); - expect(destinationPatchPuts, 1); - - now = now.add(const Duration(minutes: 1)); - await PendingOpResolutionService( - database: database, - accountId: 'account', - syncCalendar: () async => fail('A task move must use task sync.'), - syncTasks: () async { - await engine().synchronize(); - }, - nowUtc: () => now, - ).retryNow(operationId); + destinationRaw = request.body; + destinationEtag = '"destination-2"'; + return http.Response('', 204); + } + fail('Unexpected ${request.method} ${request.url}'); + }); + var now = _now; + DavAccountSyncEngine engine() => DavAccountSyncEngine( + database: database, + secretStore: secrets, + httpClient: client, + accountId: 'account', + policy: const DavAccountSyncPolicy( + discoveryMaxAge: Duration(days: 30), + inventoryMaxAge: Duration(days: 30), + maximumConcurrentCollections: 1, + ), + correlationIdFactory: () => 'partial-move-recovery', + nowUtc: () => now, + ); - expect(moves, 1); - expect(destinationPatchPuts, 2); - expect(destinationGets, 3); - expect(await database.pendingOpsDao.getOp(operationId), null); - expect( - (await (database.select( - database.davObjects, - )..where((row) => row.id.equals(sourceObject.id))).getSingle()) - .serverDeleted, - isTrue, - ); - final destinationObject = await (database.select( - database.davObjects, - )..where((row) => row.hrefKey.equals(destinationHref))).getSingle(); - expect(destinationObject.collectionId, 'destination'); - expect(destinationObject.serverDeleted, isFalse); - final tasks = await database.select(database.tasks).get(); - expect(tasks, hasLength(1)); - expect(tasks.single.davCollectionId, 'destination'); - expect(tasks.single.taskListId, 'dav-task-list-destination'); - expect(tasks.single.title, 'Parented task'); - expect(tasks.single.parentUid, null); - }, - ); + final failedReplay = await engine().synchronize(); + expect(failedReplay.pendingOperationsApplied, 0); + final failed = await database.pendingOpsDao.getOp(operationId); + expect(failed?.state, 'failed'); + expect(failed?.retryClassification, davPartialMoveRetryClassification); + expect(isDavPartiallyCompletedMove(failed!), isTrue); + expect(moves, 1); + expect(destinationPatchPuts, 1); + + now = now.add(const Duration(minutes: 1)); + await PendingOpResolutionService( + database: database, + accountId: 'account', + syncCalendar: () async => fail('A task move must use task sync.'), + syncTasks: () async { + await engine().synchronize(); + }, + nowUtc: () => now, + ).retryNow(operationId); + + expect(moves, 1); + expect(destinationPatchPuts, 2); + expect(destinationGets, 3); + expect(await database.pendingOpsDao.getOp(operationId), null); + expect( + (await (database.select( + database.davObjects, + )..where((row) => row.id.equals(sourceObject.id))).getSingle()) + .serverDeleted, + isTrue, + ); + final destinationObject = await (database.select( + database.davObjects, + )..where((row) => row.hrefKey.equals(destinationHref))).getSingle(); + expect(destinationObject.collectionId, 'destination'); + expect(destinationObject.serverDeleted, isFalse); + final tasks = await database.select(database.tasks).get(); + expect(tasks, hasLength(1)); + expect(tasks.single.davCollectionId, 'destination'); + expect(tasks.single.taskListId, 'dav-task-list-destination'); + expect(tasks.single.title, 'Parented task'); + expect(tasks.single.parentUid, null); + }); test( 'removed collection rebuilds reminders without object-level changes', @@ -815,148 +802,142 @@ void main() { }, ); - test( - 'temporary DAV failure keeps cached reminders and recovery rebuilds after reconnect', - () async { - var clock = DateTime.utc(2026, 8, 8, 12); - final initialBody = _eventWithReminder( - 'Cached appointment', - startUtc: DateTime.utc(2026, 8, 9, 9), - ); - final repository = DavObjectRepository( - database: database, - idFactory: () => 'reminder-object', - ); - await repository.commitConfirmedMutation( - accountId: 'account', - collectionId: 'collection', - provider: BusyProvider.nextcloud, - canonicalObject: _preparedMember( - href: _eventHref, - etag: '"reminder-1"', - body: initialBody, - ), - completedAtUtc: clock, - ); - final scheduleService = NotificationScheduleService( - database: database, - nowUtc: () => clock, - ); - await scheduleService.rebuildUpcomingNotifications('account'); - var scheduled = await database - .select(database.notificationSchedule) - .getSingle(); - expect( - scheduled.scheduledAtUtc, - DateTime.utc(2026, 8, 9, 8, 30).millisecondsSinceEpoch, - ); - - await expectLater( - DavAccountSyncEngine( - database: database, - secretStore: _UnavailableSecretStore(), - httpClient: MockClient((_) async => http.Response('', 500)), - accountId: 'account', - nowUtc: () => clock, - ).synchronize(), - throwsA( - isA().having( - (error) => error.code, - 'code', - 'DavCredentialStoreUnavailable', - ), - ), - ); - expect( - (await database.select(database.accounts).getSingle()).authState, - 'temporarily_unavailable', - ); - expect( - await database.select(database.notificationSchedule).get(), - hasLength(1), - ); - - clock = DateTime.utc(2026, 8, 9, 8, 30); - final notificationBackend = _ReminderRecordingBackend(); - final scheduler = NotificationScheduler( - database: database, - notifications: DesktopNotificationService( - backend: notificationBackend, - settings: AppSettings.defaults(), - now: () => clock, - ), - nowUtc: () => clock, - ); - await scheduler.checkNow(); - scheduler.stop(); - expect(notificationBackend.requests, hasLength(1)); - expect(notificationBackend.requests.single.title, 'Cached appointment'); - scheduled = await database - .select(database.notificationSchedule) - .getSingle(); - expect(scheduled.sentAtUtc, isNotNull); + test('temporary DAV failure keeps cached reminders and recovery rebuilds after reconnect', () async { + var clock = DateTime.utc(2026, 8, 8, 12); + final initialBody = _eventWithReminder( + 'Cached appointment', + startUtc: DateTime.utc(2026, 8, 9, 9), + ); + final repository = DavObjectRepository( + database: database, + idFactory: () => 'reminder-object', + ); + await repository.commitConfirmedMutation( + accountId: 'account', + collectionId: 'collection', + provider: BusyProvider.nextcloud, + canonicalObject: _preparedMember( + href: _eventHref, + etag: '"reminder-1"', + body: initialBody, + ), + completedAtUtc: clock, + ); + final scheduleService = NotificationScheduleService( + database: database, + nowUtc: () => clock, + ); + await scheduleService.rebuildUpcomingNotifications('account'); + var scheduled = await database + .select(database.notificationSchedule) + .getSingle(); + expect( + scheduled.scheduledAtUtc, + DateTime.utc(2026, 8, 9, 8, 30).millisecondsSinceEpoch, + ); - final recoveredBody = _eventWithReminder( - 'Recovered appointment', - startUtc: DateTime.utc(2026, 8, 10, 9), - ); - var syncReports = 0; - final recoveryClient = MockClient((request) async { - if (request.method == 'REPORT' && - request.body.contains('sync-collection')) { - syncReports += 1; - return http.Response( - _syncResponse( - token: 'recovered-$syncReports', - etag: '"reminder-2"', - ), - 207, - ); - } - if (request.method == 'REPORT' && - request.body.contains('calendar-multiget')) { - return http.Response( - _multigetResponse(recoveredBody, '"reminder-2"'), - 207, - ); - } - fail('Unexpected ${request.method} ${request.url}'); - }); - var rebuilds = 0; - await DavAccountSyncEngine( + await expectLater( + DavAccountSyncEngine( database: database, - secretStore: secrets, - httpClient: recoveryClient, + secretStore: _UnavailableSecretStore(), + httpClient: MockClient((_) async => http.Response('', 500)), accountId: 'account', - policy: const DavAccountSyncPolicy( - discoveryMaxAge: Duration(days: 30), - inventoryMaxAge: Duration(days: 30), - ), nowUtc: () => clock, - rebuildNotifications: (accountId, _) async { - rebuilds += 1; - expect( - (await database.select(database.accounts).getSingle()).authState, - 'signed_in', - ); - await scheduleService.rebuildUpcomingNotifications(accountId); - }, - ).synchronize(); + ).synchronize(), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavCredentialStoreUnavailable', + ), + ), + ); + expect( + (await database.select(database.accounts).getSingle()).authState, + 'temporarily_unavailable', + ); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); - expect(rebuilds, 1); - scheduled = await database - .select(database.notificationSchedule) - .getSingle(); - expect(scheduled.title, 'Recovered appointment'); - expect( - scheduled.scheduledAtUtc, - DateTime.utc(2026, 8, 10, 8, 30).millisecondsSinceEpoch, - ); - expect(scheduled.sentAtUtc, isNull); - expect(scheduled.dismissedAtUtc, isNull); - expect(scheduled.snoozedUntilUtc, isNull); - }, - ); + clock = DateTime.utc(2026, 8, 9, 8, 30); + final notificationBackend = _ReminderRecordingBackend(); + final scheduler = NotificationScheduler( + database: database, + notifications: DesktopNotificationService( + backend: notificationBackend, + settings: AppSettings.defaults(), + now: () => clock, + ), + nowUtc: () => clock, + ); + await scheduler.checkNow(); + scheduler.stop(); + expect(notificationBackend.requests, hasLength(1)); + expect(notificationBackend.requests.single.title, 'Cached appointment'); + scheduled = await database + .select(database.notificationSchedule) + .getSingle(); + expect(scheduled.sentAtUtc, isNotNull); + + final recoveredBody = _eventWithReminder( + 'Recovered appointment', + startUtc: DateTime.utc(2026, 8, 10, 9), + ); + var syncReports = 0; + final recoveryClient = MockClient((request) async { + if (request.method == 'REPORT' && + request.body.contains('sync-collection')) { + syncReports += 1; + return http.Response( + _syncResponse(token: 'recovered-$syncReports', etag: '"reminder-2"'), + 207, + ); + } + if (request.method == 'REPORT' && + request.body.contains('calendar-multiget')) { + return http.Response( + _multigetResponse(recoveredBody, '"reminder-2"'), + 207, + ); + } + fail('Unexpected ${request.method} ${request.url}'); + }); + var rebuilds = 0; + await DavAccountSyncEngine( + database: database, + secretStore: secrets, + httpClient: recoveryClient, + accountId: 'account', + policy: const DavAccountSyncPolicy( + discoveryMaxAge: Duration(days: 30), + inventoryMaxAge: Duration(days: 30), + ), + nowUtc: () => clock, + rebuildNotifications: (accountId, _) async { + rebuilds += 1; + expect( + (await database.select(database.accounts).getSingle()).authState, + 'signed_in', + ); + await scheduleService.rebuildUpcomingNotifications(accountId); + }, + ).synchronize(); + + expect(rebuilds, 1); + scheduled = await database + .select(database.notificationSchedule) + .getSingle(); + expect(scheduled.title, 'Recovered appointment'); + expect( + scheduled.scheduledAtUtc, + DateTime.utc(2026, 8, 10, 8, 30).millisecondsSinceEpoch, + ); + expect(scheduled.sentAtUtc, isNull); + expect(scheduled.dismissedAtUtc, isNull); + expect(scheduled.snoozedUntilUtc, isNull); + }); test( 'restart reconciles an in-progress MOVE after source synchronization', diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index b32249e7..d683e1a2 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -102,95 +102,152 @@ void main() { }); }); - test( - 'reopens the current schema with coordinates, remembered provenance, and pending work', - () async { + test('schema 19 migration adds contacts without changing app data', () async { + await database.close(); + final directory = await createPersistenceTestDirectory( + 'busymax-contacts-migration-', + ); + addTearDown(() async { await database.close(); - final directory = await createPersistenceTestDirectory( - 'busymax-schema14-location-', - ); - addTearDown(() async { - await database.close(); - database = AppDatabase(NativeDatabase.memory()); - await directory.delete(recursive: true); - }); - final file = File('${directory.path}/busymax.sqlite'); - database = AppDatabase(NativeDatabase(file)); - await _insertAccount(database); - await database - .into(database.calendarSources) - .insert( - CalendarSourcesCompanion.insert( - id: 'source', - accountId: 'account', - provider: 'google', - providerCalendarId: 'calendar', - summary: 'Calendar', - createdAtLocal: 1, - updatedAtLocal: 1, - ), - ); - await database - .into(database.calendarEvents) - .insert( - CalendarEventsCompanion.insert( - id: 'event', - accountId: 'account', - calendarSourceId: 'source', - provider: 'google', - providerCalendarId: 'calendar', - providerEventId: 'event', - title: 'Located event', - location: const Value('Harbour Centre'), - locationLatitude: const Value(49.2827), - locationLongitude: const Value(-123.1207), - rawJson: const Value('{}'), - createdAtLocal: 1, - updatedAtLocal: 2, - ), - ); - await database - .into(database.locationResolutions) - .insert( - LocationResolutionsCompanion.insert( - kind: 'event', - accountId: 'account', - sourceId: 'source', - itemId: 'event', - locationText: 'Harbour Centre', - label: 'Harbour Centre', - latitude: 49.2827, - longitude: -123.1207, - source: 'legacy-provider', - attribution: 'Existing provenance', - ), - ); - await database - .into(database.pendingOps) - .insert(_pendingOp(id: 'pending', createdAtUtc: _now)); + database = AppDatabase(NativeDatabase.memory()); + await directory.delete(recursive: true); + }); + final file = File('${directory.path}/busymax.sqlite'); + database = AppDatabase(NativeDatabase(file)); + await _insertAccount(database); + for (final table in const [ + 'bm_contact_source_preferences', + 'bm_contact_drafts', + 'bm_contact_sync_states', + 'bm_contact_mutations', + 'bm_contact_records', + 'bm_contact_sources', + 'bm_contact_account_links', + 'bm_contact_accounts', + ]) { + await database.customStatement('DROP TABLE $table'); + } + await database.customStatement('PRAGMA user_version = 19'); + await database.close(); + + database = AppDatabase(NativeDatabase(file)); + final version = await database + .customSelect('PRAGMA user_version') + .getSingle(); + final contactTables = await database + .customSelect( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name LIKE 'bm_contact_%' ORDER BY name", + ) + .get(); + + expect(version.read('user_version'), latestSchemaVersion); + expect( + (await database.select(database.accounts).getSingle()).id, + 'account', + ); + expect(contactTables.map((row) => row.read('name')).toSet(), { + 'bm_contact_account_links', + 'bm_contact_accounts', + 'bm_contact_drafts', + 'bm_contact_mutations', + 'bm_contact_records', + 'bm_contact_source_preferences', + 'bm_contact_sources', + 'bm_contact_sync_states', + }); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + }); + + test('reopens the current schema with coordinates, remembered provenance, and pending work', () async { + await database.close(); + final directory = await createPersistenceTestDirectory( + 'busymax-schema14-location-', + ); + addTearDown(() async { await database.close(); + database = AppDatabase(NativeDatabase.memory()); + await directory.delete(recursive: true); + }); + final file = File('${directory.path}/busymax.sqlite'); + database = AppDatabase(NativeDatabase(file)); + await _insertAccount(database); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'source', + accountId: 'account', + provider: 'google', + providerCalendarId: 'calendar', + summary: 'Calendar', + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: 'event', + accountId: 'account', + calendarSourceId: 'source', + provider: 'google', + providerCalendarId: 'calendar', + providerEventId: 'event', + title: 'Located event', + location: const Value('Harbour Centre'), + locationLatitude: const Value(49.2827), + locationLongitude: const Value(-123.1207), + rawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 2, + ), + ); + await database + .into(database.locationResolutions) + .insert( + LocationResolutionsCompanion.insert( + kind: 'event', + accountId: 'account', + sourceId: 'source', + itemId: 'event', + locationText: 'Harbour Centre', + label: 'Harbour Centre', + latitude: 49.2827, + longitude: -123.1207, + source: 'legacy-provider', + attribution: 'Existing provenance', + ), + ); + await database + .into(database.pendingOps) + .insert(_pendingOp(id: 'pending', createdAtUtc: _now)); + await database.close(); - database = AppDatabase(NativeDatabase(file)); - final version = await database - .customSelect('PRAGMA user_version') - .getSingle(); - final event = await database.select(database.calendarEvents).getSingle(); - final remembered = await database - .select(database.locationResolutions) - .getSingle(); + database = AppDatabase(NativeDatabase(file)); + final version = await database + .customSelect('PRAGMA user_version') + .getSingle(); + final event = await database.select(database.calendarEvents).getSingle(); + final remembered = await database + .select(database.locationResolutions) + .getSingle(); - expect(latestSchemaVersion, 19); - expect(version.read('user_version'), latestSchemaVersion); - expect(event.locationLatitude, 49.2827); - expect(event.locationLongitude, -123.1207); - expect(remembered.source, 'legacy-provider'); - expect(remembered.attribution, 'Existing provenance'); - expect( - (await database.select(database.pendingOps).getSingle()).id, - 'pending', - ); - }, - ); + expect(latestSchemaVersion, 20); + expect(version.read('user_version'), latestSchemaVersion); + expect(event.locationLatitude, 49.2827); + expect(event.locationLongitude, -123.1207); + expect(remembered.source, 'legacy-provider'); + expect(remembered.attribution, 'Existing provenance'); + expect( + (await database.select(database.pendingOps).getSingle()).id, + 'pending', + ); + }); test('schema 14 migration preserves actionable legacy reminders', () async { await database.close(); diff --git a/test/demo/demo_seed_test.dart b/test/demo/demo_seed_test.dart index 5f37eb43..c8260d43 100644 --- a/test/demo/demo_seed_test.dart +++ b/test/demo/demo_seed_test.dart @@ -42,9 +42,8 @@ void main() { await seedBusyMaxDemoData(database, now: today); - final items = await ScheduleRepository( - database, - ).listItems(range: ScheduleRange.day(today)); + final items = await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(today)); expect(items.map((item) => item.title), contains('Product planning')); expect(items.map((item) => item.title), contains('Focus day')); diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 279df2f4..6ad56e2b 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -1,5 +1,6 @@ import '../../../core/auth/authorization_transition_test.dart' show Harness, owned, tokens; + import 'dart:async'; import 'package:busymax/src/core/auth/authorization_persistence.dart'; @@ -201,43 +202,40 @@ void main() { }, ); } - test( - 'successful remote revocation followed by failed local removal reports actual state', - () async { - final h = Harness(); - addTearDown(() async { - h.staging.dispose(); - await h.db.close(); - }); - await h.seed(); - await h.db.customStatement( - "CREATE TRIGGER refuse_delete BEFORE DELETE ON accounts BEGIN SELECT RAISE(ABORT, 'injected'); END", - ); - await expectLater( - h.repository.removeAccount( - accountId: 'opaque', - revokeAuthorization: true, - ), - throwsA( - isA().having( - (e) => e.remoteAuthorizationRevoked, - 'remote side effect remains', - true, - ), + test('successful remote revocation followed by failed local removal reports actual state', () async { + final h = Harness(); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + await h.seed(); + await h.db.customStatement( + "CREATE TRIGGER refuse_delete BEFORE DELETE ON accounts BEGIN SELECT RAISE(ABORT, 'injected'); END", + ); + await expectLater( + h.repository.removeAccount( + accountId: 'opaque', + revokeAuthorization: true, + ), + throwsA( + isA().having( + (e) => e.remoteAuthorizationRevoked, + 'remote side effect remains', + true, ), - ); - expect(h.requests.where((r) => r.url.path == '/revoke'), hasLength(1)); - expect(await h.accounts.accountById('opaque'), isNotNull); - await h.persistence.recover(); - expect( - await h.persistence.readCurrentCredential('opaque'), - isA(), - ); - await h.db.customStatement('DROP TRIGGER refuse_delete'); - await h.repository.removeAccount(accountId: 'opaque'); - expect(await h.secrets.readCredential('opaque'), isNull); - }, - ); + ), + ); + expect(h.requests.where((r) => r.url.path == '/revoke'), hasLength(1)); + expect(await h.accounts.accountById('opaque'), isNotNull); + await h.persistence.recover(); + expect( + await h.persistence.readCurrentCredential('opaque'), + isA(), + ); + await h.db.customStatement('DROP TRIGGER refuse_delete'); + await h.repository.removeAccount(accountId: 'opaque'); + expect(await h.secrets.readCredential('opaque'), isNull); + }); test( 'legacy selected record is revoked without refresh or issuer discovery', () async { @@ -352,9 +350,8 @@ void main() { ); } expect( - await AccountsRepository( - database: database, - ).accountById('selected'), + await AccountsRepository(database: database) + .accountById('selected'), isNull, ); expect(await secrets.readCredential('selected'), isNull); @@ -364,9 +361,8 @@ void main() { isEmpty, ); expect( - (await realRepository.removeAccount( - accountId: 'selected', - )).alreadyRemoved, + (await realRepository.removeAccount(accountId: 'selected')) + .alreadyRemoved, true, ); expect( diff --git a/test/features/auth/data/auth_session_controller_test.dart b/test/features/auth/data/auth_session_controller_test.dart index b4f27287..6f153d69 100644 --- a/test/features/auth/data/auth_session_controller_test.dart +++ b/test/features/auth/data/auth_session_controller_test.dart @@ -48,73 +48,57 @@ void main() { grantedScopes: '', ); - test( - 'session-only reconciliation selects the persisted first connection without sync', - () async { - expect(controller.state.isSignedIn, isFalse); - await persist('apple:first', BusyProvider.appleICloud); - await controller.reconcileConnectedAccount('apple:first'); - expect(controller.state.isSignedIn, isTrue); - expect(controller.state.accountId, 'apple:first'); - expect(syncs, isEmpty); - }, - ); + test('session-only reconciliation selects the persisted first connection without sync', () async { + expect(controller.state.isSignedIn, isFalse); + await persist('apple:first', BusyProvider.appleICloud); + await controller.reconcileConnectedAccount('apple:first'); + expect(controller.state.isSignedIn, isTrue); + expect(controller.state.accountId, 'apple:first'); + expect(syncs, isEmpty); + }); - test( - 'session-only reconciliation preserves a valid session ahead of repository ordering', - () async { - await persist('microsoft:existing', BusyProvider.microsoft); - await controller.load(); - final original = controller.state; - syncs.clear(); - await persist('google:new', BusyProvider.google); - await controller.reconcileConnectedAccount('google:new'); - expect(controller.state, same(original)); - expect(controller.state.accountId, 'microsoft:existing'); - expect(syncs, isEmpty); - }, - ); + test('session-only reconciliation preserves a valid session ahead of repository ordering', () async { + await persist('microsoft:existing', BusyProvider.microsoft); + await controller.load(); + final original = controller.state; + syncs.clear(); + await persist('google:new', BusyProvider.google); + await controller.reconcileConnectedAccount('google:new'); + expect(controller.state, same(original)); + expect(controller.state.accountId, 'microsoft:existing'); + expect(syncs, isEmpty); + }); - test( - 'missing, subscription and reconnect-required rows cannot manufacture a session', - () async { - final original = controller.state; - await controller.reconcileConnectedAccount('missing'); - await persist('webcal:only', BusyProvider.webCal); - await controller.reconcileConnectedAccount('webcal:only'); - await persist('google:invalid', BusyProvider.google); - await (database.update( - database.accounts, - )..where((row) => row.id.equals('google:invalid'))).write( - const AccountsCompanion( - authState: Value(accountAuthStateReauthRequired), - ), - ); - await controller.reconcileConnectedAccount('google:invalid'); - expect(controller.state, same(original)); - expect(syncs, isEmpty); - }, - ); + test('missing, subscription and reconnect-required rows cannot manufacture a session', () async { + final original = controller.state; + await controller.reconcileConnectedAccount('missing'); + await persist('webcal:only', BusyProvider.webCal); + await controller.reconcileConnectedAccount('webcal:only'); + await persist('google:invalid', BusyProvider.google); + await (database.update( + database.accounts, + )..where((row) => row.id.equals('google:invalid'))).write( + const AccountsCompanion(authState: Value(accountAuthStateReauthRequired)), + ); + await controller.reconcileConnectedAccount('google:invalid'); + expect(controller.state, same(original)); + expect(syncs, isEmpty); + }); - test( - 'reconciliation repairs an invalid current account using the committed reconnection', - () async { - await persist('microsoft:old', BusyProvider.microsoft); - await controller.load(); - syncs.clear(); - await (database.update( - database.accounts, - )..where((row) => row.id.equals('microsoft:old'))).write( - const AccountsCompanion( - authState: Value(accountAuthStateReauthRequired), - ), - ); - await persist('nextcloud:connected', BusyProvider.nextcloud); - await controller.reconcileConnectedAccount('nextcloud:connected'); - expect(controller.state.accountId, 'nextcloud:connected'); - expect(syncs, isEmpty); - }, - ); + test('reconciliation repairs an invalid current account using the committed reconnection', () async { + await persist('microsoft:old', BusyProvider.microsoft); + await controller.load(); + syncs.clear(); + await (database.update( + database.accounts, + )..where((row) => row.id.equals('microsoft:old'))).write( + const AccountsCompanion(authState: Value(accountAuthStateReauthRequired)), + ); + await persist('nextcloud:connected', BusyProvider.nextcloud); + await controller.reconcileConnectedAccount('nextcloud:connected'); + expect(controller.state.accountId, 'nextcloud:connected'); + expect(syncs, isEmpty); + }); test('session-only reconciliation remains inert after disposal', () async { controller.dispose(); diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 14688330..151cb7e0 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -1,4 +1,5 @@ import '../../../support/desktop_connection_fixture.dart'; + import 'package:busymax/src/dav/auth/dav_account_onboarding_service.dart'; import 'package:busymax/src/dav/auth/nextcloud_login_flow_v2.dart'; import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; @@ -6,23 +7,30 @@ import 'package:busymax/src/providers/provider_capabilities.dart'; import 'package:busymax/src/microsoft_todo/oauth/microsoft_oauth_service.dart'; import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_models.dart'; import 'package:busymax/src/platform/common/desktop_services.dart'; + import '../../../support/desktop_activation_fixture.dart'; import '../../../support/memory_settings_store.dart'; + import 'package:busymax/src/platform/busymax_tray_service.dart'; import 'package:busymax/src/webcal/webcal_http_client.dart'; import 'package:busymax/src/webcal/webcal_subscription_service.dart'; import 'package:busymax/src/core/auth/authorization_attempt.dart'; import 'package:busymax/src/core/auth/registration_file_reader.dart'; + import '../../../support/native_registration_reader_fixture.dart'; + import 'dart:convert'; + import 'package:busymax/src/core/auth/authorization_persistence.dart'; import 'package:busymax/src/core/auth/oauth_registration.dart'; import 'package:busymax/src/core/auth/registration_staging.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_loopback_flow.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; + import 'dart:async'; import 'dart:io'; + import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; @@ -206,9 +214,9 @@ void main() { break; } } - final record = - await tester.runAsync(() => fixture.secrets.readCredential(id)) - as BoundOAuthSecretRecord; + final record = await tester.runAsync( + () => fixture.secrets.readCredential(id), + ) as BoundOAuthSecretRecord; expect( record.registration.summary().origin, method == DesktopConnectionMethod.busyMax @@ -274,9 +282,9 @@ void main() { break; } } - final replaced = - await tester.runAsync(() => fixture.secrets.readCredential(id)) - as BoundOAuthSecretRecord; + final replaced = await tester.runAsync( + () => fixture.secrets.readCredential(id), + ) as BoundOAuthSecretRecord; expect(replaced.generation, 3); expect(replaced.subject, record.subject); expect( @@ -632,64 +640,68 @@ void main() { }, ); - testWidgets('first Settings connection updates session before ICS activation', ( - tester, - ) async { - final activations = TestDesktopActivationService(); - addTearDown(activations.dispose); - late Directory directory; - await tester.runAsync(() async { - directory = await Directory.systemTemp.createTemp( - 'busymax-session-activation-', - ); - }); - addTearDown(() => directory.delete(recursive: true)); - final file = File('${directory.path}/fixture.ics'); - await tester.runAsync( - () => file.writeAsString( - 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//BusyMax Fixture//EN\r\nBEGIN:VEVENT\r\nUID:fixture-activation\r\nDTSTAMP:20261004T000000Z\r\nDTSTART:20301004T090000Z\r\nDTEND:20301004T100000Z\r\nSUMMARY:Controlled activation\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n', - ), - ); - await _pumpApp( - tester, - database: database, - oAuth: oAuth, - activations: activations, - settings: AppSettings.defaults().copyWith( - firstDayOfWeekPreference: BusyMaxFirstDayOfWeekPreference.monday, - showTrayIcon: false, - runInBackgroundWhenClosed: false, - ), - ); - await tester.pumpAndSettle(); - await _authorizeGoogleSetup(tester); - await tester.pumpAndSettle(); - final container = ProviderScope.containerOf( - tester.element(find.byType(SettingsScreen)), - ); - _expectExistingSessionSignedIn( - container.read(authSessionControllerProvider), - 'account-1', - ); - activations.add( - DesktopActivation(kind: DesktopActivationKind.icsFile, value: file.path), - ); - for ( - var attempt = 0; - attempt < 30 && - container.read(appRouterProvider).state.uri.path != '/schedule'; - attempt++ - ) { + testWidgets( + 'first Settings connection updates session before ICS activation', + (tester) async { + final activations = TestDesktopActivationService(); + addTearDown(activations.dispose); + late Directory directory; + await tester.runAsync(() async { + directory = await Directory.systemTemp.createTemp( + 'busymax-session-activation-', + ); + }); + addTearDown(() => directory.delete(recursive: true)); + final file = File('${directory.path}/fixture.ics'); await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), + () => file.writeAsString( + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//BusyMax Fixture//EN\r\nBEGIN:VEVENT\r\nUID:fixture-activation\r\nDTSTAMP:20261004T000000Z\r\nDTSTART:20301004T090000Z\r\nDTEND:20301004T100000Z\r\nSUMMARY:Controlled activation\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n', + ), ); - await tester.pump(const Duration(milliseconds: 20)); - } - expect(container.read(appRouterProvider).state.uri.path, '/schedule'); - expect(find.byType(SettingsScreen), findsNothing); - await tester.pumpAndSettle(); - await _disposeApp(tester); - }); + await _pumpApp( + tester, + database: database, + oAuth: oAuth, + activations: activations, + settings: AppSettings.defaults().copyWith( + firstDayOfWeekPreference: BusyMaxFirstDayOfWeekPreference.monday, + showTrayIcon: false, + runInBackgroundWhenClosed: false, + ), + ); + await tester.pumpAndSettle(); + await _authorizeGoogleSetup(tester); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ); + _expectExistingSessionSignedIn( + container.read(authSessionControllerProvider), + 'account-1', + ); + activations.add( + DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: file.path, + ), + ); + for ( + var attempt = 0; + attempt < 30 && + container.read(appRouterProvider).state.uri.path != '/schedule'; + attempt++ + ) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(container.read(appRouterProvider).state.uri.path, '/schedule'); + expect(find.byType(SettingsScreen), findsNothing); + await tester.pumpAndSettle(); + await _disposeApp(tester); + }, + ); testWidgets('signed-out startup opens calendar and Accounts from sidebar', ( tester, @@ -706,9 +718,9 @@ void main() { expect(find.byType(SettingsScreen), findsOneWidget); expect(router.canPop(), isTrue); expect( - GoRouterState.of( - tester.element(find.byType(SettingsScreen)), - ).uri.queryParameters['page'], + GoRouterState.of(tester.element(find.byType(SettingsScreen))) + .uri + .queryParameters['page'], 'accounts', ); for (final label in [ @@ -955,9 +967,8 @@ void main() { ); final router = GoRouter.of(tester.element(find.byType(ScheduleWorkspace))); router.go( - Uri( - pathSegments: ['', 'tasks', accountId, 'list-1', 'task-1'], - ).toString(), + Uri(pathSegments: ['', 'tasks', accountId, 'list-1', 'task-1']) + .toString(), ); await tester.pumpAndSettle(); @@ -975,9 +986,8 @@ void main() { await tester.enterText(titleField, 'Unsaved route title'); await tester.pump(); router.go( - Uri( - pathSegments: ['', 'tasks', accountId, 'list-1', 'missing-task'], - ).toString(), + Uri(pathSegments: ['', 'tasks', accountId, 'list-1', 'missing-task']) + .toString(), ); await tester.pump(); expect( @@ -1406,49 +1416,50 @@ void main() { }, ); - testWidgets('account add failure stays in Settings and preserves the session', ( - tester, - ) async { - await _insertAccount( - database, - id: 'microsoft:existing', - provider: BusyProvider.microsoft, - ); - oAuth.signInError = const OAuthException( - 'OAuthCallbackTimeout', - 'raw timeout', - ); - await _pumpApp(tester, database: database, oAuth: oAuth); - await tester.pumpAndSettle(); - await _openSettings(tester); - final container = ProviderScope.containerOf( - tester.element(find.byType(SettingsScreen)), - ); + testWidgets( + 'account add failure stays in Settings and preserves the session', + (tester) async { + await _insertAccount( + database, + id: 'microsoft:existing', + provider: BusyProvider.microsoft, + ); + oAuth.signInError = const OAuthException( + 'OAuthCallbackTimeout', + 'raw timeout', + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + await _openSettings(tester); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ); - await _authorizeGoogleSetup(tester); - await tester.pumpAndSettle(); + await _authorizeGoogleSetup(tester); + await tester.pumpAndSettle(); - expect(oAuth.signInCalls, 1); - expect(find.byType(SettingsScreen), findsOneWidget); - expect( - find.text( - 'Authorization timed out. Try again; select a fresh configuration if it was imported.', - ), - findsOneWidget, - ); - expect(find.textContaining('raw timeout'), findsNothing); - _expectExistingSessionSignedIn( - container.read(authSessionControllerProvider), - 'microsoft:existing', - ); - expect( - (await tester.runAsync( - () => database.select(database.accounts).getSingle(), - ))!.authState, - accountAuthStateSignedIn, - ); - await _disposeApp(tester); - }); + expect(oAuth.signInCalls, 1); + expect(find.byType(SettingsScreen), findsOneWidget); + expect( + find.text( + 'Authorization timed out. Try again; select a fresh configuration if it was imported.', + ), + findsOneWidget, + ); + expect(find.textContaining('raw timeout'), findsNothing); + _expectExistingSessionSignedIn( + container.read(authSessionControllerProvider), + 'microsoft:existing', + ); + expect( + (await tester.runAsync( + () => database.select(database.accounts).getSingle(), + ))!.authState, + accountAuthStateSignedIn, + ); + await _disposeApp(tester); + }, + ); testWidgets( 'reconnecting an account keeps the existing session and Settings route', @@ -1527,9 +1538,9 @@ Future _sendAltLeft(WidgetTester tester) async { Future _disposeApp(WidgetTester tester) async { // Mock gateways do not consume registration handles like production OAuth. - ProviderScope.containerOf( - tester.element(find.byType(BusyMaxApp)), - ).read(registrationStagingProvider).cancel(); + ProviderScope.containerOf(tester.element(find.byType(BusyMaxApp))) + .read(registrationStagingProvider) + .cancel(); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(milliseconds: 1)); } diff --git a/test/features/auth/presentation/registration_setup_dialog_test.dart b/test/features/auth/presentation/registration_setup_dialog_test.dart index eb2ae215..57e2e6c4 100644 --- a/test/features/auth/presentation/registration_setup_dialog_test.dart +++ b/test/features/auth/presentation/registration_setup_dialog_test.dart @@ -1,4 +1,5 @@ import '../../../support/desktop_registration_config.dart'; + import 'dart:async'; import 'dart:io'; @@ -364,9 +365,8 @@ void main() { expect(tester.getTopLeft(_key('back')), headerPosition); await tester.tap(_key('copy-all')); await tester.pumpAndSettle(); - final steps = lookupAppLocalizations( - const Locale('en'), - ).registrationDesktopSteps(BusyProvider.microsoft); + final steps = lookupAppLocalizations(const Locale('en')) + .registrationDesktopSteps(BusyProvider.microsoft); expect(copied, steps.singleWhere((step) => step.copyAll).values); // Back and its shortcut stay inside the one modal, preserving controllers. await tester.tap(_key('back')); @@ -437,9 +437,9 @@ void main() { connect(); await tester.pumpAndSettle(); expect(harness.results, hasLength(1)); - final registration = - staging.consume(harness.results.single!) - as MicrosoftPublicRegistration; + final registration = staging.consume( + harness.results.single!, + ) as MicrosoftPublicRegistration; expect(registration.clientId, _clientId); expect(registration.tenantId, isNull); await tester.pumpWidget(const SizedBox.shrink()); @@ -456,9 +456,8 @@ void main() { addTearDown(() => directory.delete(recursive: true)); file = File('${directory.path}/desktop.json'); await file.writeAsString( - await File( - 'test/fixtures/oauth/desktop_synthetic.json', - ).readAsString(), + await File('test/fixtures/oauth/desktop_synthetic.json') + .readAsString(), ); staging.dispose(); staging = RegistrationStaging( @@ -524,9 +523,8 @@ void main() { addTearDown(() => directory.delete(recursive: true)); file = File('${directory.path}/desktop.json'); await file.writeAsString( - await File( - 'test/fixtures/oauth/desktop_synthetic.json', - ).readAsString(), + await File('test/fixtures/oauth/desktop_synthetic.json') + .readAsString(), ); staging.dispose(); staging = RegistrationStaging( diff --git a/test/features/calendar/data/calendar_collection_creation_service_test.dart b/test/features/calendar/data/calendar_collection_creation_service_test.dart index 67ce2865..6bcb40f8 100644 --- a/test/features/calendar/data/calendar_collection_creation_service_test.dart +++ b/test/features/calendar/data/calendar_collection_creation_service_test.dart @@ -118,9 +118,8 @@ void main() { await _seedAccount(database, BusyProvider.nextcloud); await expectLater( - CalendarRepository( - database: database, - ).createLocalSource(accountId: 'account', summary: 'Wrong route'), + CalendarRepository(database: database) + .createLocalSource(accountId: 'account', summary: 'Wrong route'), throwsA(isA()), ); expect(await database.select(database.pendingOps).get(), isEmpty); diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index 3b8cf906..9c823a4a 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -62,66 +62,60 @@ void main() { await database.close(); }); - test( - 'reschedule persists once, retains latest unrelated fields and refreshes reminders offline', - () async { - await _seedScheduledEvent(repository, database); - final event = await database.select(database.calendarEvents).getSingle(); - final detail = (await repository.loadEventDetail(event.id))!; - final request = _timingRequest(detail); - await database - .update(database.calendarEvents) - .write( - const CalendarEventsCompanion( - title: Value('Concurrent rename'), - description: Value('

Keep full HTML

'), - attachmentsJson: Value('[{"id":"keep"}]'), - conferenceJson: Value( - '{"entryPoints":[{"uri":"https://example.com/meeting"}]}', - ), + test('reschedule persists once, retains latest unrelated fields and refreshes reminders offline', () async { + await _seedScheduledEvent(repository, database); + final event = await database.select(database.calendarEvents).getSingle(); + final detail = (await repository.loadEventDetail(event.id))!; + final request = _timingRequest(detail); + await database + .update(database.calendarEvents) + .write( + const CalendarEventsCompanion( + title: Value('Concurrent rename'), + description: Value('

Keep full HTML

'), + attachmentsJson: Value('[{"id":"keep"}]'), + conferenceJson: Value( + '{"entryPoints":[{"uri":"https://example.com/meeting"}]}', ), - ); - var syncRequests = 0; - final coordinator = ScheduleReschedulingCoordinator( - repository: repository, - chooseScope: (_, _) async => throw StateError('Not recurring'), - chooseGuestUpdates: (_) async => throw StateError('No guests'), - requestSync: (account) async { - expect(account, detail.accountId); - syncRequests++; - }, - ); - expect(await coordinator.commit(request), ScheduleRescheduleResult.saved); - expect( - await coordinator.commit(request), - ScheduleRescheduleResult.cancelled, - ); - final saved = await database.select(database.calendarEvents).getSingle(); - expect( - DateTime.parse(saved.startDateTime!), - DateTime.utc(2026, 6, 8, 11), - ); - expect(DateTime.parse(saved.endDateTime!), DateTime.utc(2026, 6, 8, 12)); - expect(saved.title, 'Concurrent rename'); - expect(saved.description, '

Keep full HTML

'); - expect(saved.attachmentsJson, '[{"id":"keep"}]'); - expect(saved.conferenceJson, contains('https://example.com/meeting')); - expect(saved.calendarSourceId, detail.sourceId); - expect(saved.providerCalendarId, detail.providerCalendarId); - expect(saved.accountId, detail.accountId); - final pending = await database.select(database.pendingOps).getSingle(); - final body = jsonDecode(pending.requestJson) as Map; - expect(body.containsKey('title'), isFalse); - expect(body.containsKey('description'), isFalse); - expect(body.containsKey('attendeesJson'), isFalse); - expect(syncRequests, 1); - expect( - (await database.select(database.notificationSchedule).getSingle()) - .scheduledAtUtc, - DateTime.utc(2026, 6, 8, 10, 50).millisecondsSinceEpoch, - ); - }, - ); + ), + ); + var syncRequests = 0; + final coordinator = ScheduleReschedulingCoordinator( + repository: repository, + chooseScope: (_, _) async => throw StateError('Not recurring'), + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (account) async { + expect(account, detail.accountId); + syncRequests++; + }, + ); + expect(await coordinator.commit(request), ScheduleRescheduleResult.saved); + expect( + await coordinator.commit(request), + ScheduleRescheduleResult.cancelled, + ); + final saved = await database.select(database.calendarEvents).getSingle(); + expect(DateTime.parse(saved.startDateTime!), DateTime.utc(2026, 6, 8, 11)); + expect(DateTime.parse(saved.endDateTime!), DateTime.utc(2026, 6, 8, 12)); + expect(saved.title, 'Concurrent rename'); + expect(saved.description, '

Keep full HTML

'); + expect(saved.attachmentsJson, '[{"id":"keep"}]'); + expect(saved.conferenceJson, contains('https://example.com/meeting')); + expect(saved.calendarSourceId, detail.sourceId); + expect(saved.providerCalendarId, detail.providerCalendarId); + expect(saved.accountId, detail.accountId); + final pending = await database.select(database.pendingOps).getSingle(); + final body = jsonDecode(pending.requestJson) as Map; + expect(body.containsKey('title'), isFalse); + expect(body.containsKey('description'), isFalse); + expect(body.containsKey('attendeesJson'), isFalse); + expect(syncRequests, 1); + expect( + (await database.select(database.notificationSchedule).getSingle()) + .scheduledAtUtc, + DateTime.utc(2026, 6, 8, 10, 50).millisecondsSinceEpoch, + ); + }); test( 'regular event with stale recurrence metadata moves without a scope prompt', @@ -134,204 +128,186 @@ void main() { }, ); - test( - 'regular event with missing payload and stale recurrence metadata moves without a scope prompt', - () async { - await _expectStaleRegularEventMovesWithoutScope( - repository, - database, - rawJson: '{}', - ); - }, - ); + test('regular event with missing payload and stale recurrence metadata moves without a scope prompt', () async { + await _expectStaleRegularEventMovesWithoutScope( + repository, + database, + rawJson: '{}', + ); + }); - test( - 'recovery baseline does not promote stale regular-event metadata to recurrence', - () async { - const start = '2026-06-08T09:00:00.000Z'; - const end = '2026-06-08T10:00:00.000Z'; - await _upsertSource(repository); - await repository.upsertEvent( - accountId: 'google:g', - event: const CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: 'calendar-1', - providerEventId: 'regular-recovery-event', - providerRecurringEventId: 'stale-series-id', - providerOriginalStartKey: start, - title: 'Regular event', - organizerJson: {'self': true}, - startDateTime: start, - startTimeZone: 'UTC', - endDateTime: end, - endTimeZone: 'UTC', - rawJson: { - 'id': 'regular-recovery-event', - 'summary': 'Regular event', - 'organizer': {'self': true}, - 'start': {'dateTime': start, 'timeZone': 'UTC'}, - 'end': {'dateTime': end, 'timeZone': 'UTC'}, - }, - ), - ); - final eventId = CalendarRepository.eventId( - accountId: 'google:g', + test('recovery baseline does not promote stale regular-event metadata to recurrence', () async { + const start = '2026-06-08T09:00:00.000Z'; + const end = '2026-06-08T10:00:00.000Z'; + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( provider: BusyProvider.google, providerCalendarId: 'calendar-1', providerEventId: 'regular-recovery-event', + providerRecurringEventId: 'stale-series-id', providerOriginalStartKey: start, - ); - final initial = (await repository.loadEventDetail(eventId))!; - expect(initial.requiresRecurringMutationScope, isFalse); + title: 'Regular event', + organizerJson: {'self': true}, + startDateTime: start, + startTimeZone: 'UTC', + endDateTime: end, + endTimeZone: 'UTC', + rawJson: { + 'id': 'regular-recovery-event', + 'summary': 'Regular event', + 'organizer': {'self': true}, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': end, 'timeZone': 'UTC'}, + }, + ), + ); + final eventId = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'regular-recovery-event', + providerOriginalStartKey: start, + ); + final initial = (await repository.loadEventDetail(eventId))!; + expect(initial.requiresRecurringMutationScope, isFalse); - await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - initial, - ).copyWith(title: 'Optimistic title'), - ); - final discardedOperation = await database - .select(database.pendingOps) - .getSingle(); - await repository.restoreEventAfterMutationDiscard(discardedOperation); - await database.pendingOpsDao.deleteOp(discardedOperation.id); - - final restored = (await repository.loadEventDetail(eventId))!; - expect(restored.providerRecurringEventId, equals(null)); - expect(restored.requiresRecurringMutationScope, isFalse); - expect((restored.raw as Map).containsKey('recurringEventId'), isFalse); - - final item = (await ScheduleRepository(database).listItems( - range: ScheduleRange.day(DateTime(2026, 6, 8)), - )).whereType().single; - var scopeRequests = 0; - final result = - await ScheduleReschedulingCoordinator( - repository: repository, - chooseScope: (_, _) async { - scopeRequests++; - return RecurringEventMutationScope.singleOccurrence; - }, - chooseGuestUpdates: (_) async => throw StateError('No guests'), - requestSync: (_) async {}, - ).commit( - ScheduleRescheduleRequest( - item: item, - interval: ScheduleInterval( - item.start!.add(const Duration(minutes: 15)), - item.end!.add(const Duration(minutes: 15)), - ), + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(initial) + .copyWith(title: 'Optimistic title'), + ); + final discardedOperation = await database + .select(database.pendingOps) + .getSingle(); + await repository.restoreEventAfterMutationDiscard(discardedOperation); + await database.pendingOpsDao.deleteOp(discardedOperation.id); + + final restored = (await repository.loadEventDetail(eventId))!; + expect(restored.providerRecurringEventId, equals(null)); + expect(restored.requiresRecurringMutationScope, isFalse); + expect((restored.raw as Map).containsKey('recurringEventId'), isFalse); + + final item = (await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 8)), + )).whereType().single; + var scopeRequests = 0; + final result = + await ScheduleReschedulingCoordinator( + repository: repository, + chooseScope: (_, _) async { + scopeRequests++; + return RecurringEventMutationScope.singleOccurrence; + }, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval( + item.start!.add(const Duration(minutes: 15)), + item.end!.add(const Duration(minutes: 15)), ), - ); - expect(result, ScheduleRescheduleResult.saved); - expect(scopeRequests, 0); - final request = - jsonDecode( - (await database.select(database.pendingOps).getSingle()) - .requestJson, - ) - as Map; - expect(request.containsKey(calendarEventRecurringScopeKey), isFalse); - }, - ); + ), + ); + expect(result, ScheduleRescheduleResult.saved); + expect(scopeRequests, 0); + final request = jsonDecode( + (await database.select(database.pendingOps).getSingle()).requestJson, + ) as Map; + expect(request.containsKey(calendarEventRecurringScopeKey), isFalse); + }); - test( - 'Tokyo series loads through ScheduleRepository and reschedules across the host DST gap', - () async { - final hostZone = ProcessTimeZone(); - hostZone.set('America/Vancouver'); - addTearDown(hostZone.restore); - expect(DateTime(2026, 3, 8, 2, 30).hour, isNot(2)); - await _upsertSource(repository); - await _upsertGoogleSeriesMaster( - repository, - database, - seriesId: 'tokyo-series', + test('Tokyo series loads through ScheduleRepository and reschedules across the host DST gap', () async { + final hostZone = ProcessTimeZone(); + hostZone.set('America/Vancouver'); + addTearDown(hostZone.restore); + expect(DateTime(2026, 3, 8, 2, 30).hour, isNot(2)); + await _upsertSource(repository); + await _upsertGoogleSeriesMaster( + repository, + database, + seriesId: 'tokyo-series', + title: 'Tokyo meeting', + start: '2026-03-01T02:30:00', + end: '2026-03-01T03:30:00', + timeZone: 'Asia/Tokyo', + ); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'tokyo-occurrence', + providerRecurringEventId: 'tokyo-series', + providerOriginalStartKey: '2026-03-08T02:30:00+09:00', title: 'Tokyo meeting', - start: '2026-03-01T02:30:00', - end: '2026-03-01T03:30:00', - timeZone: 'Asia/Tokyo', - ); - await repository.upsertEvent( - accountId: 'google:g', - event: const CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: 'calendar-1', - providerEventId: 'tokyo-occurrence', - providerRecurringEventId: 'tokyo-series', - providerOriginalStartKey: '2026-03-08T02:30:00+09:00', - title: 'Tokyo meeting', - organizerJson: {'self': true}, - startDateTime: '2026-03-08T02:30:00', - endDateTime: '2026-03-08T03:30:00', - startTimeZone: 'Asia/Tokyo', - endTimeZone: 'Asia/Tokyo', - rawJson: { - 'id': 'tokyo-occurrence', - 'recurringEventId': 'tokyo-series', - 'originalStartTime': {'dateTime': '2026-03-08T02:30:00+09:00'}, - 'start': { - 'dateTime': '2026-03-08T02:30:00', - 'timeZone': 'Asia/Tokyo', - }, - 'end': { - 'dateTime': '2026-03-08T03:30:00', - 'timeZone': 'Asia/Tokyo', - }, + organizerJson: {'self': true}, + startDateTime: '2026-03-08T02:30:00', + endDateTime: '2026-03-08T03:30:00', + startTimeZone: 'Asia/Tokyo', + endTimeZone: 'Asia/Tokyo', + rawJson: { + 'id': 'tokyo-occurrence', + 'recurringEventId': 'tokyo-series', + 'originalStartTime': {'dateTime': '2026-03-08T02:30:00+09:00'}, + 'start': { + 'dateTime': '2026-03-08T02:30:00', + 'timeZone': 'Asia/Tokyo', }, - ), + 'end': {'dateTime': '2026-03-08T03:30:00', 'timeZone': 'Asia/Tokyo'}, + }, + ), + ); + final schedule = ScheduleRepository(database); + Future displayed() async => (await schedule.listItems( + range: ScheduleRange.day(DateTime(2026, 3, 7)), + )).whereType().single; + final item = await displayed(); + expect(item.start, DateTime(2026, 3, 7, 9, 30)); + expect(item.end, DateTime(2026, 3, 7, 10, 30)); + expect(item.end!.difference(item.start!), const Duration(hours: 1)); + expect(item.canReschedule, isTrue); + final coordinator = ScheduleReschedulingCoordinator( + repository: repository, + chooseScope: (_, _) async => RecurringEventMutationScope.entireSeries, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ); + // Move within the written host-gap hour. Series projection persists 02:45 + // with a separate Tokyo zone, which must still display as 09:45 Vancouver. + Future move(CalendarScheduleItem source) async { + final interval = const ScheduleTimeMath().change( + original: ScheduleInterval(source.start!, source.end!), + action: ScheduleTimingAction.move, + anchor: source.start!, + pointer: source.start!.add(const Duration(minutes: 15)), ); - final schedule = ScheduleRepository(database); - Future displayed() async => - (await schedule.listItems( - range: ScheduleRange.day(DateTime(2026, 3, 7)), - )).whereType().single; - final item = await displayed(); - expect(item.start, DateTime(2026, 3, 7, 9, 30)); - expect(item.end, DateTime(2026, 3, 7, 10, 30)); - expect(item.end!.difference(item.start!), const Duration(hours: 1)); - expect(item.canReschedule, isTrue); - final coordinator = ScheduleReschedulingCoordinator( - repository: repository, - chooseScope: (_, _) async => RecurringEventMutationScope.entireSeries, - chooseGuestUpdates: (_) async => throw StateError('No guests'), - requestSync: (_) async {}, + expect( + await coordinator.commit( + ScheduleRescheduleRequest(item: source, interval: interval), + ), + ScheduleRescheduleResult.saved, ); - // Move within the written host-gap hour. Series projection persists 02:45 - // with a separate Tokyo zone, which must still display as 09:45 Vancouver. - Future move(CalendarScheduleItem source) async { - final interval = const ScheduleTimeMath().change( - original: ScheduleInterval(source.start!, source.end!), - action: ScheduleTimingAction.move, - anchor: source.start!, - pointer: source.start!.add(const Duration(minutes: 15)), - ); - expect( - await coordinator.commit( - ScheduleRescheduleRequest(item: source, interval: interval), - ), - ScheduleRescheduleResult.saved, - ); - } - - await move(item); - final stored = (await repository.loadEventDetail(item.id))!; - expect(stored.startDateTime, '2026-03-08T02:45:00.000'); - expect(stored.endDateTime, '2026-03-08T03:45:00.000'); - expect(stored.startTimeZone, 'Asia/Tokyo'); - expect(stored.endTimeZone, 'Asia/Tokyo'); - expect(stored.syncStatus, 'pending'); - final updated = await displayed(); - expect(updated.start, DateTime(2026, 3, 7, 9, 45)); - expect(updated.end, DateTime(2026, 3, 7, 10, 45)); - expect(updated.canReschedule, isTrue); - await move(updated); - final movedAgain = await displayed(); - expect(movedAgain.start, DateTime(2026, 3, 7, 10)); - expect(movedAgain.end, DateTime(2026, 3, 7, 11)); - expect(await database.select(database.pendingOps).get(), hasLength(2)); - }, - skip: !(Platform.isLinux || Platform.isMacOS), - ); + } + + await move(item); + final stored = (await repository.loadEventDetail(item.id))!; + expect(stored.startDateTime, '2026-03-08T02:45:00.000'); + expect(stored.endDateTime, '2026-03-08T03:45:00.000'); + expect(stored.startTimeZone, 'Asia/Tokyo'); + expect(stored.endTimeZone, 'Asia/Tokyo'); + expect(stored.syncStatus, 'pending'); + final updated = await displayed(); + expect(updated.start, DateTime(2026, 3, 7, 9, 45)); + expect(updated.end, DateTime(2026, 3, 7, 10, 45)); + expect(updated.canReschedule, isTrue); + await move(updated); + final movedAgain = await displayed(); + expect(movedAgain.start, DateTime(2026, 3, 7, 10)); + expect(movedAgain.end, DateTime(2026, 3, 7, 11)); + expect(await database.select(database.pendingOps).get(), hasLength(2)); + }, skip: !(Platform.isLinux || Platform.isMacOS)); test( 'zoned whole-series drag preserves wall delta and occurrence fields', @@ -386,12 +362,9 @@ void main() { expect((await repository.loadEventDetail(ids.last))!.conference, { 'occurrence': 'keep', }); - final body = - jsonDecode( - (await database.select(database.pendingOps).getSingle()) - .requestJson, - ) - as Map; + final body = jsonDecode( + (await database.select(database.pendingOps).getSingle()).requestJson, + ) as Map; expect(body.containsKey('conferenceJson'), isFalse); }, ); @@ -859,72 +832,67 @@ void main() { }, ); - test( - 'Nextcloud administration never substitutes a local mutation when unavailable', - () async { - await database - .into(database.accounts) - .insert( - AccountsCompanion.insert( - id: 'nextcloud:n', - provider: 'nextcloud', - authority: 'https://cloud.example.test', - providerAccountId: 'n', - credentialKind: 'nextcloud_app_password', - authState: const Value('signed_in'), - grantedScopes: const Value(''), - createdAtUtc: '2026-06-08T00:00:00.000Z', - updatedAtUtc: '2026-06-08T00:00:00.000Z', - ), - ); - await expectLater( - repository.createLocalSource( - accountId: 'nextcloud:n', - summary: 'New calendar', - ), - throwsA( - isA().having( - (error) => error.operation, - 'operation', - CalendarMutationOperation.createCalendar, + test('Nextcloud administration never substitutes a local mutation when unavailable', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'nextcloud:n', + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'n', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', ), - ), - ); - await repository.upsertSource( + ); + await expectLater( + repository.createLocalSource( accountId: 'nextcloud:n', - source: const CalendarSourceDto( - provider: BusyProvider.nextcloud, - providerCalendarId: '/calendars/n/work/', - summary: 'Work', + summary: 'New calendar', + ), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.createCalendar, ), - ); - final source = await database - .select(database.calendarSources) - .getSingle(); + ), + ); + await repository.upsertSource( + accountId: 'nextcloud:n', + source: const CalendarSourceDto( + provider: BusyProvider.nextcloud, + providerCalendarId: '/calendars/n/work/', + summary: 'Work', + ), + ); + final source = await database.select(database.calendarSources).getSingle(); - await expectLater( - repository.renameLocalSource(source.id, 'Renamed'), - throwsA( - isA().having( - (error) => error.operation, - 'operation', - CalendarMutationOperation.renameCalendar, - ), + await expectLater( + repository.renameLocalSource(source.id, 'Renamed'), + throwsA( + isA().having( + (error) => error.operation, + 'operation', + CalendarMutationOperation.renameCalendar, ), - ); - await expectLater( - repository.deleteLocalSource(source.id), - throwsUnsupportedError, - ); + ), + ); + await expectLater( + repository.deleteLocalSource(source.id), + throwsUnsupportedError, + ); - final unchanged = await database - .select(database.calendarSources) - .getSingle(); - expect(unchanged.summary, 'Work'); - expect(unchanged.isDeleted, isFalse); - expect(await database.select(database.pendingOps).get(), isEmpty); - }, - ); + final unchanged = await database + .select(database.calendarSources) + .getSingle(); + expect(unchanged.summary, 'Work'); + expect(unchanged.isDeleted, isFalse); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); test( 'event queue preserves guest delivery and Meet creation intent', @@ -1211,9 +1179,8 @@ void main() { await expectLater( repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail!, - ).copyWith(title: 'Unauthorized edit'), + EventEditorDraft.fromEventDetail(detail!) + .copyWith(title: 'Unauthorized edit'), ), throwsA(isA()), ); @@ -1392,9 +1359,8 @@ void main() { expect((reopened.raw as Map)['eventLabelId'], 'new-label'); expect((reopened.raw as Map)['unrelated'], {'keep': true}); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - reopened, - ).copyWith(clearEventLabelId: true), + EventEditorDraft.fromEventDetail(reopened) + .copyWith(clearEventLabelId: true), ); ops = await database.select(database.pendingOps).get(); expect(jsonDecode(ops.last.requestJson)['eventLabelId'], ''); @@ -1511,13 +1477,9 @@ void main() { (await repository.loadEventDetail(id))!, ).copyWith(title: 'Focus renamed'), ); - var request = - jsonDecode( - (await database.select(database.pendingOps).get()) - .last - .requestJson, - ) - as Map; + var request = jsonDecode( + (await database.select(database.pendingOps).get()).last.requestJson, + ) as Map; expect(request, isNot(contains('googleStatusProperties'))); expect(request, isNot(contains(calendarEventGoogleStatusTypeContextKey))); await repository.updateLocalEvent( @@ -1530,13 +1492,9 @@ void main() { }, ), ); - request = - jsonDecode( - (await database.select(database.pendingOps).get()) - .last - .requestJson, - ) - as Map; + request = jsonDecode( + (await database.select(database.pendingOps).get()).last.requestJson, + ) as Map; expect(request[calendarEventGoogleStatusTypeContextKey], 'focusTime'); expect(request, isNot(contains('eventType'))); expect(request, isNot(contains('providerRaw'))); @@ -1610,13 +1568,9 @@ void main() { await repository.updateLocalEvent(initial); expect(await database.select(database.pendingOps).get(), isEmpty); await repository.updateLocalEvent(initial.copyWith(colorId: '2')); - var request = - jsonDecode( - (await database.select(database.pendingOps).get()) - .last - .requestJson, - ) - as Map; + var request = jsonDecode( + (await database.select(database.pendingOps).get()).last.requestJson, + ) as Map; expect(request['colorId'], '2'); expect(request, isNot(contains('eventType'))); await repository.updateLocalEvent( @@ -1632,13 +1586,9 @@ void main() { remindersChanged: true, ), ); - request = - jsonDecode( - (await database.select(database.pendingOps).get()) - .last - .requestJson, - ) - as Map; + request = jsonDecode( + (await database.select(database.pendingOps).get()).last.requestJson, + ) as Map; expect((request['remindersJson'] as Map)['useDefault'], false); final count = (await database.select(database.pendingOps).get()).length; await expectLater( @@ -2413,9 +2363,8 @@ void main() { final selection = LocationResult(label: 'Café & Hall', point: point); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail, - ).copyWith(locationChange: LocationChange.replace(selection)), + EventEditorDraft.fromEventDetail(detail) + .copyWith(locationChange: LocationChange.replace(selection)), ); final after = await database.select(database.calendarEvents).getSingle(); @@ -2770,11 +2719,8 @@ Future _expectStaleRegularEventMovesWithoutScope( expect(result, ScheduleRescheduleResult.saved); expect(scopeRequests, 0); - final request = - jsonDecode( - (await database.select(database.pendingOps).getSingle()) - .requestJson, - ) - as Map; + final request = jsonDecode( + (await database.select(database.pendingOps).getSingle()).requestJson, + ) as Map; expect(request.containsKey(calendarEventRecurringScopeKey), isFalse); } diff --git a/test/features/calendar/domain/calendar_management_capabilities_test.dart b/test/features/calendar/domain/calendar_management_capabilities_test.dart index dc45ff87..f6641b6a 100644 --- a/test/features/calendar/domain/calendar_management_capabilities_test.dart +++ b/test/features/calendar/domain/calendar_management_capabilities_test.dart @@ -89,34 +89,28 @@ void main() { expect(source.capabilities.canDeleteCalendar, isTrue); }); - test( - 'shared read-only Google calendar permits personal name, color, and removal', - () { - const source = CalendarSourceEntity( - id: 'shared-calendar', - accountId: 'google-account', - provider: BusyProvider.google, - providerCalendarId: 'shared@example.com', - summary: 'Shared', - selected: true, - hidden: false, - readOnly: true, - isDeleted: false, - dataOwner: 'other@example.com', - authenticatedAccountEmail: 'me@example.com', - ); + test('shared read-only Google calendar permits personal name, color, and removal', () { + const source = CalendarSourceEntity( + id: 'shared-calendar', + accountId: 'google-account', + provider: BusyProvider.google, + providerCalendarId: 'shared@example.com', + summary: 'Shared', + selected: true, + hidden: false, + readOnly: true, + isDeleted: false, + dataOwner: 'other@example.com', + authenticatedAccountEmail: 'me@example.com', + ); - expect(source.capabilities.canCreateEvents, isFalse); - expect(source.capabilities.renameMode, CalendarRenameMode.personal); - expect( - source.capabilities.removalMode, - CalendarRemovalMode.removeFromList, - ); - expect(source.capabilities.canDeleteCalendar, isFalse); - expect(source.capabilities.canRemoveCalendar, isTrue); - expect(source.capabilities.canChangeCalendarColor, isTrue); - }, - ); + expect(source.capabilities.canCreateEvents, isFalse); + expect(source.capabilities.renameMode, CalendarRenameMode.personal); + expect(source.capabilities.removalMode, CalendarRemovalMode.removeFromList); + expect(source.capabilities.canDeleteCalendar, isFalse); + expect(source.capabilities.canRemoveCalendar, isTrue); + expect(source.capabilities.canChangeCalendarColor, isTrue); + }); test('Microsoft deletion follows isRemovable rather than canEdit', () { const removableReadOnly = CalendarSourceEntity( diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 7ef475d2..dcfe5530 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -115,9 +115,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - calendarRemoteApiClientForAccountProvider( - 'account', - ).overrideWithValue(client), + calendarRemoteApiClientForAccountProvider('account') + .overrideWithValue(client), ], child: localizedTestApp( theme: BusyMaxYaruTheme.build( @@ -178,9 +177,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - calendarRemoteApiClientForAccountProvider( - 'account', - ).overrideWithValue(client), + calendarRemoteApiClientForAccountProvider('account') + .overrideWithValue(client), ], child: localizedTestApp( theme: BusyMaxYaruTheme.build( @@ -321,9 +319,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - calendarRemoteApiClientForAccountProvider( - 'account', - ).overrideWithValue(client), + calendarRemoteApiClientForAccountProvider('account') + .overrideWithValue(client), ], child: localizedTestApp( child: Scaffold( @@ -505,9 +502,9 @@ void main() { expect(saveButton.style?.minimumSize, isNull); final cancelContext = tester.element(find.text('Cancel')); expect( - Theme.of( - cancelContext, - ).filledButtonTheme.style?.textStyle?.resolve(const {})?.fontWeight, + Theme.of(cancelContext).filledButtonTheme.style?.textStyle + ?.resolve(const {}) + ?.fontWeight, FontWeight.bold, ); }); diff --git a/test/features/diagnostics/presentation/diagnostics_screen_test.dart b/test/features/diagnostics/presentation/diagnostics_screen_test.dart index 52870fb5..69f80f75 100644 --- a/test/features/diagnostics/presentation/diagnostics_screen_test.dart +++ b/test/features/diagnostics/presentation/diagnostics_screen_test.dart @@ -105,11 +105,9 @@ Future _pumpDiagnostics( } void _selectMenuAction(WidgetTester tester, String label) { - final menu = - tester.widget( - find.byWidgetPredicate((widget) => widget is BusyMaxMenuButton), - ) - as dynamic; + final menu = tester.widget( + find.byWidgetPredicate((widget) => widget is BusyMaxMenuButton), + ) as dynamic; final entry = (menu.entries as List).singleWhere( (candidate) => candidate.label == label, ); diff --git a/test/features/maps/location_resolution_repository_test.dart b/test/features/maps/location_resolution_repository_test.dart index acaf00c3..1bfa39ea 100644 --- a/test/features/maps/location_resolution_repository_test.dart +++ b/test/features/maps/location_resolution_repository_test.dart @@ -124,9 +124,8 @@ void main() { final pendingBefore = await database.select(database.pendingOps).get(); final launched = []; - final destination = await LocationDestinationResolver( - repository, - ).resolveSaved(location: 'Hall', identity: identity); + final destination = await LocationDestinationResolver(repository) + .resolveSaved(location: 'Hall', identity: identity); final result = await ExternalLocationLauncher( platform: () => ExternalLocationPlatform.windows, launcher: (uri, {mode = LaunchMode.platformDefault}) async { @@ -161,13 +160,11 @@ void main() { source: 'legacy-provider', attribution: 'City result attribution', ); - await LocationResolutionRepository( - database, - ).apply(identity, 'Hall', LocationChange.replace(coarse)); + await LocationResolutionRepository(database) + .apply(identity, 'Hall', LocationChange.replace(coarse)); - final remembered = await LocationResolutionRepository( - database, - ).load(identity, 'Hall'); + final remembered = await LocationResolutionRepository(database) + .load(identity, 'Hall'); expect(remembered?.source, 'legacy-provider'); expect(remembered?.attribution, 'City result attribution'); @@ -659,9 +656,9 @@ void main() { title: 'Occurrence', location: location, startDateTime: start, - endDateTime: DateTime.parse( - start, - ).add(const Duration(hours: 1)).toIso8601String(), + endDateTime: DateTime.parse(start) + .add(const Duration(hours: 1)) + .toIso8601String(), isCancelled: cancelled, ), ); @@ -819,9 +816,8 @@ void main() { ), ); - final destination = await LocationDestinationResolver( - repository, - ).resolveSaved(location: '', identity: occurrence); + final destination = await LocationDestinationResolver(repository) + .resolveSaved(location: '', identity: occurrence); expect(destination?.point, _selection.point); }); diff --git a/test/features/notifications/notification_cancellation_queue_test.dart b/test/features/notifications/notification_cancellation_queue_test.dart index aae96a89..bd303e52 100644 --- a/test/features/notifications/notification_cancellation_queue_test.dart +++ b/test/features/notifications/notification_cancellation_queue_test.dart @@ -8,31 +8,28 @@ import 'package:flutter_test/flutter_test.dart'; import '../../support/recording_notification_backend.dart'; void main() { - test( - 'pending cancellation keeps its original backend across service replacement', - () async { - final queue = NotificationCancellationQueue(); - final oldBackend = RecordingNotificationBackend() - ..cancellationFailuresRemaining = 1; - final oldService = DesktopNotificationService( - backend: oldBackend, - settings: AppSettings.defaults(), - cancellationQueue: queue, - ); - expect(await oldService.cancelReminder('old-delivery'), isFalse); - final newBackend = RecordingNotificationBackend(); - final replacement = DesktopNotificationService( - backend: newBackend, - settings: AppSettings.defaults(), - cancellationQueue: queue, - ); - await replacement.retryReminderCancellations(); - expect(oldBackend.cancelledIds, ['old-delivery']); - expect(newBackend.cancelledIds, isEmpty); - await replacement.retryReminderCancellations(); - expect(oldBackend.cancellationAttempts, hasLength(2)); - }, - ); + test('pending cancellation keeps its original backend across service replacement', () async { + final queue = NotificationCancellationQueue(); + final oldBackend = RecordingNotificationBackend() + ..cancellationFailuresRemaining = 1; + final oldService = DesktopNotificationService( + backend: oldBackend, + settings: AppSettings.defaults(), + cancellationQueue: queue, + ); + expect(await oldService.cancelReminder('old-delivery'), isFalse); + final newBackend = RecordingNotificationBackend(); + final replacement = DesktopNotificationService( + backend: newBackend, + settings: AppSettings.defaults(), + cancellationQueue: queue, + ); + await replacement.retryReminderCancellations(); + expect(oldBackend.cancelledIds, ['old-delivery']); + expect(newBackend.cancelledIds, isEmpty); + await replacement.retryReminderCancellations(); + expect(oldBackend.cancellationAttempts, hasLength(2)); + }); test( 'concurrent cancellation requests share one native attempt and complete', diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index 2c096477..97df4bb7 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; + import 'dart:async'; import 'dart:convert'; @@ -10,7 +11,9 @@ import 'package:busymax/src/features/notifications/notification_schedule_service import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/app/app_settings.dart'; + import '../../support/recording_notification_backend.dart'; + import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart' hide isNotNull; import 'package:drift/native.dart'; @@ -119,8 +122,7 @@ void main() { event: googleCalendarEventFromJson('cal-1', { 'id': 'malformed', 'summary': 'Notes', - 'description': - '

Notes � � � �

', + 'description': '

Notes � � � �

', 'start': {'dateTime': '2026-06-08T09:00:00Z'}, 'end': {'dateTime': '2026-06-08T10:00:00Z'}, 'reminders': { diff --git a/test/features/notifications/notification_scheduler_test.dart b/test/features/notifications/notification_scheduler_test.dart index 2c0a9d04..a6efc45c 100644 --- a/test/features/notifications/notification_scheduler_test.dart +++ b/test/features/notifications/notification_scheduler_test.dart @@ -668,25 +668,22 @@ void main() { }, ); - test( - 'an old snooze cannot act on a reminder removed and recreated at the same time', - () async { - await _insertDueTaskNotification(database, now); - await scheduler.checkNow(); - final old = backend.notifications.single; - await database.delete(database.notificationSchedule).go(); - await _insertDueTaskNotification(database, now); - await scheduler.checkNow(); - final current = await database - .select(database.notificationSchedule) - .getSingle(); - await old.invoke('snooze'); - expect( - await database.select(database.notificationSchedule).getSingle(), - current, - ); - }, - ); + test('an old snooze cannot act on a reminder removed and recreated at the same time', () async { + await _insertDueTaskNotification(database, now); + await scheduler.checkNow(); + final old = backend.notifications.single; + await database.delete(database.notificationSchedule).go(); + await _insertDueTaskNotification(database, now); + await scheduler.checkNow(); + final current = await database + .select(database.notificationSchedule) + .getSingle(); + await old.invoke('snooze'); + expect( + await database.select(database.notificationSchedule).getSingle(), + current, + ); + }); for (final quiet in [false, true]) { test('visible snooze uses updated settings (quiet=$quiet)', () async { diff --git a/test/features/schedule/presentation/cloud_calendar_series_export_test.dart b/test/features/schedule/presentation/cloud_calendar_series_export_test.dart index 76c26e4e..022418a6 100644 --- a/test/features/schedule/presentation/cloud_calendar_series_export_test.dart +++ b/test/features/schedule/presentation/cloud_calendar_series_export_test.dart @@ -47,9 +47,9 @@ void main() { ..._microsoftMaster, 'start': {'dateTime': startUtc, 'timeZone': 'UTC'}, 'end': { - 'dateTime': DateTime.parse( - startUtc, - ).add(const Duration(hours: 1)).toIso8601String(), + 'dateTime': DateTime.parse(startUtc) + .add(const Duration(hours: 1)) + .toIso8601String(), 'timeZone': 'UTC', }, 'recurrence': { @@ -186,37 +186,31 @@ void main() { ); }); - test( - 'a series starting after the 2037 table keeps its authoritative wall time and UNTIL', - () { - final data = cloudSeriesToICalendar( - master: zonedMicrosoftSeries( - zone: 'America/Los_Angeles', - startUtc: '2038-07-01T16:00:00', - range: { - 'type': 'endDate', - 'startDate': '2038-07-01', - 'endDate': '2038-07-31', - }, - ), - exceptions: const [], - nowUtc: DateTime.utc(2026), - ); - expect( + test('a series starting after the 2037 table keeps its authoritative wall time and UNTIL', () { + final data = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: 'America/Los_Angeles', + startUtc: '2038-07-01T16:00:00', + range: { + 'type': 'endDate', + 'startDate': '2038-07-01', + 'endDate': '2038-07-31', + }, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect(data, contains('DTSTART;TZID=America/Los_Angeles:20380701T090000')); + expect(data, contains('UNTIL=20380731T160000Z')); + expect( + exportedInstant( data, - contains('DTSTART;TZID=America/Los_Angeles:20380701T090000'), - ); - expect(data, contains('UNTIL=20380731T160000Z')); - expect( - exportedInstant( - data, - 'America/Los_Angeles', - DateTime.utc(2038, 7, 31, 9), - ), - DateTime.utc(2038, 7, 31, 16), - ); - }, - ); + 'America/Los_Angeles', + DateTime.utc(2038, 7, 31, 9), + ), + DateTime.utc(2038, 7, 31, 16), + ); + }); test('IANA 2025c all-year DST continuation stays constant', () { for (final zone in ['Africa/Casablanca', 'Africa/El_Aaiun']) { @@ -316,70 +310,67 @@ void main() { ); }); - test( - 'Google series export waits for every page and retains moved/cancelled instances', - () async { - final requests = []; - final client = GoogleCalendarApiClient( - httpClient: MockClient((request) async { - requests.add(request); - if (request.url.path.endsWith('/events/master')) { - return http.Response(jsonEncode(_googleMaster), 200); - } - if (request.url.queryParameters['pageToken'] == 'second') { - return http.Response( - jsonEncode({ - 'items': [_googleMoved, _googleCancelled], - }), - 200, - ); - } + test('Google series export waits for every page and retains moved/cancelled instances', () async { + final requests = []; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(_googleMaster), 200); + } + if (request.url.queryParameters['pageToken'] == 'second') { return http.Response( jsonEncode({ - 'items': [_googleMaster], - 'nextPageToken': 'second', + 'items': [_googleMoved, _googleCancelled], }), 200, ); - }), - baseUri: Uri.parse('https://www.googleapis.com'), - ); - final data = await exportGoogleEventSeries( - client: client, - calendarId: 'primary', - eventId: 'master', - nowUtc: DateTime.utc(2026, 8, 1), - ); - expect( - requests.where((r) => r.url.queryParameters['pageToken'] == 'second'), - hasLength(1), - ); - expect( - requests.where((r) => r.url.queryParameters['showDeleted'] == 'true'), - hasLength(2), - ); - expect( - requests.where((r) => r.url.path.endsWith('/calendarList')), - isEmpty, - ); - expect(data, contains('UID:series@example.test')); - expect(data, contains('RRULE:FREQ=DAILY;COUNT=3')); - expect( - data, - contains('RECURRENCE-ID;TZID=America/Los_Angeles:20260831T090000'), - ); - expect(data, contains('SUMMARY:Moved')); - expect(data, contains('STATUS:CANCELLED')); - expect(data, contains('BEGIN:VALARM')); - expect(data, contains('ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=ACCEPTED')); - expect(data, contains('ATTACH:https://files.example.test/agenda.pdf')); - final parsed = IcalIngestion.parseString( - data, - policy: IcalIngestionPolicy.fileImport, - ); - expect(parsed.recurrenceSets.single.semantic.components, hasLength(3)); - }, - ); + } + return http.Response( + jsonEncode({ + 'items': [_googleMaster], + 'nextPageToken': 'second', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final data = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect( + requests.where((r) => r.url.queryParameters['pageToken'] == 'second'), + hasLength(1), + ); + expect( + requests.where((r) => r.url.queryParameters['showDeleted'] == 'true'), + hasLength(2), + ); + expect( + requests.where((r) => r.url.path.endsWith('/calendarList')), + isEmpty, + ); + expect(data, contains('UID:series@example.test')); + expect(data, contains('RRULE:FREQ=DAILY;COUNT=3')); + expect( + data, + contains('RECURRENCE-ID;TZID=America/Los_Angeles:20260831T090000'), + ); + expect(data, contains('SUMMARY:Moved')); + expect(data, contains('STATUS:CANCELLED')); + expect(data, contains('BEGIN:VALARM')); + expect(data, contains('ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=ACCEPTED')); + expect(data, contains('ATTACH:https://files.example.test/agenda.pdf')); + final parsed = IcalIngestion.parseString( + data, + policy: IcalIngestionPolicy.fileImport, + ); + expect(parsed.recurrenceSets.single.semantic.components, hasLength(3)); + }); test('Google later-page failure does not return a partial series', () async { final client = GoogleCalendarApiClient( @@ -716,9 +707,10 @@ void main() { isTrue, ); expect(occurrences.last.isAfter(until), isTrue); - final references = RegExp( - r';TZID=([^:]+):', - ).allMatches(data).map((match) => match.group(1)!).toSet(); + final references = RegExp(r';TZID=([^:]+):') + .allMatches(data) + .map((match) => match.group(1)!) + .toSet(); final definitions = RegExp( r'^TZID:([^\r\n]+)', multiLine: true, @@ -760,9 +752,8 @@ void main() { expect(data, contains('TZOFFSETTO:-0800')); time_zone_data.initializeTimeZones(); final location = tz.getLocation('America/Los_Angeles'); - final match = RegExp( - r'DTSTART;TZID=America/Los_Angeles:(\d{8})T(\d{6})', - ).firstMatch(data)!; + final match = RegExp(r'DTSTART;TZID=America/Los_Angeles:(\d{8})T(\d{6})') + .firstMatch(data)!; final date = match.group(1)!; final time = match.group(2)!; final year = int.parse(date.substring(0, 4)); @@ -781,9 +772,8 @@ void main() { ], ); expect( - RegExp( - r'BEGIN:(?:STANDARD|DAYLIGHT)[\s\S]*?RRULE:FREQ=YEARLY', - ).hasMatch(data), + RegExp(r'BEGIN:(?:STANDARD|DAYLIGHT)[\s\S]*?RRULE:FREQ=YEARLY') + .hasMatch(data), isTrue, ); }, @@ -839,60 +829,57 @@ void main() { ); }); - test( - 'series export retains calendar-default popup alarms only with authoritative settings', - () async { - final master = { - ..._googleMaster, - 'reminders': {'useDefault': true}, - }; - final client = GoogleCalendarApiClient( - httpClient: MockClient((request) async { - if (request.url.path.endsWith('/events/master')) { - return http.Response(jsonEncode(master), 200); - } - if (request.url.path.endsWith('/users/me/calendarList')) { - return http.Response( - jsonEncode({ - 'items': [ - { - 'id': 'primary', - 'summary': 'Primary', - 'defaultReminders': [ - {'method': 'popup', 'minutes': 5}, - ], - }, - ], - }), - 200, - ); - } + test('series export retains calendar-default popup alarms only with authoritative settings', () async { + final master = { + ..._googleMaster, + 'reminders': {'useDefault': true}, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(master), 200); + } + if (request.url.path.endsWith('/users/me/calendarList')) { return http.Response( jsonEncode({ - 'items': [master], + 'items': [ + { + 'id': 'primary', + 'summary': 'Primary', + 'defaultReminders': [ + {'method': 'popup', 'minutes': 5}, + ], + }, + ], }), 200, ); - }), - baseUri: Uri.parse('https://www.googleapis.com'), - ); - final exported = await exportGoogleEventSeries( - client: client, - calendarId: 'primary', - eventId: 'master', + } + return http.Response( + jsonEncode({ + 'items': [master], + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final exported = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect(exported, contains('TRIGGER:-PT5M')); + expect( + () => cloudSeriesToICalendar( + master: googleCalendarEventFromJson('primary', master), + exceptions: const [], nowUtc: DateTime.utc(2026, 8, 1), - ); - expect(exported, contains('TRIGGER:-PT5M')); - expect( - () => cloudSeriesToICalendar( - master: googleCalendarEventFromJson('primary', master), - exceptions: const [], - nowUtc: DateTime.utc(2026, 8, 1), - ), - throwsFormatException, - ); - }, - ); + ), + throwsFormatException, + ); + }); test( 'E Google exception defaults are fetched when master is explicit', @@ -984,9 +971,10 @@ void main() { eventId: 'master', nowUtc: DateTime.utc(2026, 8, 1), ); - final events = RegExp( - r'BEGIN:VEVENT[\s\S]*?END:VEVENT', - ).allMatches(exported).map((match) => match.group(0)!).toList(); + final events = RegExp(r'BEGIN:VEVENT[\s\S]*?END:VEVENT') + .allMatches(exported) + .map((match) => match.group(0)!) + .toList(); expect(defaultReads, 1); expect(events, hasLength(2)); expect(events.first, contains('TRIGGER:-PT10M')); diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index ee9849f7..6cf5f408 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -779,9 +779,8 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( } if (additionalEventDate != null) { final startDate = DateFormat('yyyy-MM-dd').format(additionalEventDate); - final endDate = DateFormat( - 'yyyy-MM-dd', - ).format(additionalEventDate.add(const Duration(days: 1))); + final endDate = DateFormat('yyyy-MM-dd') + .format(additionalEventDate.add(const Duration(days: 1))); await calendar.upsertEvent( accountId: 'account', event: CalendarEventDto( diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index cc0eb4e3..02270ab9 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -241,15 +241,15 @@ void main() { BusyMaxMenuButton calendarMenu(String id) => tester.widget(find.byKey(ValueKey(('calendar-options', id)))); expect( - calendarMenu( - 'c1', - ).entries.singleWhere((entry) => entry.value == 'move-up').enabled, + calendarMenu('c1').entries + .singleWhere((entry) => entry.value == 'move-up') + .enabled, isFalse, ); expect( - calendarMenu( - 'c2', - ).entries.singleWhere((entry) => entry.value == 'move-down').enabled, + calendarMenu('c2').entries + .singleWhere((entry) => entry.value == 'move-down') + .enabled, isFalse, ); final beforeElement = tester.element( @@ -307,9 +307,8 @@ void main() { findsNothing, ); expect( - AppSettings.fromJson( - store.value, - ).isTaskListVisibleInSchedule('a', 'l2'), + AppSettings.fromJson(store.value) + .isTaskListVisibleInSchedule('a', 'l2'), isTrue, ); await tester.pumpWidget(const SizedBox.shrink()); @@ -359,15 +358,15 @@ void main() { lists.add([_list('a', 'l2'), _list('a', 'l1')]); await tester.pumpAndSettle(); expect( - AppSettings.fromJson( - store.value, - ).sidebarOrder.calendarSourceIdsByAccount['a'], + AppSettings.fromJson(store.value) + .sidebarOrder + .calendarSourceIdsByAccount['a'], ['c2', 'c1'], ); expect( - AppSettings.fromJson( - store.value, - ).sidebarOrder.taskListIdsByAccount['a'], + AppSettings.fromJson(store.value) + .sidebarOrder + .taskListIdsByAccount['a'], ['l2', 'l1'], ); calendars.add([ diff --git a/test/features/schedule/presentation/schedule_sidebar_provider_test.dart b/test/features/schedule/presentation/schedule_sidebar_provider_test.dart index 9e66943c..08cb472b 100644 --- a/test/features/schedule/presentation/schedule_sidebar_provider_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_provider_test.dart @@ -1,10 +1,13 @@ import 'dart:async'; + import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../test_localized_app.dart'; import '../../../support/memory_settings_store.dart'; + import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 993e1426..60ebdfa8 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -810,18 +810,16 @@ void main() { expect(planner.offTimesParam.offTimesAllDaysRanges.first.end.hour, 8); expect(planner.offTimesParam.offTimesAllDaysRanges.last.start.hour, 18); expect(planner.offTimesParam.offTimesAllDaysRanges.last.end.hour, 24); - final painter = - planner.offTimesParam.offTimesAllDaysPainter!( - 0, - selectedDate, - true, - 0.9, - planner.offTimesParam.offTimesAllDaysRanges, - Theme.of( - tester.element(find.byType(ScheduleDayWeekView)), - ).colorScheme.surface, - ) - as icv.OffSetAllDaysPainter; + final painter = planner.offTimesParam.offTimesAllDaysPainter!( + 0, + selectedDate, + true, + 0.9, + planner.offTimesParam.offTimesAllDaysRanges, + Theme.of(tester.element(find.byType(ScheduleDayWeekView))) + .colorScheme + .surface, + ) as icv.OffSetAllDaysPainter; expect(painter.paintToday, isTrue); }); @@ -829,9 +827,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final item = _itemsFor( - selectedDate, - ).whereType().first; + final item = _itemsFor(selectedDate) + .whereType() + .first; await tester.pumpWidget( localizedTestApp( @@ -851,9 +849,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final item = _itemsFor( - selectedDate, - ).whereType().first; + final item = _itemsFor(selectedDate) + .whereType() + .first; var activationCount = 0; Offset? activationPosition = Offset.zero; @@ -917,9 +915,9 @@ void main() { testWidgets('interactive task chip uses the event click cursor', ( tester, ) async { - final task = _itemsFor( - DateTime(2026, 1, 15), - ).whereType().first; + final task = _itemsFor(DateTime(2026, 1, 15)) + .whereType() + .first; await tester.pumpWidget( localizedTestApp( @@ -1431,9 +1429,9 @@ void main() { final selectedDate = DateTime(2026, 1, 15); ScheduleItem? selectedItem; Offset? pointerPosition; - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; await tester.pumpWidget( localizedTestApp( @@ -1476,9 +1474,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; Future? action; await tester.pumpWidget( @@ -1887,9 +1885,9 @@ void main() { addTearDown(tester.view.reset); final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; final theme = BusyMaxYaruTheme.build( brightness: baseline.brightness, accentColor: const Color(0xFF3584E4), @@ -1900,9 +1898,8 @@ void main() { theme: theme, child: Builder( builder: (context) { - final mediaQuery = MediaQuery.of( - context, - ).copyWith(disableAnimations: true); + final mediaQuery = MediaQuery.of(context) + .copyWith(disableAnimations: true); return MediaQuery( data: mediaQuery, child: Scaffold( @@ -2096,9 +2093,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; final controller = ScheduleAnchoredPopoverController(); await tester.pumpWidget( @@ -2139,9 +2136,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; Future? action; await tester.pumpWidget( @@ -2700,9 +2697,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; await tester.pumpWidget( localizedTestApp( @@ -2769,9 +2766,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; await tester.pumpWidget( localizedTestApp( @@ -2817,9 +2814,9 @@ void main() { tester, ) async { final selectedDate = DateTime(2026, 1, 15); - final event = _itemsFor( - selectedDate, - ).whereType().first; + final event = _itemsFor(selectedDate) + .whereType() + .first; await tester.pumpWidget( localizedTestApp( @@ -3737,9 +3734,9 @@ void main() { }); test('schedule presentation does not use banned package final UI', () { - final files = Directory( - 'lib/src/features/schedule/presentation', - ).listSync().whereType(); + final files = Directory('lib/src/features/schedule/presentation') + .listSync() + .whereType(); for (final file in files) { final source = file.readAsStringSync(); expect(source, isNot(contains('EventsMonths'))); @@ -3967,9 +3964,8 @@ void main() { final linux = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', ).readAsStringSync(); - final windows = File( - 'lib/src/ui/windows/windows_schedule_page.dart', - ).readAsStringSync(); + final windows = File('lib/src/ui/windows/windows_schedule_page.dart') + .readAsStringSync(); final android = File( 'lib/src/android/presentation/android_schedule_screen.dart', ).readAsStringSync(); @@ -3988,13 +3984,11 @@ void main() { final sidebar = File( 'lib/src/features/schedule/presentation/schedule_sidebar.dart', ).readAsStringSync(); - final nativeRunner = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); + final nativeRunner = File('linux/runner/my_application.cc') + .readAsStringSync(); - final pageFrame = File( - 'lib/src/app/linux/linux_page_frame.dart', - ).readAsStringSync(); + final pageFrame = File('lib/src/app/linux/linux_page_frame.dart') + .readAsStringSync(); expect(pageFrame, contains('class BusyMaxLinuxBrandHeader')); expect(pageFrame, contains("'BusyMax'")); @@ -4771,12 +4765,10 @@ void main() { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - final nativeRunner = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); - final headerActions = File( - 'lib/src/app/busymax_header_actions.dart', - ).readAsStringSync(); + final nativeRunner = File('linux/runner/my_application.cc') + .readAsStringSync(); + final headerActions = File('lib/src/app/busymax_header_actions.dart') + .readAsStringSync(); final sidebar = File( 'lib/src/features/schedule/presentation/schedule_sidebar.dart', ).readAsStringSync(); @@ -4856,9 +4848,8 @@ void main() { final taskChip = File( 'lib/src/features/schedule/presentation/schedule_task_chip.dart', ).readAsStringSync(); - final projection = File( - 'lib/src/schedule/schedule_projection.dart', - ).readAsStringSync(); + final projection = File('lib/src/schedule/schedule_projection.dart') + .readAsStringSync(); expect(eventBlock, contains('color: surfaceColors.control')); expect(eventBlock, contains('sourceAccent')); @@ -5005,9 +4996,8 @@ void main() { final toolbar = File( 'lib/src/features/schedule/presentation/schedule_toolbar.dart', ).readAsStringSync(); - final nativeRunner = File( - 'linux/runner/my_application.cc', - ).readAsStringSync(); + final nativeRunner = File('linux/runner/my_application.cc') + .readAsStringSync(); expect( toolbar, @@ -5077,9 +5067,8 @@ void main() { final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); - final scheduleItem = File( - 'lib/src/schedule/schedule_item.dart', - ).readAsStringSync(); + final scheduleItem = File('lib/src/schedule/schedule_item.dart') + .readAsStringSync(); final repository = File( 'lib/src/features/calendar/data/calendar_repository.dart', ).readAsStringSync(); @@ -5097,12 +5086,10 @@ void main() { }); test('year mode uses existing schedule primitives', () { - final mode = File( - 'lib/src/schedule/schedule_view_mode.dart', - ).readAsStringSync(); - final range = File( - 'lib/src/schedule/schedule_range.dart', - ).readAsStringSync(); + final mode = File('lib/src/schedule/schedule_view_mode.dart') + .readAsStringSync(); + final range = File('lib/src/schedule/schedule_range.dart') + .readAsStringSync(); final workspace = File( 'lib/src/features/schedule/presentation/schedule_workspace.dart', ).readAsStringSync(); diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 3e7b0f79..d3aab3d9 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; + import 'package:busymax/src/schedule/schedule_search_match.dart'; import 'package:busymax/src/schedule/schedule_search_criteria.dart'; @@ -182,46 +183,40 @@ void main() { expect(snippet.length, lessThanOrEqualTo(142)); } }); - test( - 'identity extraction ignores response metadata and handles organizer emailAddress', - () { - expect(matchesScheduleQuery(event, 'accepted'), isFalse); - expect(matchesScheduleQuery(event, 'tentative'), isFalse); - const nested = CalendarScheduleItem( - id: 'nested', - accountId: 'a', - provider: BusyProvider.microsoft, - sourceId: 's', - providerCalendarId: 's', - title: 'Meeting', - allDay: true, - organizer: { - 'emailAddress': { - 'name': 'Chris Green', - 'address': 'chris@example.com', - }, + test('identity extraction ignores response metadata and handles organizer emailAddress', () { + expect(matchesScheduleQuery(event, 'accepted'), isFalse); + expect(matchesScheduleQuery(event, 'tentative'), isFalse); + const nested = CalendarScheduleItem( + id: 'nested', + accountId: 'a', + provider: BusyProvider.microsoft, + sourceId: 's', + providerCalendarId: 's', + title: 'Meeting', + allDay: true, + organizer: { + 'emailAddress': { + 'name': 'Chris Green', + 'address': 'chris@example.com', }, - ); - expect( - matchesScheduleQuery(nested, 'Chris Green chris@example.com'), - isTrue, - ); - expect( - matches(event, const ScheduleFilters(person: 'unknown')), - isFalse, - ); - expect( - matches( - task(), - const ScheduleFilters( - includeCalendarEvents: false, - person: 'ignored', - ), + }, + ); + expect( + matchesScheduleQuery(nested, 'Chris Green chris@example.com'), + isTrue, + ); + expect(matches(event, const ScheduleFilters(person: 'unknown')), isFalse); + expect( + matches( + task(), + const ScheduleFilters( + includeCalendarEvents: false, + person: 'ignored', ), - isTrue, - ); - }, - ); + ), + isTrue, + ); + }); test('task location and cross-field words remain searchable', () { expect(matchesScheduleQuery(task(), 'REPORT office appendix'), isTrue); expect(matchesScheduleQuery(event, 'review north alex planning'), isTrue); @@ -318,44 +313,41 @@ void main() { contains('Morgan'), ); }); - test( - 'criteria copy sets, have value equality and respect week start and inclusive custom end', - () { - final ids = {'a', 'b'}; - final first = ScheduleSearchCriteria( - referenceDate: today, - firstWeekday: DateTime.sunday, - sourceIds: ids, - taskListKeys: {}, - ); - ids.clear(); - expect(first.sourceIds, {'a', 'b'}); - final equal = first.copyWith(sourceIds: {'b', 'a'}); - expect(first, equal); - expect(first.hashCode, equal.hashCode); - expect(first.copyWith(person: 'Alex'), isNot(first)); - expect( - first - .copyWith(person: 'Alex') - .copyWith(type: ScheduleSearchType.tasks) - .person, - isEmpty, - ); - expect( - first.copyWith(date: ScheduleSearchDate.thisWeek).range!.start, - DateTime(2026, 6, 7), - ); - final custom = first - .copyWith( - date: ScheduleSearchDate.custom, - customStart: today, - customEnd: DateTime(2026, 6, 14), - ) - .range!; - expect(custom.start, today); - expect(custom.end, DateTime(2026, 6, 15)); - }, - ); + test('criteria copy sets, have value equality and respect week start and inclusive custom end', () { + final ids = {'a', 'b'}; + final first = ScheduleSearchCriteria( + referenceDate: today, + firstWeekday: DateTime.sunday, + sourceIds: ids, + taskListKeys: {}, + ); + ids.clear(); + expect(first.sourceIds, {'a', 'b'}); + final equal = first.copyWith(sourceIds: {'b', 'a'}); + expect(first, equal); + expect(first.hashCode, equal.hashCode); + expect(first.copyWith(person: 'Alex'), isNot(first)); + expect( + first + .copyWith(person: 'Alex') + .copyWith(type: ScheduleSearchType.tasks) + .person, + isEmpty, + ); + expect( + first.copyWith(date: ScheduleSearchDate.thisWeek).range!.start, + DateTime(2026, 6, 7), + ); + final custom = first + .copyWith( + date: ScheduleSearchDate.custom, + customStart: today, + customEnd: DateTime(2026, 6, 14), + ) + .range!; + expect(custom.start, today); + expect(custom.end, DateTime(2026, 6, 15)); + }); }); for (final completion in ScheduleTaskCompletion.values) { @@ -383,43 +375,40 @@ void main() { }); }); } - test( - 'repository Microsoft due uses later due day and due timezone rather than start', - () async { - final db = AppDatabase.memoryForTests(); - addTearDown(db.close); - await _insertScheduleAccount(db, provider: BusyProvider.microsoft); - await _insertTaskList(db); - await _insertTask(db, id: 'ms', title: 'Report'); - await db - .update(db.tasks) - .write( - const TasksCompanion( - microsoftStartDateTime: Value('2026-06-10T09:00:00'), - microsoftDueDateTime: Value('2026-06-12T12:00:00'), - microsoftDueTimeZone: Value('Pacific Standard Time'), - ), - ); - final repo = ScheduleRepository(db); - const filters = ScheduleFilters(useTaskDueDate: true, query: 'report'); - expect( - await repo.listItems( - range: ScheduleRange.day(DateTime(2026, 6, 10)), - filters: filters, - ), - isEmpty, - ); - final items = await repo.listItems( - range: ScheduleRange.day(DateTime(2026, 6, 12)), + test('repository Microsoft due uses later due day and due timezone rather than start', () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _insertScheduleAccount(db, provider: BusyProvider.microsoft); + await _insertTaskList(db); + await _insertTask(db, id: 'ms', title: 'Report'); + await db + .update(db.tasks) + .write( + const TasksCompanion( + microsoftStartDateTime: Value('2026-06-10T09:00:00'), + microsoftDueDateTime: Value('2026-06-12T12:00:00'), + microsoftDueTimeZone: Value('Pacific Standard Time'), + ), + ); + final repo = ScheduleRepository(db); + const filters = ScheduleFilters(useTaskDueDate: true, query: 'report'); + expect( + await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 6, 10)), filters: filters, - ); - expect(items, hasLength(1)); - expect( - (items.single as TaskScheduleItem).due, - DateTime.utc(2026, 6, 12, 19).toLocal(), - ); - }, - ); + ), + isEmpty, + ); + final items = await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: filters, + ); + expect(items, hasLength(1)); + expect( + (items.single as TaskScheduleItem).due, + DateTime.utc(2026, 6, 12, 19).toLocal(), + ); + }); test( 'repository DAV DTSTART-only legacy dueUtc does not become an actual due', () async { @@ -456,64 +445,61 @@ void main() { ); }, ); - test( - 'repository bounded text does not leak and Any date skips projection coverage', - () async { - final db = AppDatabase.memoryForTests(); - addTearDown(db.close); - await _seedSearchDatabase(db); - var calls = 0; - var cloudCalls = 0; - final repo = ScheduleRepository( - db, - ensureCloudCoverage: (_, filters) async { - cloudCalls++; - expect(filters.sourceIds, isEmpty); - return false; - }, - ensureProjectionCoverage: (_) async { - calls++; - }, - ); - final range = ScheduleRange.day(DateTime(2026, 1, 1)); - final coverageFinished = repo.watchChanges().firstWhere( - (_) => repo.cloudCoverageCompleteFor(range) == false, - ); - expect( - await repo.listItems( - range: range, - filters: const ScheduleFilters(query: 'future budget'), - ), - isEmpty, - ); - await coverageFinished; - expect(calls, 1); - expect(cloudCalls, 1); - expect(repo.cloudCoverageCompleteFor(range), isFalse); - expect( - await repo.listItems( - range: range, - filters: const ScheduleFilters( - query: 'future budget', - ignoreDateRange: true, - ), + test('repository bounded text does not leak and Any date skips projection coverage', () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _seedSearchDatabase(db); + var calls = 0; + var cloudCalls = 0; + final repo = ScheduleRepository( + db, + ensureCloudCoverage: (_, filters) async { + cloudCalls++; + expect(filters.sourceIds, isEmpty); + return false; + }, + ensureProjectionCoverage: (_) async { + calls++; + }, + ); + final range = ScheduleRange.day(DateTime(2026, 1, 1)); + final coverageFinished = repo.watchChanges().firstWhere( + (_) => repo.cloudCoverageCompleteFor(range) == false, + ); + expect( + await repo.listItems( + range: range, + filters: const ScheduleFilters(query: 'future budget'), + ), + isEmpty, + ); + await coverageFinished; + expect(calls, 1); + expect(cloudCalls, 1); + expect(repo.cloudCoverageCompleteFor(range), isFalse); + expect( + await repo.listItems( + range: range, + filters: const ScheduleFilters( + query: 'future budget', + ignoreDateRange: true, ), - hasLength(1), - ); - expect(calls, 1); - expect(cloudCalls, 1); - expect( - await repo.listItems( - range: ScheduleRange.day(DateTime(2026, 2, 15)), - filters: const ScheduleFilters( - query: 'future budget', - useTaskDueDate: true, - ), + ), + hasLength(1), + ); + expect(calls, 1); + expect(cloudCalls, 1); + expect( + await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 2, 15)), + filters: const ScheduleFilters( + query: 'future budget', + useTaskDueDate: true, ), - hasLength(1), - ); - }, - ); + ), + hasLength(1), + ); + }); test('range completion is keyed to requested search sources', () async { final db = AppDatabase.memoryForTests(); addTearDown(db.close); @@ -601,47 +587,44 @@ void main() { expect(calls, 2); }, ); - test( - 'repository date filter includes spanning events and excludes boundary and outside events', - () async { - final db = AppDatabase.memoryForTests(); - addTearDown(db.close); - await _insertScheduleAccount(db, provider: BusyProvider.google); - final calendar = CalendarRepository(database: db); - await calendar.upsertSource( + test('repository date filter includes spanning events and excludes boundary and outside events', () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _insertScheduleAccount(db, provider: BusyProvider.google); + final calendar = CalendarRepository(database: db); + await calendar.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + ), + ); + for (final entry in [ + ('inside', '2026-06-12', '2026-06-13'), + ('overlap', '2026-06-11', '2026-06-14'), + ('outside', '2026-06-13', '2026-06-14'), + ('ends-at-start', '2026-06-11', '2026-06-12'), + ]) { + await calendar.upsertEvent( accountId: 'account', - source: const CalendarSourceDto( + event: CalendarEventDto( provider: BusyProvider.google, providerCalendarId: 'calendar', - summary: 'Work', + providerEventId: entry.$1, + title: entry.$1, + allDay: true, + startDate: entry.$2, + endDate: entry.$3, ), ); - for (final entry in [ - ('inside', '2026-06-12', '2026-06-13'), - ('overlap', '2026-06-11', '2026-06-14'), - ('outside', '2026-06-13', '2026-06-14'), - ('ends-at-start', '2026-06-11', '2026-06-12'), - ]) { - await calendar.upsertEvent( - accountId: 'account', - event: CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: 'calendar', - providerEventId: entry.$1, - title: entry.$1, - allDay: true, - startDate: entry.$2, - endDate: entry.$3, - ), - ); - } - final items = await ScheduleRepository(db).listItems( - range: ScheduleRange.day(DateTime(2026, 6, 12)), - filters: const ScheduleFilters(includeTasks: false), - ); - expect(items.map((i) => i.title).toSet(), {'inside', 'overlap'}); - }, - ); + } + final items = await ScheduleRepository(db).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: const ScheduleFilters(includeTasks: false), + ); + expect(items.map((i) => i.title).toSet(), {'inside', 'overlap'}); + }); test( 'search matches event title, location, description, and calendar name', diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index ce752aa3..3501e92e 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,5 +1,11 @@ +import '../../../support/contacts_fixture.dart'; +import '../../../platform/contacts_settings_routes_test.dart' + show complete, settle; + import '../../../core/auth/authorization_transition_test.dart' show Harness; + import 'package:busymax/src/core/auth/oauth_registration.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -47,11 +53,112 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../../../test_localized_app.dart'; import '../../../support/fake_autostart_service.dart'; import '../../../support/memory_settings_store.dart'; + import 'package:busymax/src/platform/common/desktop_services.dart'; const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { + testWidgets( + 'Linux contacts source switch immediately saves, filters and rolls back failed persistence', + (tester) async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + await tester.runAsync(() async { + await f.addParent(BusyProvider.google); + await f.controller.enableLinkedContacts('google'); + await f.controller.synchronizeAccount('contacts:google'); + }); + final parent = (await tester.runAsync( + () => f.accounts.accountById('google'), + ))!; + final container = _container( + selectedAccountId: 'google', + authRepository: _FakeAuthRepository(), + accounts: [parent], + contactsFixture: f, + accountsRepository: f.accounts, + ); + addTearDown(() async { + await _disposeDiagnosticsWidget(tester); + container.dispose(); + await complete(tester, f.controller.close); + await tester.runAsync(f.database.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1800), + initialPage: SettingsPage.accounts, + ); + await settle(tester); + final source = (await complete( + tester, + f.controller.sourceSettings, + )).single.source; + expect(find.text('Enable contact editing'), findsNothing); + expect(find.text('Read and write access'), findsNothing); + expect( + find.text('Read-only contacts for attendee suggestions'), + findsWidgets, + ); + expect( + find.byKey(const ValueKey('reconnect-contacts-google')), + findsOneWidget, + ); + final toggle = find.byKey(ValueKey('contact-source-${source.key}')); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await settle(tester); + expect(tester.widget(toggle).value, isFalse); + expect( + await complete( + tester, + () => + f.controller.suggestAttendees('ada', busyMaxAccountId: 'google'), + ), + isEmpty, + ); + await tester.runAsync( + () => f.database.customStatement( + "CREATE TRIGGER reject_source_preference BEFORE UPDATE ON bm_contact_source_preferences BEGIN SELECT RAISE(ABORT,'fixture preference rejected'); END", + ), + ); + await tester.tap(toggle); + await settle(tester); + expect(tester.widget(toggle).value, isFalse); + expect( + find.text('Could not save contact source preference'), + findsOneWidget, + ); + expect( + (await complete(tester, f.controller.sourceSettings)).single.enabled, + isFalse, + ); + await tester.runAsync( + () => + f.database.customStatement('DROP TRIGGER reject_source_preference'), + ); + await tester.tap(toggle); + await settle(tester); + expect(tester.widget(toggle).value, isTrue); + expect( + await complete( + tester, + () => + f.controller.suggestAttendees('ada', busyMaxAccountId: 'google'), + ), + hasLength(1), + ); + expect( + (await complete( + tester, + () => f.store.read((tx) => tx.account('contacts:google')), + ))!.enabled, + isTrue, + ); + await _disposeDiagnosticsWidget(tester); + }, + ); testWidgets('Settings reports unsaved preferences and retries persistence', ( tester, ) async { @@ -152,10 +259,9 @@ void main() { await observing.finished; await tester.pumpAndSettle(); expect( - (await h.secrets.readCredential('opaque') - as GoogleDesktopCredential) - .tokenSet - .accessToken, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).tokenSet.accessToken, 'candidate-access', ); await tester.pumpWidget(const SizedBox()); @@ -606,9 +712,9 @@ void main() { addTearDown(container.dispose); await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); - final foreground = Theme.of( - tester.element(find.text('Calendar import')), - ).colorScheme.onSurface; + final foreground = Theme.of(tester.element(find.text('Calendar import'))) + .colorScheme + .onSurface; for (final label in [ 'Google', 'Calendar import', @@ -828,14 +934,13 @@ void main() { database: database, secretStore: secrets, nextcloudLoginFlow: _unusedNextcloudLoginFlow(), - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) async => throw StateError('Discovery is not used by removal.'), + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => throw StateError('Discovery is not used by removal.'), nextcloudCredentialRevoker: ({required accountId, required credential}) async { throw const DavException( @@ -2118,9 +2223,16 @@ ProviderContainer _container({ AccountsRepository? accountsRepository, SignedInSyncRunner? signedInSyncRunner, Harness? productionHarness, + ContactsFixture? contactsFixture, }) { return ProviderContainer( overrides: [ + if (contactsFixture != null) ...[ + databaseProvider.overrideWithValue(contactsFixture.database), + busyMaxContactsControllerProvider.overrideWithValue( + contactsFixture.controller, + ), + ], if (productionHarness != null) ...[ databaseProvider.overrideWithValue(productionHarness.db), authorizationPersistenceProvider.overrideWithValue( @@ -2586,9 +2698,8 @@ void registerSettingsAuthorizationCancellationRegressions() { }); await h.seed(); if (!shared) await h.makeGoogleUserOwned(); - final before = (await h.secrets.readCredential( - 'opaque', - ))!.toJson(); + final before = (await h.secrets.readCredential('opaque'))! + .toJson(); final observing = _ObservedAuthRepository(h); final container = _container( selectedAccountId: 'opaque', @@ -2671,10 +2782,9 @@ void registerSettingsAuthorizationCancellationRegressions() { await observing.finished; await tester.pumpAndSettle(); expect( - (await h.secrets.readCredential('opaque') - as GoogleDesktopCredential) - .tokenSet - .accessToken, + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).tokenSet.accessToken, 'candidate-access', ); expect((await h.summary('opaque')).showRetirementNotice, shared); diff --git a/test/features/sync/account_sync_operations_test.dart b/test/features/sync/account_sync_operations_test.dart index 880450cd..9d0d03a7 100644 --- a/test/features/sync/account_sync_operations_test.dart +++ b/test/features/sync/account_sync_operations_test.dart @@ -5,33 +5,30 @@ import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test( - 'failed pull with no imported data retains eligible cached reminder delivery', - () async { - final coordinator = AccountSyncCoordinator(); - await expectLater( - coordinator.trackTaskImport('account', () async { - throw StateError('quota before any writes'); - }, changedOnFailure: () => false), - throwsStateError, - ); - expect(await coordinator.blocksDueToday('account'), false); - await expectLater( - coordinator.trackTaskImport('account', () async { - throw StateError('partial import'); - }), - throwsStateError, - ); - await expectLater( - coordinator.trackTaskImport('account', () async { - throw StateError('quota'); - }, changedOnFailure: () => false), - throwsStateError, - ); - expect(await coordinator.blocksDueToday('account'), true); - await coordinator.dispose(); - }, - ); + test('failed pull with no imported data retains eligible cached reminder delivery', () async { + final coordinator = AccountSyncCoordinator(); + await expectLater( + coordinator.trackTaskImport('account', () async { + throw StateError('quota before any writes'); + }, changedOnFailure: () => false), + throwsStateError, + ); + expect(await coordinator.blocksDueToday('account'), false); + await expectLater( + coordinator.trackTaskImport('account', () async { + throw StateError('partial import'); + }), + throwsStateError, + ); + await expectLater( + coordinator.trackTaskImport('account', () async { + throw StateError('quota'); + }, changedOnFailure: () => false), + throwsStateError, + ); + expect(await coordinator.blocksDueToday('account'), true); + await coordinator.dispose(); + }); test( 'routes providers explicitly and never creates a WebCal task sync', diff --git a/test/features/sync/calendar_event_clear_patch_test.dart b/test/features/sync/calendar_event_clear_patch_test.dart index 489d3fab..a67b8782 100644 --- a/test/features/sync/calendar_event_clear_patch_test.dart +++ b/test/features/sync/calendar_event_clear_patch_test.dart @@ -126,43 +126,39 @@ void main() { expect(outgoing['content'], contains('Join Teams meeting')); }); - test( - 'nested Teams HTML is replayed once without stale trailing notes', - () async { - const meeting = - '

Meeting ID: 123

'; - const original = - '

Old before

$meeting

Old after

'; - final first = await _editAndReplay( - provider: BusyProvider.microsoft, - onlineMeeting: true, - onlineBodyHtml: original, - editedDescription: 'First revision', - ); - final firstHtml = - ((jsonDecode(first.patchRequest.body) as Map)['body'] - as Map)['content'] - as String; - expect(firstHtml, contains(meeting)); - expect(firstHtml, isNot(contains('Old before'))); - expect(firstHtml, isNot(contains('Old after'))); - expect(first.reopenedDescription, contains('First revision')); - final second = await _editAndReplay( - provider: BusyProvider.microsoft, - onlineMeeting: true, - onlineBodyHtml: firstHtml, - editedDescription: 'Second revision', - ); - final secondHtml = - ((jsonDecode(second.patchRequest.body) as Map)['body'] - as Map)['content'] - as String; - expect(meeting.allMatches(secondHtml), hasLength(1)); - expect(secondHtml, isNot(contains('First revision'))); - expect(secondHtml, contains('Second revision')); - expect(second.reopenedDescription, contains('Second revision')); - }, - ); + test('nested Teams HTML is replayed once without stale trailing notes', () async { + const meeting = + '

Meeting ID: 123

'; + const original = + '

Old before

$meeting

Old after

'; + final first = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + onlineBodyHtml: original, + editedDescription: 'First revision', + ); + final firstHtml = + ((jsonDecode(first.patchRequest.body) as Map)['body'] as Map)['content'] + as String; + expect(firstHtml, contains(meeting)); + expect(firstHtml, isNot(contains('Old before'))); + expect(firstHtml, isNot(contains('Old after'))); + expect(first.reopenedDescription, contains('First revision')); + final second = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + onlineBodyHtml: firstHtml, + editedDescription: 'Second revision', + ); + final secondHtml = + ((jsonDecode(second.patchRequest.body) as Map)['body'] + as Map)['content'] + as String; + expect(meeting.allMatches(secondHtml), hasLength(1)); + expect(secondHtml, isNot(contains('First revision'))); + expect(secondHtml, contains('Second revision')); + expect(second.reopenedDescription, contains('Second revision')); + }); } void _expectUnrelatedOptionalFieldsOmitted(Map body) { @@ -280,9 +276,7 @@ _editAndReplay({ if (onlineMeeting) 'body': { 'contentType': 'html', - 'content': - onlineBodyHtml ?? - '

Original agenda

', + 'content': onlineBodyHtml ?? '

Original agenda

', }, }, ), @@ -332,9 +326,7 @@ _editAndReplay({ ? (jsonDecode(request.body) as Map)['body'] : { 'contentType': 'html', - 'content': - onlineBodyHtml ?? - '

Original agenda

', + 'content': onlineBodyHtml ?? '

Original agenda

', }; } return http.Response( @@ -367,9 +359,8 @@ _editAndReplay({ applied: applied, queuedRequest: queuedRequest, patchRequest: patchRequest, - reopenedDescription: (await repository.loadEventDetail( - eventId, - ))?.description, + reopenedDescription: (await repository.loadEventDetail(eventId)) + ?.description, ); } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index 22807894..c0fe6947 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -65,59 +65,56 @@ void main() { await database.close(); }); - test( - 'guarded timing edit replays exact endpoints and guest policy without moving ownership', - () async { - final repository = CalendarRepository(database: database); - final id = await _insertEvent( - database, - providerEventId: 'drag-event', - startTimeZone: 'UTC', - endTimeZone: 'UTC', - ); - final detail = (await repository.loadEventDetail(id))!; - client.remoteEvent = client._event( - 'drag-event', - title: 'Base', - organizerJson: const {'self': true}, - ); - client.persistEventUpdates = true; - await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail).copyWith( - start: DateTime.utc(2026, 6, 8, 14, 15), - end: DateTime.utc(2026, 6, 8, 15, 45), - ), - timingBaseline: EventTimingBaseline.fromDetail(detail), - guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, - ); - final replay = CalendarPendingOpsReplayer( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 9), - ); - expect(await replay.replayDueOps(), 1); - expect(await replay.replayDueOps(), 0); - final mutation = client.updatedMutations.single; - expect( - DateTime.parse(mutation.startDateTime!), - DateTime.utc(2026, 6, 8, 14, 15), - ); - expect( - DateTime.parse(mutation.endDateTime!), - DateTime.utc(2026, 6, 8, 15, 45), - ); - expect(mutation.title, equals(null)); - expect(client.guestUpdatePolicies, [CalendarGuestUpdatePolicy.doNotSend]); - final saved = (await repository.loadEventDetail(id))!; - expect(saved.startDateTime, client.remoteEvent!.startDateTime); - expect(saved.endDateTime, client.remoteEvent!.endDateTime); - expect(saved.sourceId, detail.sourceId); - expect(saved.accountId, detail.accountId); - expect(saved.providerCalendarId, detail.providerCalendarId); - expect(saved.title, 'Base'); - }, - ); + test('guarded timing edit replays exact endpoints and guest policy without moving ownership', () async { + final repository = CalendarRepository(database: database); + final id = await _insertEvent( + database, + providerEventId: 'drag-event', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ); + final detail = (await repository.loadEventDetail(id))!; + client.remoteEvent = client._event( + 'drag-event', + title: 'Base', + organizerJson: const {'self': true}, + ); + client.persistEventUpdates = true; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + start: DateTime.utc(2026, 6, 8, 14, 15), + end: DateTime.utc(2026, 6, 8, 15, 45), + ), + timingBaseline: EventTimingBaseline.fromDetail(detail), + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ); + final replay = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ); + expect(await replay.replayDueOps(), 1); + expect(await replay.replayDueOps(), 0); + final mutation = client.updatedMutations.single; + expect( + DateTime.parse(mutation.startDateTime!), + DateTime.utc(2026, 6, 8, 14, 15), + ); + expect( + DateTime.parse(mutation.endDateTime!), + DateTime.utc(2026, 6, 8, 15, 45), + ); + expect(mutation.title, equals(null)); + expect(client.guestUpdatePolicies, [CalendarGuestUpdatePolicy.doNotSend]); + final saved = (await repository.loadEventDetail(id))!; + expect(saved.startDateTime, client.remoteEvent!.startDateTime); + expect(saved.endDateTime, client.remoteEvent!.endDateTime); + expect(saved.sourceId, detail.sourceId); + expect(saved.accountId, detail.accountId); + expect(saved.providerCalendarId, detail.providerCalendarId); + expect(saved.title, 'Base'); + }); for (final throughEngine in [false, true]) { for (final failSettings in [false, true]) { @@ -444,9 +441,8 @@ void main() { ); detail = (await repository.loadEventDetail(local.id))!; await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail, - ).copyWith(location: 'Local room'), + EventEditorDraft.fromEventDetail(detail) + .copyWith(location: 'Local room'), ); final queued = await database.select(database.pendingOps).get(); final create = queued.singleWhere( @@ -860,12 +856,9 @@ void main() { draft.copyWith(end: providerCivilDateTime(DateTime.utc(2026, 3, 8, 4))), ); - final request = - jsonDecode( - (await database.select(database.pendingOps).getSingle()) - .requestJson, - ) - as Map; + final request = jsonDecode( + (await database.select(database.pendingOps).getSingle()).requestJson, + ) as Map; expect(request['start'], '2026-03-08T02:30:00.000'); expect(request['end'], '2026-03-08T04:00:00.000'); }, @@ -1424,9 +1417,8 @@ void main() { sourceId: destination.calendarSourceId, itemId: destination.id, ); - final remembered = await LocationResolutionRepository( - database, - ).load(destinationIdentity, 'Same room'); + final remembered = await LocationResolutionRepository(database) + .load(destinationIdentity, 'Same room'); expect(remembered, isNull); final resolved = await LocationDestinationResolver( @@ -1512,42 +1504,40 @@ void main() { }, ); - test( - 'recurring Google import keeps its supplemental point after create and expanded sync', - () async { + test('recurring Google import keeps its supplemental point after create and expanded sync', () async { + await database.close(); + final directory = await createPersistenceTestDirectory( + 'busymax-google-series-location-', + ); + addTearDown(() async { await database.close(); - final directory = await createPersistenceTestDirectory( - 'busymax-google-series-location-', - ); - addTearDown(() async { - await database.close(); - database = AppDatabase(NativeDatabase.memory()); - await directory.delete(recursive: true); - }); - final databaseFile = File('${directory.path}/busymax.sqlite'); - database = AppDatabase(NativeDatabase(databaseFile)); - await _insertAccount(database); - await CalendarRepository(database: database).upsertSource( - accountId: 'account', - source: const CalendarSourceDto( - provider: BusyProvider.google, - providerCalendarId: 'cal-1', - summary: 'Work', - timeZone: 'America/Vancouver', - dataOwner: 'me@example.com', - ), - ); - final repository = CalendarRepository( - database: database, - now: () => DateTime.utc(2026, 8, 29), - ); - final importService = IcalImportService( - database: database, - calendarRepository: repository, - ); - final preview = importService.parsePreview( - utf8.encode( - _icalCalendar(''' + database = AppDatabase(NativeDatabase.memory()); + await directory.delete(recursive: true); + }); + final databaseFile = File('${directory.path}/busymax.sqlite'); + database = AppDatabase(NativeDatabase(databaseFile)); + await _insertAccount(database); + await CalendarRepository(database: database).upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + timeZone: 'America/Vancouver', + dataOwner: 'me@example.com', + ), + ); + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 8, 29), + ); + final importService = IcalImportService( + database: database, + calendarRepository: repository, + ); + final preview = importService.parsePreview( + utf8.encode( + _icalCalendar(''' BEGIN:VEVENT UID:recurring-location-import DTSTART:20260830T160000Z @@ -1558,225 +1548,222 @@ GEO:49.2827;-123.1207 RRULE:FREQ=WEEKLY;COUNT=2 END:VEVENT '''), - ), - ); + ), + ); - final report = await importService.importPreview( - preview: preview, - destination: (await importService.writableDestinations()).single, - ); - expect(report.queued, 1); - expect( - await database.select(database.locationResolutions).get(), - hasLength(1), - ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 1); + expect( + await database.select(database.locationResolutions).get(), + hasLength(1), + ); - client.createEventOverride = (calendarId, mutation) => CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: calendarId, - providerEventId: mutation.providerEventId ?? 'imported-master', - title: mutation.title!, - location: mutation.location, - startDateTime: mutation.startDateTime, - startTimeZone: mutation.startTimeZone, - endDateTime: mutation.endDateTime, - endTimeZone: mutation.endTimeZone, - recurrenceJson: mutation.recurrence, - rawJson: { - 'id': mutation.providerEventId ?? 'imported-master', - 'summary': mutation.title, - 'location': mutation.location, - 'start': { - 'dateTime': mutation.startDateTime, - 'timeZone': mutation.startTimeZone, - }, - 'end': { - 'dateTime': mutation.endDateTime, - 'timeZone': mutation.endTimeZone, - }, - 'recurrence': mutation.recurrence, + client.createEventOverride = (calendarId, mutation) => CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: calendarId, + providerEventId: mutation.providerEventId ?? 'imported-master', + title: mutation.title!, + location: mutation.location, + startDateTime: mutation.startDateTime, + startTimeZone: mutation.startTimeZone, + endDateTime: mutation.endDateTime, + endTimeZone: mutation.endTimeZone, + recurrenceJson: mutation.recurrence, + rawJson: { + 'id': mutation.providerEventId ?? 'imported-master', + 'summary': mutation.title, + 'location': mutation.location, + 'start': { + 'dateTime': mutation.startDateTime, + 'timeZone': mutation.startTimeZone, }, - ); - expect( - await CalendarPendingOpsReplayer( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 8, 29), - ).replayDueOps(), - 1, - ); - - final master = await database.select(database.calendarEvents).getSingle(); - expect(master.providerRecurringEventId, isNull); - expect(master.recurrenceJson, isNotNull); - final confirmedResolutions = await database - .select(database.locationResolutions) - .get(); - expect(confirmedResolutions, hasLength(2)); - expect( - confirmedResolutions - .where((row) => row.kind == LocationItemKind.event.name) - .single - .itemId, - master.id, - ); - expect( - confirmedResolutions - .where((row) => row.kind == googleSeriesLocationResolutionKind) - .single - .itemId, - master.providerEventId, - ); - - CalendarEventDto occurrence({ - required String id, - required String originalStart, - }) => CalendarEventDto( - provider: BusyProvider.google, - providerCalendarId: 'cal-1', - providerEventId: id, - providerRecurringEventId: master.providerEventId, - providerOriginalStartKey: originalStart, - title: 'Imported series', - location: 'Room 2', - startDateTime: originalStart, - startTimeZone: 'UTC', - endDateTime: DateTime.parse( - originalStart, - ).add(const Duration(hours: 1)).toIso8601String(), - endTimeZone: 'UTC', - rawJson: { - 'id': id, - 'recurringEventId': master.providerEventId, - 'originalStartTime': {'dateTime': originalStart}, - 'summary': 'Imported series', - 'location': 'Room 2', + 'end': { + 'dateTime': mutation.endDateTime, + 'timeZone': mutation.endTimeZone, }, - ); - - client.syncEventsOverride = [ - occurrence( - id: 'import-instance-1', - originalStart: '2026-08-30T16:00:00.000Z', - ), - ]; - await CalendarSyncEngine( + 'recurrence': mutation.recurrence, + }, + ); + expect( + await CalendarPendingOpsReplayer( database: database, client: client, accountId: 'account', nowUtc: () => DateTime.utc(2026, 8, 29), - ).fullSync(); + ).replayDueOps(), + 1, + ); - final events = await database.select(database.calendarEvents).get(); - expect( - events.singleWhere((event) => event.id == master.id).isDeleted, - isTrue, - ); - final instances = events - .where( - (event) => event.providerRecurringEventId == master.providerEventId, - ) - .toList(); - expect(instances, hasLength(1), reason: events.toString()); - expect( - instances, - everyElement( - isNot(predicate((event) => event.isDeleted)), - ), - ); - final rememberedBeforeResolution = await database - .select(database.locationResolutions) - .get(); - expect( - rememberedBeforeResolution, - hasLength(1), - reason: 'calls=${client.calls}, events=$events, master=$master', - ); - expect( - rememberedBeforeResolution.single.kind, - googleSeriesLocationResolutionKind, - ); - expect(rememberedBeforeResolution.single.itemId, master.providerEventId); - expect( - rememberedBeforeResolution, - everyElement( - isA() - .having((row) => row.latitude, 'latitude', 49.2827) - .having((row) => row.longitude, 'longitude', -123.1207) - .having((row) => row.source, 'source', 'ical') - .having( - (row) => row.attribution, - 'attribution', - 'Imported iCalendar GEO', - ), - ), - ); + final master = await database.select(database.calendarEvents).getSingle(); + expect(master.providerRecurringEventId, isNull); + expect(master.recurrenceJson, isNotNull); + final confirmedResolutions = await database + .select(database.locationResolutions) + .get(); + expect(confirmedResolutions, hasLength(2)); + expect( + confirmedResolutions + .where((row) => row.kind == LocationItemKind.event.name) + .single + .itemId, + master.id, + ); + expect( + confirmedResolutions + .where((row) => row.kind == googleSeriesLocationResolutionKind) + .single + .itemId, + master.providerEventId, + ); - final resolver = LocationDestinationResolver( - LocationResolutionRepository(database), - ); - for (final instance in instances) { - final resolved = await resolver.resolveSaved( - location: instance.location ?? '', - identity: LocationItemIdentity( - kind: LocationItemKind.event, - accountId: instance.accountId, - sourceId: instance.calendarSourceId, - itemId: instance.id, - ), - ); - expect( - resolved?.point, - GeographicPoint(latitude: 49.2827, longitude: -123.1207), - ); - } - expect( - await database.select(database.locationResolutions).get(), - rememberedBeforeResolution, - ); + CalendarEventDto occurrence({ + required String id, + required String originalStart, + }) => CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: id, + providerRecurringEventId: master.providerEventId, + providerOriginalStartKey: originalStart, + title: 'Imported series', + location: 'Room 2', + startDateTime: originalStart, + startTimeZone: 'UTC', + endDateTime: DateTime.parse(originalStart) + .add(const Duration(hours: 1)) + .toIso8601String(), + endTimeZone: 'UTC', + rawJson: { + 'id': id, + 'recurringEventId': master.providerEventId, + 'originalStartTime': {'dateTime': originalStart}, + 'summary': 'Imported series', + 'location': 'Room 2', + }, + ); - await database.close(); - database = AppDatabase(NativeDatabase(databaseFile)); - final reopenedResolver = LocationDestinationResolver( - LocationResolutionRepository(database), - ); + client.syncEventsOverride = [ + occurrence( + id: 'import-instance-1', + originalStart: '2026-08-30T16:00:00.000Z', + ), + ]; + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ).fullSync(); - client.syncEventsOverride = [ - occurrence( - id: 'import-instance-later', - originalStart: '2026-09-06T16:00:00.000Z', + final events = await database.select(database.calendarEvents).get(); + expect( + events.singleWhere((event) => event.id == master.id).isDeleted, + isTrue, + ); + final instances = events + .where( + (event) => event.providerRecurringEventId == master.providerEventId, + ) + .toList(); + expect(instances, hasLength(1), reason: events.toString()); + expect( + instances, + everyElement(isNot(predicate((event) => event.isDeleted))), + ); + final rememberedBeforeResolution = await database + .select(database.locationResolutions) + .get(); + expect( + rememberedBeforeResolution, + hasLength(1), + reason: 'calls=${client.calls}, events=$events, master=$master', + ); + expect( + rememberedBeforeResolution.single.kind, + googleSeriesLocationResolutionKind, + ); + expect(rememberedBeforeResolution.single.itemId, master.providerEventId); + expect( + rememberedBeforeResolution, + everyElement( + isA() + .having((row) => row.latitude, 'latitude', 49.2827) + .having((row) => row.longitude, 'longitude', -123.1207) + .having((row) => row.source, 'source', 'ical') + .having( + (row) => row.attribution, + 'attribution', + 'Imported iCalendar GEO', + ), + ), + ); + + final resolver = LocationDestinationResolver( + LocationResolutionRepository(database), + ); + for (final instance in instances) { + final resolved = await resolver.resolveSaved( + location: instance.location ?? '', + identity: LocationItemIdentity( + kind: LocationItemKind.event, + accountId: instance.accountId, + sourceId: instance.calendarSourceId, + itemId: instance.id, ), - ]; - await CalendarSyncEngine( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 9, 5), - ).fullSync(); - final later = (await database.select(database.calendarEvents).get()) - .singleWhere( - (event) => event.providerEventId == 'import-instance-later', - ); - expect( - (await reopenedResolver.resolveSaved( - location: later.location ?? '', - identity: LocationItemIdentity( - kind: LocationItemKind.event, - accountId: later.accountId, - sourceId: later.calendarSourceId, - itemId: later.id, - ), - ))?.point, - GeographicPoint(latitude: 49.2827, longitude: -123.1207), ); expect( - await database.select(database.locationResolutions).get(), - rememberedBeforeResolution, + resolved?.point, + GeographicPoint(latitude: 49.2827, longitude: -123.1207), ); - expect(await database.select(database.pendingOps).get(), isEmpty); - }, - ); + } + expect( + await database.select(database.locationResolutions).get(), + rememberedBeforeResolution, + ); + + await database.close(); + database = AppDatabase(NativeDatabase(databaseFile)); + final reopenedResolver = LocationDestinationResolver( + LocationResolutionRepository(database), + ); + + client.syncEventsOverride = [ + occurrence( + id: 'import-instance-later', + originalStart: '2026-09-06T16:00:00.000Z', + ), + ]; + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 9, 5), + ).fullSync(); + final later = (await database.select(database.calendarEvents).get()) + .singleWhere( + (event) => event.providerEventId == 'import-instance-later', + ); + expect( + (await reopenedResolver.resolveSaved( + location: later.location ?? '', + identity: LocationItemIdentity( + kind: LocationItemKind.event, + accountId: later.accountId, + sourceId: later.calendarSourceId, + itemId: later.id, + ), + ))?.point, + GeographicPoint(latitude: 49.2827, longitude: -123.1207), + ); + expect( + await database.select(database.locationResolutions).get(), + rememberedBeforeResolution, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); test('explicit replacement and clear win while copying an event', () async { await _insertMicrosoftAccountAndSource(database); @@ -1940,9 +1927,8 @@ END:VEVENT ); expect( - await LocationResolutionRepository( - database, - ).load(unrelatedIdentity, 'Shared hall'), + await LocationResolutionRepository(database) + .load(unrelatedIdentity, 'Shared hall'), unrelatedSelection, ); final copied = @@ -2021,9 +2007,8 @@ END:VEVENT isEmpty, ); expect( - await LocationResolutionRepository( - database, - ).load(sourceIdentity, 'Source room'), + await LocationResolutionRepository(database) + .load(sourceIdentity, 'Source room'), isNotNull, ); expect( @@ -2098,9 +2083,8 @@ END:VEVENT itemId: destination.id, ); expect( - await LocationResolutionRepository( - database, - ).load(destinationIdentity, 'Mapped room'), + await LocationResolutionRepository(database) + .load(destinationIdentity, 'Mapped room'), isNull, ); final resolved = @@ -2448,9 +2432,8 @@ END:VEVENT ); final detail = (await repository.loadEventDetail(eventId))!; await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail, - ).copyWith(eventLabelId: 'label-1'), + EventEditorDraft.fromEventDetail(detail) + .copyWith(eventLabelId: 'label-1'), ); final requests = []; final api = GoogleCalendarApiClient( @@ -2708,9 +2691,8 @@ END:VEVENT providerEventId: 'label-conflict', ); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - (await repository.loadEventDetail(id))!, - ).copyWith(eventLabelId: 'label-b'), + EventEditorDraft.fromEventDetail((await repository.loadEventDetail(id))!) + .copyWith(eventLabelId: 'label-b'), ); final requests = []; final api = GoogleCalendarApiClient( @@ -3077,13 +3059,9 @@ END:VEVENT ).replayDueOps(), 1, ); - final posted = - jsonDecode( - requests - .singleWhere((request) => request.method == 'POST') - .body, - ) - as Map; + final posted = jsonDecode( + requests.singleWhere((request) => request.method == 'POST').body, + ) as Map; expect(posted['eventType'], 'workingLocation'); expect((posted['workingLocationProperties'] as Map)['customLocation'], { 'label': 'Library', @@ -3711,170 +3689,163 @@ END:VEVENT }, ); - test( - 'Google following title/time split copies the series point once across retry and sync', - () async { - final repository = CalendarRepository(database: database); - final ids = []; - for (final day in [1, 8, 15]) { - ids.add( - await _insertGoogleOccurrence( - repository, - day: day, - location: 'Room 2', - ), - ); - } - await _insertGoogleSeriesResolution(database); - final detail = await repository.loadEventDetail(ids[1]); - await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail!).copyWith( - title: 'New series title', - start: DateTime.utc(2026, 6, 8, 10), - end: DateTime.utc(2026, 6, 8, 11), - recurringMutationScope: RecurringEventMutationScope.thisAndFuture, - ), - ); - final operation = await database.select(database.pendingOps).getSingle(); - final splitSeriesId = operation.id.replaceAll('-', '').toLowerCase(); - client - ..remoteEvent = _googleSeriesMaster(location: 'Room 2') - ..eventInstances = [ - _googleSeriesInstance(day: 1, location: 'Room 2'), - _googleSeriesInstance(day: 8, location: 'Room 2'), - _googleSeriesInstance(day: 15, location: 'Room 2'), - ] - ..createEventResponseError = StateError('response lost'); - - expect( - await CalendarPendingOpsReplayer( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 8), - ).replayDueOps(), - 0, - ); - expect( - (await database.select(database.locationResolutions).get()).map( - (row) => row.itemId, - ), - ['series-master'], + test('Google following title/time split copies the series point once across retry and sync', () async { + final repository = CalendarRepository(database: database); + final ids = []; + for (final day in [1, 8, 15]) { + ids.add( + await _insertGoogleOccurrence(repository, day: day, location: 'Room 2'), ); + } + await _insertGoogleSeriesResolution(database); + final detail = await repository.loadEventDetail(ids[1]); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'New series title', + start: DateTime.utc(2026, 6, 8, 10), + end: DateTime.utc(2026, 6, 8, 11), + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + final operation = await database.select(database.pendingOps).getSingle(); + final splitSeriesId = operation.id.replaceAll('-', '').toLowerCase(); + client + ..remoteEvent = _googleSeriesMaster(location: 'Room 2') + ..eventInstances = [ + _googleSeriesInstance(day: 1, location: 'Room 2'), + _googleSeriesInstance(day: 8, location: 'Room 2'), + _googleSeriesInstance(day: 15, location: 'Room 2'), + ] + ..createEventResponseError = StateError('response lost'); - expect( - await CalendarPendingOpsReplayer( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 9), - ).replayDueOps(), - 1, - ); - expect(client.distinctCreatedEventCount, 1); - expect( - client.createdMutations.map((mutation) => mutation.providerEventId), - [splitSeriesId, splitSeriesId], - ); - final seriesResolutions = await database - .select(database.locationResolutions) - .get(); - expect(seriesResolutions, hasLength(2)); - expect(seriesResolutions.map((row) => row.itemId).toSet(), { - 'series-master', - splitSeriesId, - }); - expect( - seriesResolutions, - everyElement( - isA() - .having((row) => row.locationText, 'location', 'Room 2') - .having((row) => row.latitude, 'latitude', 49.2827) - .having((row) => row.longitude, 'longitude', -123.1207) - .having((row) => row.source, 'source', 'ical') - .having( - (row) => row.attribution, - 'attribution', - 'Imported iCalendar GEO', - ), - ), - ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + expect( + (await database.select(database.locationResolutions).get()).map( + (row) => row.itemId, + ), + ['series-master'], + ); - client.syncEventsOverride = [ - _googleSeriesInstance(day: 1, location: 'Room 2'), - _googleSeriesInstance( - day: 8, - id: 'split-instance-08', - seriesId: splitSeriesId, - title: 'New series title', - location: 'Room 2', - startHour: 10, - ), - _googleSeriesInstance( - day: 15, - id: 'split-instance-15', - seriesId: splitSeriesId, - title: 'New series title', - location: 'Room 2', - startHour: 10, - ), - ]; - await CalendarSyncEngine( + expect( + await CalendarPendingOpsReplayer( database: database, client: client, accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 10), - ).fullSync(); + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 1, + ); + expect(client.distinctCreatedEventCount, 1); + expect( + client.createdMutations.map((mutation) => mutation.providerEventId), + [splitSeriesId, splitSeriesId], + ); + final seriesResolutions = await database + .select(database.locationResolutions) + .get(); + expect(seriesResolutions, hasLength(2)); + expect(seriesResolutions.map((row) => row.itemId).toSet(), { + 'series-master', + splitSeriesId, + }); + expect( + seriesResolutions, + everyElement( + isA() + .having((row) => row.locationText, 'location', 'Room 2') + .having((row) => row.latitude, 'latitude', 49.2827) + .having((row) => row.longitude, 'longitude', -123.1207) + .having((row) => row.source, 'source', 'ical') + .having( + (row) => row.attribution, + 'attribution', + 'Imported iCalendar GEO', + ), + ), + ); - final active = (await database.select(database.calendarEvents).get()) - .where((event) => !event.isDeleted) - .toList(); - expect(active, hasLength(3)); - expect(active.map((event) => event.providerRecurringEventId).toSet(), { - 'series-master', - splitSeriesId, - }); - for (final event in active) { - final resolved = await LocationResolutionRepository(database).load( - LocationItemIdentity( - kind: LocationItemKind.event, - accountId: event.accountId, - sourceId: event.calendarSourceId, - itemId: event.id, - ), - event.location ?? '', - ); - expect( - resolved, - isA() - .having( - (result) => result.point, - 'point', - GeographicPoint(latitude: 49.2827, longitude: -123.1207), - ) - .having((result) => result.source, 'source', 'ical') - .having( - (result) => result.attribution, - 'attribution', - 'Imported iCalendar GEO', - ), - ); - } - expect( - await CalendarPendingOpsReplayer( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 11), - ).replayDueOps(), - 0, + client.syncEventsOverride = [ + _googleSeriesInstance(day: 1, location: 'Room 2'), + _googleSeriesInstance( + day: 8, + id: 'split-instance-08', + seriesId: splitSeriesId, + title: 'New series title', + location: 'Room 2', + startHour: 10, + ), + _googleSeriesInstance( + day: 15, + id: 'split-instance-15', + seriesId: splitSeriesId, + title: 'New series title', + location: 'Room 2', + startHour: 10, + ), + ]; + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 10), + ).fullSync(); + + final active = (await database.select(database.calendarEvents).get()) + .where((event) => !event.isDeleted) + .toList(); + expect(active, hasLength(3)); + expect(active.map((event) => event.providerRecurringEventId).toSet(), { + 'series-master', + splitSeriesId, + }); + for (final event in active) { + final resolved = await LocationResolutionRepository(database).load( + LocationItemIdentity( + kind: LocationItemKind.event, + accountId: event.accountId, + sourceId: event.calendarSourceId, + itemId: event.id, + ), + event.location ?? '', ); expect( - await database.select(database.locationResolutions).get(), - hasLength(2), + resolved, + isA() + .having( + (result) => result.point, + 'point', + GeographicPoint(latitude: 49.2827, longitude: -123.1207), + ) + .having((result) => result.source, 'source', 'ical') + .having( + (result) => result.attribution, + 'attribution', + 'Imported iCalendar GEO', + ), ); - }, - ); + } + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 11), + ).replayDueOps(), + 0, + ); + expect( + await database.select(database.locationResolutions).get(), + hasLength(2), + ); + }); test( 'Google following location split keeps only the earlier series point', @@ -4400,9 +4371,8 @@ END:VEVENT address: const {'city': 'First city'}, ); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail, - ).copyWith(locationChange: LocationChange.replace(first)), + EventEditorDraft.fromEventDetail(detail) + .copyWith(locationChange: LocationChange.replace(first)), ); detail = (await repository.loadEventDetail(eventId))!; final second = LocationResult( @@ -4411,9 +4381,8 @@ END:VEVENT address: const {'city': 'Second city'}, ); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail, - ).copyWith(locationChange: LocationChange.replace(second)), + EventEditorDraft.fromEventDetail(detail) + .copyWith(locationChange: LocationChange.replace(second)), ); final replayer = CalendarPendingOpsReplayer( @@ -4714,15 +4683,13 @@ END:VEVENT var detail = await repository.loadEventDetail(eventId); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail!, - ).copyWith(title: 'First local title'), + EventEditorDraft.fromEventDetail(detail!) + .copyWith(title: 'First local title'), ); detail = await repository.loadEventDetail(eventId); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail!, - ).copyWith(location: 'Second local room'), + EventEditorDraft.fromEventDetail(detail!) + .copyWith(location: 'Second local room'), ); client @@ -5322,9 +5289,8 @@ END:VEVENT providerEventId: 'provider-event', ); - final accountId = await CalendarRepository( - database: database, - ).deleteLocalEvent(eventId); + final accountId = await CalendarRepository(database: database) + .deleteLocalEvent(eventId); expect(accountId, 'account'); final pending = await database.pendingOpsDao.pendingOpsForReplay( @@ -5670,9 +5636,8 @@ END:VEVENT pending = await database.select(database.pendingOps).getSingle(); expect(pending.attemptCount, 1); expect( - DateTime.parse( - pending.nextAttemptAtUtc!, - ).isAfter(DateTime.utc(2026, 6, 8)), + DateTime.parse(pending.nextAttemptAtUtc!) + .isAfter(DateTime.utc(2026, 6, 8)), isTrue, ); expect( @@ -5903,9 +5868,8 @@ END:VEVENT await (database.update(database.calendarSources) ..where((row) => row.id.equals('account|google|cal-1'))) .write(const CalendarSourcesCompanion(hidden: Value(true))); - await CalendarRepository( - database: database, - ).deleteLocalSource('account|google|cal-1'); + await CalendarRepository(database: database) + .deleteLocalSource('account|google|cal-1'); client.calendarDeleteError = const GoogleCalendarApiError( statusCode: 403, code: 'forbidden', @@ -5938,9 +5902,8 @@ END:VEVENT )..where((row) => row.id.equals('account|google|cal-1'))).write( const CalendarSourcesCompanion(dataOwner: Value('other@example.com')), ); - await CalendarRepository( - database: database, - ).deleteLocalSource('account|google|cal-1'); + await CalendarRepository(database: database) + .deleteLocalSource('account|google|cal-1'); client.calendarListDeleteError = const GoogleCalendarApiError( statusCode: 403, code: 'forbidden', @@ -5971,9 +5934,8 @@ END:VEVENT () async { await _insertMicrosoftAccountAndSource(database, isRemovable: true); const sourceId = 'microsoft-account|microsoft|ms-cal-1'; - await CalendarRepository( - database: database, - ).deleteLocalSource(sourceId); + await CalendarRepository(database: database) + .deleteLocalSource(sourceId); final microsoftClient = _FakeMicrosoftCalendarClient() ..calendarDeleteError = MicrosoftCalendarApiError( statusCode: statusCode, @@ -6285,9 +6247,8 @@ Future _insertEvent( }, }, ); - await CalendarRepository( - database: database, - ).upsertEvent(accountId: 'account', event: event); + await CalendarRepository(database: database) + .upsertEvent(accountId: 'account', event: event); return CalendarRepository.eventId( accountId: 'account', provider: BusyProvider.google, @@ -6422,9 +6383,9 @@ CalendarEventDto _googleSeriesInstance({ final start = actualStart?.toIso8601String() ?? '2026-06-${date}T${startHour.toString().padLeft(2, '0')}:00:00.000Z'; - final end = DateTime.parse( - start, - ).add(const Duration(hours: 1)).toIso8601String(); + final end = DateTime.parse(start) + .add(const Duration(hours: 1)) + .toIso8601String(); final eventId = id ?? 'instance-$date'; return CalendarEventDto( provider: BusyProvider.google, @@ -6455,9 +6416,8 @@ List _weeklyOccurrenceStarts(CalendarEventMutation mutation) { final recurrence = (mutation.recurrence as List?) ?.map((value) => value.toString()) .firstWhere((value) => value.startsWith('RRULE:'), orElse: () => ''); - final countMatch = RegExp( - r'(?:^|;)COUNT=(\d+)(?:;|$)', - ).firstMatch(recurrence ?? ''); + final countMatch = RegExp(r'(?:^|;)COUNT=(\d+)(?:;|$)') + .firstMatch(recurrence ?? ''); final count = int.parse(countMatch!.group(1)!); final start = DateTime.parse(mutation.startDateTime!); return List.generate( diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index f30b893e..6010079f 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'dart:convert'; + import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/notifications/notification_scheduler.dart'; + import '../../support/recording_notification_backend.dart'; + import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_provider_capabilities.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; @@ -634,123 +637,120 @@ void main() { }, ); - test( - 'Microsoft calendar-view occurrences hydrate their master for whole-series mutations', - () async { - const source = CalendarSourceDto( - provider: BusyProvider.microsoft, - providerCalendarId: 'cal-1', - summary: 'Work', - ); - await _insertAccount(database, provider: BusyProvider.microsoft); - const master = CalendarEventDto( - provider: BusyProvider.microsoft, - providerCalendarId: 'cal-1', - providerEventId: 'series-1', - title: 'Weekly planning', - startDateTime: '2026-07-14T09:00:00.000Z', - endDateTime: '2026-07-14T10:00:00.000Z', - recurrenceJson: { - 'pattern': {'type': 'weekly', 'interval': 1}, - 'range': {'type': 'noEnd', 'startDate': '2026-07-14'}, - }, - eventType: 'seriesMaster', - updatedAtServer: '2026-07-01T00:00:00.000Z', - rawJson: { - 'id': 'series-1', - 'subject': 'Weekly planning', - 'type': 'seriesMaster', - 'lastModifiedDateTime': '2026-07-01T00:00:00.000Z', - }, - ); - const occurrence = CalendarEventDto( - provider: BusyProvider.microsoft, - providerCalendarId: 'cal-1', - providerEventId: 'occurrence-1', - providerRecurringEventId: 'series-1', - providerOriginalStartKey: '2026-07-14T09:00:00.000Z', - title: 'Weekly planning', - startDateTime: '2026-07-14T09:00:00.000Z', - endDateTime: '2026-07-14T10:00:00.000Z', - eventType: 'occurrence', - updatedAtServer: '2026-07-10T00:00:00.000Z', - rawJson: { - 'id': 'occurrence-1', - 'seriesMasterId': 'series-1', - 'originalStart': '2026-07-14T09:00:00.000Z', - 'type': 'occurrence', - 'lastModifiedDateTime': '2026-07-10T00:00:00.000Z', - 'isOrganizer': true, - }, - ); - final client = _FakeCalendarClient( - provider: BusyProvider.microsoft, - calendars: const [source], - eventsById: const {'series-1': master}, - pages: const [ - CalendarSyncPageDto(events: [occurrence]), - ], - ); + test('Microsoft calendar-view occurrences hydrate their master for whole-series mutations', () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + const master = CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + providerEventId: 'series-1', + title: 'Weekly planning', + startDateTime: '2026-07-14T09:00:00.000Z', + endDateTime: '2026-07-14T10:00:00.000Z', + recurrenceJson: { + 'pattern': {'type': 'weekly', 'interval': 1}, + 'range': {'type': 'noEnd', 'startDate': '2026-07-14'}, + }, + eventType: 'seriesMaster', + updatedAtServer: '2026-07-01T00:00:00.000Z', + rawJson: { + 'id': 'series-1', + 'subject': 'Weekly planning', + 'type': 'seriesMaster', + 'lastModifiedDateTime': '2026-07-01T00:00:00.000Z', + }, + ); + const occurrence = CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + providerEventId: 'occurrence-1', + providerRecurringEventId: 'series-1', + providerOriginalStartKey: '2026-07-14T09:00:00.000Z', + title: 'Weekly planning', + startDateTime: '2026-07-14T09:00:00.000Z', + endDateTime: '2026-07-14T10:00:00.000Z', + eventType: 'occurrence', + updatedAtServer: '2026-07-10T00:00:00.000Z', + rawJson: { + 'id': 'occurrence-1', + 'seriesMasterId': 'series-1', + 'originalStart': '2026-07-14T09:00:00.000Z', + 'type': 'occurrence', + 'lastModifiedDateTime': '2026-07-10T00:00:00.000Z', + 'isOrganizer': true, + }, + ); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + eventsById: const {'series-1': master}, + pages: const [ + CalendarSyncPageDto(events: [occurrence]), + ], + ); - await CalendarSyncEngine( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 7, 10), - ).fullSync(); + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).fullSync(); - final occurrenceId = CalendarRepository.eventId( - accountId: 'account', - provider: BusyProvider.microsoft, - providerCalendarId: source.providerCalendarId, - providerEventId: occurrence.providerEventId, - providerOriginalStartKey: occurrence.providerOriginalStartKey, - ); - final masterId = CalendarRepository.eventId( - accountId: 'account', - provider: BusyProvider.microsoft, - providerCalendarId: source.providerCalendarId, - providerEventId: master.providerEventId, - ); - final storedMaster = await (database.select( - database.calendarEvents, - )..where((row) => row.id.equals(masterId))).getSingle(); - expect(storedMaster.isDeleted, isTrue); - expect(storedMaster.updatedAtServer, master.updatedAtServer); - expect((jsonDecode(storedMaster.rawJson!) as Map)['id'], 'series-1'); - final repository = CalendarRepository(database: database); - final detail = await repository.loadEventDetail(occurrenceId); - await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail(detail!).copyWith( - title: 'Renamed series', - recurringMutationScope: RecurringEventMutationScope.entireSeries, - ), - ); - await repository.deleteLocalEvent( - occurrenceId, - recurringScope: RecurringEventMutationScope.entireSeries, - ); + final occurrenceId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + providerEventId: occurrence.providerEventId, + providerOriginalStartKey: occurrence.providerOriginalStartKey, + ); + final masterId = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + providerEventId: master.providerEventId, + ); + final storedMaster = await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(masterId))).getSingle(); + expect(storedMaster.isDeleted, isTrue); + expect(storedMaster.updatedAtServer, master.updatedAtServer); + expect((jsonDecode(storedMaster.rawJson!) as Map)['id'], 'series-1'); + final repository = CalendarRepository(database: database); + final detail = await repository.loadEventDetail(occurrenceId); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail!).copyWith( + title: 'Renamed series', + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + await repository.deleteLocalEvent( + occurrenceId, + recurringScope: RecurringEventMutationScope.entireSeries, + ); - final operations = await (database.select( - database.pendingOps, - )..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])).get(); - expect(operations, hasLength(2)); - expect(operations.map((operation) => operation.operationType), [ - 'event.patch', - 'event.delete', - ]); - expect( - operations.map((operation) => operation.baselineUpdatedUtc), - everyElement(master.updatedAtServer), - ); - expect( - operations.map( - (operation) => (jsonDecode(operation.baselineRawJson!) as Map)['id'], - ), - everyElement(master.providerEventId), - ); - }, - ); + final operations = await (database.select( + database.pendingOps, + )..orderBy([(row) => OrderingTerm.asc(row.createdAtUtc)])).get(); + expect(operations, hasLength(2)); + expect(operations.map((operation) => operation.operationType), [ + 'event.patch', + 'event.delete', + ]); + expect( + operations.map((operation) => operation.baselineUpdatedUtc), + everyElement(master.updatedAtServer), + ); + expect( + operations.map( + (operation) => (jsonDecode(operation.baselineRawJson!) as Map)['id'], + ), + everyElement(master.providerEventId), + ); + }); test('no-cursor baseline reconciles a missing provider event', () async { const source = CalendarSourceDto( @@ -1224,101 +1224,90 @@ void main() { }, ); - test( - 'A Microsoft ID-only occurrence removal deletes its original row and reminder', - () async { - const source = CalendarSourceDto( + test('A Microsoft ID-only occurrence removal deletes its original row and reminder', () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + summary: 'Calendar', + primaryCalendar: true, + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( provider: BusyProvider.microsoft, providerCalendarId: 'cal-primary', - summary: 'Calendar', - primaryCalendar: true, - ); - await _insertAccount(database, provider: BusyProvider.microsoft); - await _insertSource(database, source); - final repository = CalendarRepository(database: database); - await repository.upsertEvent( - accountId: 'account', - event: const CalendarEventDto( - provider: BusyProvider.microsoft, - providerCalendarId: 'cal-primary', - providerEventId: 'occurrence-1', - providerRecurringEventId: 'master-1', - providerOriginalStartKey: '2026-07-15T09:00:00Z', - title: 'Occurrence', - startDateTime: '2026-07-15T09:00:00Z', - endDateTime: '2026-07-15T10:00:00Z', - remindersJson: { - 'isReminderOn': true, - 'reminderMinutesBeforeStart': 10, - }, - ), - ); - await NotificationScheduleService( - database: database, - nowUtc: () => DateTime.utc(2026, 7, 15, 8), - ).rebuildUpcomingEventNotifications('account'); - expect( - await database.select(database.notificationSchedule).get(), - hasLength(1), - ); - expect( - await ScheduleRepository( - database, - ).listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))), - isNotEmpty, - ); - await repository.saveSyncState( + providerEventId: 'occurrence-1', + providerRecurringEventId: 'master-1', + providerOriginalStartKey: '2026-07-15T09:00:00Z', + title: 'Occurrence', + startDateTime: '2026-07-15T09:00:00Z', + endDateTime: '2026-07-15T10:00:00Z', + remindersJson: {'isReminderOn': true, 'reminderMinutesBeforeStart': 10}, + ), + ); + await NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ).rebuildUpcomingEventNotifications('account'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + expect( + await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))), + isNotEmpty, + ); + await repository.saveSyncState( + accountId: 'account', + provider: BusyProvider.microsoft, + syncKind: 'events', + calendarSourceId: CalendarRepository.sourceId( accountId: 'account', provider: BusyProvider.microsoft, - syncKind: 'events', - calendarSourceId: CalendarRepository.sourceId( - accountId: 'account', - provider: BusyProvider.microsoft, - providerCalendarId: 'cal-primary', - ), - rangeStart: DateTime.utc(2025, 7).toIso8601String(), - rangeEnd: DateTime.utc(2028, 8).toIso8601String(), - cursorKind: 'microsoft_delta_link', - cursorValue: 'https://graph.example/delta', - full: true, - ); - final marker = microsoftCalendarEventFromJson('cal-primary', { - 'id': 'occurrence-1', - '@removed': {'reason': 'deleted'}, - }); - final client = _FakeCalendarClient( - provider: BusyProvider.microsoft, - calendars: const [source], - pages: [ - CalendarSyncPageDto(events: [marker]), - CalendarSyncPageDto(events: [marker]), - ], - missingEventIds: const {'occurrence-1'}, - ); - final engine = CalendarSyncEngine( - database: database, - client: client, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 7, 15, 8), - ); - await engine.incrementalSync(); - await engine.incrementalSync(); - final rows = await database.select(database.calendarEvents).get(); - expect(rows, hasLength(1)); - expect(rows.single.providerOriginalStartKey, '2026-07-15T09:00:00Z'); - expect(rows.single.isDeleted, isTrue); - expect( - await ScheduleRepository( - database, - ).listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))), - isEmpty, - ); - expect( - await database.select(database.notificationSchedule).get(), - isEmpty, - ); - }, - ); + providerCalendarId: 'cal-primary', + ), + rangeStart: DateTime.utc(2025, 7).toIso8601String(), + rangeEnd: DateTime.utc(2028, 8).toIso8601String(), + cursorKind: 'microsoft_delta_link', + cursorValue: 'https://graph.example/delta', + full: true, + ); + final marker = microsoftCalendarEventFromJson('cal-primary', { + 'id': 'occurrence-1', + '@removed': {'reason': 'deleted'}, + }); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + pages: [ + CalendarSyncPageDto(events: [marker]), + CalendarSyncPageDto(events: [marker]), + ], + missingEventIds: const {'occurrence-1'}, + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ); + await engine.incrementalSync(); + await engine.incrementalSync(); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(1)); + expect(rows.single.providerOriginalStartKey, '2026-07-15T09:00:00Z'); + expect(rows.single.isDeleted, isTrue); + expect( + await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))), + isEmpty, + ); + expect(await database.select(database.notificationSchedule).get(), isEmpty); + }); test( 'A Microsoft view removal preserves an event cached from another range', @@ -1380,9 +1369,8 @@ void main() { expect(rows.single.isDeleted, isFalse); expect(rows.single.startDateTime, '2030-01-15T09:00:00Z'); expect( - await ScheduleRepository( - database, - ).listItems(range: ScheduleRange.day(DateTime(2030, 1, 15))), + await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(DateTime(2030, 1, 15))), hasLength(1), ); }, @@ -1895,9 +1883,8 @@ void main() { updatedAtUtc: '2026-07-01T00:00:00.000Z', ), ); - await CalendarRepository( - database: database, - ).upsertSource(accountId: 'other-account', source: unrelated); + await CalendarRepository(database: database) + .upsertSource(accountId: 'other-account', source: unrelated); final selectedId = CalendarRepository.sourceId( accountId: 'account', provider: BusyProvider.google, @@ -1955,9 +1942,9 @@ void main() { providerEventId: id, title: id, startDateTime: start, - endDateTime: DateTime.parse( - start, - ).add(const Duration(hours: 1)).toIso8601String(), + endDateTime: DateTime.parse(start) + .add(const Duration(hours: 1)) + .toIso8601String(), rawJson: {'id': id}, ), ); @@ -2053,9 +2040,8 @@ Future _insertAccount( } Future _insertSource(AppDatabase database, CalendarSourceDto source) { - return CalendarRepository( - database: database, - ).upsertSource(accountId: 'account', source: source); + return CalendarRepository(database: database) + .upsertSource(accountId: 'account', source: source); } Future _insertEvent( @@ -2069,9 +2055,8 @@ Future _insertEvent( providerCalendarId: providerCalendarId, remindersJson: remindersJson, ); - await CalendarRepository( - database: database, - ).upsertEvent(accountId: 'account', event: event); + await CalendarRepository(database: database) + .upsertEvent(accountId: 'account', event: event); return CalendarRepository.eventId( accountId: 'account', provider: provider, diff --git a/test/features/sync/cloud_calendar_range_coordination_test.dart b/test/features/sync/cloud_calendar_range_coordination_test.dart index df19cc80..4eb79b54 100644 --- a/test/features/sync/cloud_calendar_range_coordination_test.dart +++ b/test/features/sync/cloud_calendar_range_coordination_test.dart @@ -139,84 +139,79 @@ void main() { }, ); - test( - 'opening a Microsoft shared calendar coordinates its initial range with sync', - () async { - final authorization = _SharedCalendarAuthorization(); - final discoveryHttp = MockClient((request) async { - expect(request.method, 'GET'); - expect(request.url.pathSegments, [ - 'v1.0', - 'users', - 'owner@example.com', - 'calendar', - ]); - return http.Response( - jsonEncode({ - 'id': 'owner-calendar', - 'name': 'Owner calendar', - 'canEdit': true, - }), - 200, - ); - }); - addTearDown(discoveryHttp.close); - final discovery = MicrosoftCalendarApiClient( - httpClient: discoveryHttp, - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - responseTimeZone: 'UTC', + test('opening a Microsoft shared calendar coordinates its initial range with sync', () async { + final authorization = _SharedCalendarAuthorization(); + final discoveryHttp = MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.pathSegments, [ + 'v1.0', + 'users', + 'owner@example.com', + 'calendar', + ]); + return http.Response( + jsonEncode({ + 'id': 'owner-calendar', + 'name': 'Owner calendar', + 'canEdit': true, + }), + 200, ); - final fixture = await _Fixture.create( - provider: BusyProvider.microsoft, - authorization: authorization, - discovery: discovery, - ); - addTearDown(fixture.dispose); - fixture.client.holdSync = true; + }); + addTearDown(discoveryHttp.close); + final discovery = MicrosoftCalendarApiClient( + httpClient: discoveryHttp, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final fixture = await _Fixture.create( + provider: BusyProvider.microsoft, + authorization: authorization, + discovery: discovery, + ); + addTearDown(fixture.dispose); + fixture.client.holdSync = true; - final sync = fixture.track( - fixture.container - .read(accountSyncOperationsProvider) - .syncCalendar(_accountId, full: true), - ); - await fixture.client.syncStarted.future.timeout( - const Duration(seconds: 2), - ); - final opening = fixture.track( - fixture.container - .read(microsoftSharedCalendarServiceProvider) - .openPrimaryCalendar( - accountId: _accountId, - owner: 'owner@example.com', - ), - ); - await _waitFor(() => fixture.gate.accountIds.length == 2); - final rangeStartedBeforeBaselineCompleted = - fixture.client.rangeStarted.isCompleted; + final sync = fixture.track( + fixture.container + .read(accountSyncOperationsProvider) + .syncCalendar(_accountId, full: true), + ); + await fixture.client.syncStarted.future.timeout(const Duration(seconds: 2)); + final opening = fixture.track( + fixture.container + .read(microsoftSharedCalendarServiceProvider) + .openPrimaryCalendar( + accountId: _accountId, + owner: 'owner@example.com', + ), + ); + await _waitFor(() => fixture.gate.accountIds.length == 2); + final rangeStartedBeforeBaselineCompleted = + fixture.client.rangeStarted.isCompleted; - fixture.client.releaseSync.complete(); - await sync; - final result = await opening; + fixture.client.releaseSync.complete(); + await sync; + final result = await opening; - expect(result.outcome, MicrosoftSharedCalendarOpenOutcome.ready); - expect(authorization.accounts, [_accountId]); - expect(rangeStartedBeforeBaselineCompleted, isFalse); - expect(fixture.gate.accountIds, [_accountId, _accountId]); - final sharedId = const MicrosoftSharedPrimaryCalendarAddress( - owner: 'owner@example.com', - graphCalendarId: 'owner-calendar', - ).sourceCalendarId; - expect(fixture.client.rangeCalendarIds, [sharedId]); - expect( - result.sourceId, - CalendarRepository.sourceId( - accountId: _accountId, - provider: BusyProvider.microsoft, - providerCalendarId: sharedId, - ), - ); - }, - ); + expect(result.outcome, MicrosoftSharedCalendarOpenOutcome.ready); + expect(authorization.accounts, [_accountId]); + expect(rangeStartedBeforeBaselineCompleted, isFalse); + expect(fixture.gate.accountIds, [_accountId, _accountId]); + final sharedId = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.com', + graphCalendarId: 'owner-calendar', + ).sourceCalendarId; + expect(fixture.client.rangeCalendarIds, [sharedId]); + expect( + result.sourceId, + CalendarRepository.sourceId( + accountId: _accountId, + provider: BusyProvider.microsoft, + providerCalendarId: sharedId, + ), + ); + }); } const _accountId = 'account'; @@ -270,9 +265,8 @@ final class _Fixture { providerCalendarId: 'calendar', summary: 'Calendar', ); - await CalendarRepository( - database: database, - ).upsertSource(accountId: _accountId, source: source); + await CalendarRepository(database: database) + .upsertSource(accountId: _accountId, source: source); final client = _HeldCalendarClient( source: source, event: CalendarEventDto( @@ -318,9 +312,8 @@ final class _Fixture { authorization, ), if (discovery != null) - microsoftCalendarApiClientForAccountProvider( - _accountId, - ).overrideWithValue(discovery), + microsoftCalendarApiClientForAccountProvider(_accountId) + .overrideWithValue(discovery), ], ); return _Fixture( diff --git a/test/features/sync/domain_sync_schedule_test.dart b/test/features/sync/domain_sync_schedule_test.dart index 9539b7dd..d39fa04f 100644 --- a/test/features/sync/domain_sync_schedule_test.dart +++ b/test/features/sync/domain_sync_schedule_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'package:drift/native.dart'; import 'package:busymax/src/calendar_providers/cloud_calendar_client.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; @@ -6,8 +7,10 @@ import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_client.dart'; import 'package:busymax/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart'; + import 'dart:async'; import 'dart:convert'; + import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; @@ -263,58 +266,52 @@ void main() { } } - test( - 'concurrent cooldown writers retain the longest value and stale success cannot clear it', - () async { - final entered = Completer(), release = Completer(); - final pull = policy.run( + test('concurrent cooldown writers retain the longest value and stale success cannot clear it', () async { + final entered = Completer(), release = Completer(); + final pull = policy.run( + 'a', + SyncDomain.tasks, + pull: () async { + entered.complete(); + await release.future; + }, + deferred: () async {}, + ); + final rejected = expectLater(pull, throwsA(isA())); + await entered.future; + await Future.wait([ + policy.recordFailureCooldown( 'a', SyncDomain.tasks, - pull: () async { - entered.complete(); - await release.future; - }, - deferred: () async {}, - ); - final rejected = expectLater( - pull, - throwsA(isA()), - ); - await entered.future; - await Future.wait([ - policy.recordFailureCooldown( - 'a', - SyncDomain.tasks, - const TaskRemoteError( - statusCode: 429, - code: 'throttled', - message: 'synthetic', - retryable: true, - retryAfter: Duration(hours: 4), - ), + const TaskRemoteError( + statusCode: 429, + code: 'throttled', + message: 'synthetic', + retryable: true, + retryAfter: Duration(hours: 4), ), - DomainSyncPolicy(db, nowUtc: () => now).recordFailureCooldown( - 'a', - SyncDomain.tasks, - const TaskRemoteError( - statusCode: 429, - code: 'throttled', - message: 'synthetic', - retryable: true, - retryAfter: Duration(seconds: 1), - ), + ), + DomainSyncPolicy(db, nowUtc: () => now).recordFailureCooldown( + 'a', + SyncDomain.tasks, + const TaskRemoteError( + statusCode: 429, + code: 'throttled', + message: 'synthetic', + retryable: true, + retryAfter: Duration(seconds: 1), ), - ]); - release.complete(); - await rejected; - final row = await db.select(db.domainSyncSchedules).getSingle(); - expect( - row.cooldownUntilUtc, - now.add(const Duration(hours: 4)).toIso8601String(), - ); - expect(row.lastSuccessfulPullUtc, isNull); - }, - ); + ), + ]); + release.complete(); + await rejected; + final row = await db.select(db.domainSyncSchedules).getSingle(); + expect( + row.cooldownUntilUtc, + now.add(const Duration(hours: 4)).toIso8601String(), + ); + expect(row.lastSuccessfulPullUtc, isNull); + }); for (final quota in [false, true]) { for (final retryHeader in [null, 'malformed']) { test( @@ -439,211 +436,200 @@ void main() { }, ); } - test( - 'changed paginated workload preserves pages and dispatches local creation without passive polling', - () async { - final requests = []; - final client = GoogleTasksRestApiClient( - httpClient: MockClient((r) async { - requests.add(r); - if (r.method == 'POST') { - return http.Response( - jsonEncode({'id': 'remote-created', 'title': 'Offline'}), - 200, - ); - } - if (r.url.path.endsWith('/lists')) { - return http.Response( - jsonEncode({ - 'items': r.url.queryParameters['pageToken'] == null - ? [] - : [ - {'id': 'l', 'title': 'List'}, - ], - if (r.url.queryParameters['pageToken'] == null) - 'nextPageToken': 'lists-next', - }), - 200, - ); - } + test('changed paginated workload preserves pages and dispatches local creation without passive polling', () async { + final requests = []; + final client = GoogleTasksRestApiClient( + httpClient: MockClient((r) async { + requests.add(r); + if (r.method == 'POST') { + return http.Response( + jsonEncode({'id': 'remote-created', 'title': 'Offline'}), + 200, + ); + } + if (r.url.path.endsWith('/lists')) { return http.Response( jsonEncode({ - 'items': [ - { - 'id': r.url.queryParameters['pageToken'] == null - ? 't1' - : 't2', - 'title': 'Changed', - 'status': 'needsAction', - }, - ], + 'items': r.url.queryParameters['pageToken'] == null + ? [] + : [ + {'id': 'l', 'title': 'List'}, + ], if (r.url.queryParameters['pageToken'] == null) - 'nextPageToken': 'tasks-next', + 'nextPageToken': 'lists-next', }), 200, ); - }), - baseUri: Uri.https('tasks.googleapis.com', '/'), - ); - final engine = SyncEngine( - database: db, - apiClient: client, - accountId: 'a', - nowUtc: () => now, - ); - await withSyncTrigger( - SyncTrigger.background, - () => policy.run( - 'a', - SyncDomain.tasks, - pull: engine.incrementalSync, - deferred: engine.dispatchPendingWrites, - ), - ); - expect(requests.length, 4); - expect(await db.select(db.tasks).get(), hasLength(2)); - await db - .into(db.pendingOps) - .insert( - PendingOpsCompanion.insert( - id: 'op', - accountId: 'a', - entityType: 'task', - operation: 'create_task', - taskListId: const Value('l'), - taskId: const Value('local'), - localTempId: const Value('local'), - requestJson: jsonEncode({ - 'body': {'title': 'Offline'}, - }), - createdAtUtc: now.toIso8601String(), - updatedAtUtc: now.toIso8601String(), - ), - ); - await withSyncTrigger( - SyncTrigger.localMutation, - () => policy.run( - 'a', - SyncDomain.tasks, - pull: engine.incrementalSync, - deferred: engine.dispatchPendingWrites, - ), - ); - expect(requests.length, 5); - expect(requests.last.method, 'POST'); - expect(await db.select(db.pendingOps).get(), isEmpty); - }, - ); - test( - 'foreground and background overlap consume one window under production account coordination', - () async { - final coordinator = AccountSyncCoordinator(); - addTearDown(coordinator.dispose); - final started = Completer(), release = Completer(); - var pulls = 0, reminders = 0; - Future run(SyncTrigger trigger) => withSyncTrigger( - trigger, - () => coordinator.run( - 'a', - () => policy.run( - 'a', - SyncDomain.tasks, - pull: () async { - pulls++; - started.complete(); - await release.future; - }, - deferred: () async { - reminders++; - }, + } + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': r.url.queryParameters['pageToken'] == null ? 't1' : 't2', + 'title': 'Changed', + 'status': 'needsAction', + }, + ], + if (r.url.queryParameters['pageToken'] == null) + 'nextPageToken': 'tasks-next', + }), + 200, + ); + }), + baseUri: Uri.https('tasks.googleapis.com', '/'), + ); + final engine = SyncEngine( + database: db, + apiClient: client, + accountId: 'a', + nowUtc: () => now, + ); + await withSyncTrigger( + SyncTrigger.background, + () => policy.run( + 'a', + SyncDomain.tasks, + pull: engine.incrementalSync, + deferred: engine.dispatchPendingWrites, + ), + ); + expect(requests.length, 4); + expect(await db.select(db.tasks).get(), hasLength(2)); + await db + .into(db.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: 'op', + accountId: 'a', + entityType: 'task', + operation: 'create_task', + taskListId: const Value('l'), + taskId: const Value('local'), + localTempId: const Value('local'), + requestJson: jsonEncode({ + 'body': {'title': 'Offline'}, + }), + createdAtUtc: now.toIso8601String(), + updatedAtUtc: now.toIso8601String(), ), - ), - ); - final background = run(SyncTrigger.background); - await started.future; - final foreground = run(SyncTrigger.foreground); - release.complete(); - await Future.wait([background, foreground]); - expect(pulls, 1); - expect(reminders, 1); - policy = DomainSyncPolicy( - db, - nowUtc: () => now, - ); // restart retains eligibility - await run(SyncTrigger.background); - expect(pulls, 1); - }, - ); - test( - 'Tasks quota defers its domain through restart while Calendar and cached reminders continue', - () async { - var tasks = 0, calendar = 0, reminders = 0; - final operations = RoutingAccountSyncOperations( - providerForAccount: (_) async => BusyProvider.google, - syncDav: (_, {required full}) async {}, - syncWebCal: (_, {required full}) async {}, - syncTasksRest: (_, {required full}) => policy.run( + ); + await withSyncTrigger( + SyncTrigger.localMutation, + () => policy.run( + 'a', + SyncDomain.tasks, + pull: engine.incrementalSync, + deferred: engine.dispatchPendingWrites, + ), + ); + expect(requests.length, 5); + expect(requests.last.method, 'POST'); + expect(await db.select(db.pendingOps).get(), isEmpty); + }); + test('foreground and background overlap consume one window under production account coordination', () async { + final coordinator = AccountSyncCoordinator(); + addTearDown(coordinator.dispose); + final started = Completer(), release = Completer(); + var pulls = 0, reminders = 0; + Future run(SyncTrigger trigger) => withSyncTrigger( + trigger, + () => coordinator.run( + 'a', + () => policy.run( 'a', SyncDomain.tasks, pull: () async { - tasks++; - throw const TaskRemoteError( - statusCode: 403, - code: 'quota', - message: 'Rate limit', - retryable: true, - retryAfter: Duration(hours: 2), - ); - }, - deferred: () async {}, - maintainCached: () async { - reminders++; - }, - ), - syncCalendarRest: (_, {required full}) => policy.run( - 'a', - SyncDomain.calendar, - pull: () async { - calendar++; + pulls++; + started.complete(); + await release.future; }, deferred: () async { reminders++; }, ), - ); - // Use the actual remote error classification, including a long server hint. - await expectLater( - operations.syncAccount('a', full: false), - throwsA(isA()), - ); - expect(tasks, 1); - expect(calendar, 1); - policy = DomainSyncPolicy(db, nowUtc: () => now); - await expectLater( - withSyncTrigger( - SyncTrigger.manual, - () => operations.syncAccount('a', full: false), - ), - throwsA(isA()), - ); - expect(tasks, 1); - expect(calendar, 2); - expect(reminders, 1); - final row = await (db.select( - db.domainSyncSchedules, - )..where((r) => r.domain.equals('tasks'))).getSingle(); - expect( - DateTime.parse(row.cooldownUntilUtc!), - now.add(const Duration(hours: 2)), - ); - now = now.subtract(const Duration(hours: 3)); - await expectLater( - operations.syncTasks('a', full: false), - throwsA(isA()), - ); - expect(tasks, 1); - }, - ); + ), + ); + final background = run(SyncTrigger.background); + await started.future; + final foreground = run(SyncTrigger.foreground); + release.complete(); + await Future.wait([background, foreground]); + expect(pulls, 1); + expect(reminders, 1); + policy = DomainSyncPolicy( + db, + nowUtc: () => now, + ); // restart retains eligibility + await run(SyncTrigger.background); + expect(pulls, 1); + }); + test('Tasks quota defers its domain through restart while Calendar and cached reminders continue', () async { + var tasks = 0, calendar = 0, reminders = 0; + final operations = RoutingAccountSyncOperations( + providerForAccount: (_) async => BusyProvider.google, + syncDav: (_, {required full}) async {}, + syncWebCal: (_, {required full}) async {}, + syncTasksRest: (_, {required full}) => policy.run( + 'a', + SyncDomain.tasks, + pull: () async { + tasks++; + throw const TaskRemoteError( + statusCode: 403, + code: 'quota', + message: 'Rate limit', + retryable: true, + retryAfter: Duration(hours: 2), + ); + }, + deferred: () async {}, + maintainCached: () async { + reminders++; + }, + ), + syncCalendarRest: (_, {required full}) => policy.run( + 'a', + SyncDomain.calendar, + pull: () async { + calendar++; + }, + deferred: () async { + reminders++; + }, + ), + ); + // Use the actual remote error classification, including a long server hint. + await expectLater( + operations.syncAccount('a', full: false), + throwsA(isA()), + ); + expect(tasks, 1); + expect(calendar, 1); + policy = DomainSyncPolicy(db, nowUtc: () => now); + await expectLater( + withSyncTrigger( + SyncTrigger.manual, + () => operations.syncAccount('a', full: false), + ), + throwsA(isA()), + ); + expect(tasks, 1); + expect(calendar, 2); + expect(reminders, 1); + final row = await (db.select( + db.domainSyncSchedules, + )..where((r) => r.domain.equals('tasks'))).getSingle(); + expect( + DateTime.parse(row.cooldownUntilUtc!), + now.add(const Duration(hours: 2)), + ); + now = now.subtract(const Duration(hours: 3)); + await expectLater( + operations.syncTasks('a', full: false), + throwsA(isA()), + ); + expect(tasks, 1); + }); test( 'clock reversal permits stale work and disabled domains issue no requests', () async { diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 9a91f9ac..cb5ae98c 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -58,9 +58,8 @@ void main() { taskId: 'task-1', ); final before = await database.pendingOpsDao.getOp('op-1'); - await AccountsRepository( - database: database, - ).markReconnectRequired('account'); + await AccountsRepository(database: database) + .markReconnectRequired('account'); await expectLater( service.retryNow('op-1'), @@ -82,37 +81,31 @@ void main() { }, ); - test( - 'provider-dependent discard does not read provider state when account needs reconnect', - () async { - await database.tasksDao.upsertTask( - _localTask('task-1', localDirty: true), - ); - await _enqueueBlockedOp( - database, - operation: 'patch_task', - taskListId: 'list-1', - taskId: 'task-1', - ); - await AccountsRepository( - database: database, - ).markReconnectRequired('account'); + test('provider-dependent discard does not read provider state when account needs reconnect', () async { + await database.tasksDao.upsertTask(_localTask('task-1', localDirty: true)); + await _enqueueBlockedOp( + database, + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + ); + await AccountsRepository(database: database) + .markReconnectRequired('account'); - await expectLater( - service.discard('op-1'), - throwsA(isA()), - ); + await expectLater( + service.discard('op-1'), + throwsA(isA()), + ); - expect(apiClient.getTaskIds, isEmpty); - expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); - expect( - await database.tasksDao.listTasks('account', 'list-1'), - hasLength(1), - ); - expect(taskSyncCalls, 0); - expect(calendarSyncCalls, 0); - }, - ); + expect(apiClient.getTaskIds, isEmpty); + expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); + expect( + await database.tasksDao.listTasks('account', 'list-1'), + hasLength(1), + ); + expect(taskSyncCalls, 0); + expect(calendarSyncCalls, 0); + }); test( 'discard blocked task patch refreshes task and clears dirty flags', @@ -212,9 +205,8 @@ void main() { await service.discard('op-1'); - final list = (await database.taskListsDao.listTaskLists( - 'account', - )).single; + final list = (await database.taskListsDao.listTaskLists('account')) + .single; expect(list.title, 'Remote list'); expect(list.localDirty, isFalse); expect(list.pendingDelete, isFalse); @@ -307,9 +299,8 @@ void main() { throwsA(isA()), ); - final list = (await database.taskListsDao.listTaskLists( - 'account', - )).single; + final list = (await database.taskListsDao.listTaskLists('account')) + .single; expect(list.localDirty, isTrue); expect(await database.pendingOpsDao.getOp('op-1'), isNot(equals(null))); expect(taskSyncCalls, 0); @@ -689,9 +680,8 @@ void main() { final event = await database.select(database.calendarEvents).getSingle(); final detail = await repository.loadEventDetail(event.id); await repository.updateLocalEvent( - EventEditorDraft.fromEventDetail( - detail!, - ).copyWith(title: 'Optimistic title'), + EventEditorDraft.fromEventDetail(detail!) + .copyWith(title: 'Optimistic title'), ); final operation = await database.select(database.pendingOps).getSingle(); await _blockOperation(database, operation.id); @@ -863,9 +853,8 @@ void main() { expect(await database.select(database.pendingOps).get(), isEmpty); expect( - (await database.taskListsDao.listTaskLists( - 'account', - )).map((taskList) => taskList.id), + (await database.taskListsDao.listTaskLists('account')) + .map((taskList) => taskList.id), isNot(contains(temporaryTaskListId)), ); expect( @@ -1384,97 +1373,94 @@ void main() { }, ); - test( - 'discard when no checklist item was created removes only the exact account chain', - () async { - final localProjection = encodeTaskChecklistItems([ - TaskChecklistItemEntity.fromJson(const { - 'id': 'local-step', - 'displayName': 'Uncertain', - 'isChecked': true, - }), - ]); - await database.tasksDao.upsertTask( - _localTask('task-1', checklistItemsJson: localProjection), - ); - await _enqueueBlockedOp( - database, - id: 'create-step', - entityType: 'task_checklist_item', - operation: 'create_task_checklist_item', - taskListId: 'list-1', - taskId: 'task-1', - localTempId: 'local-step', - state: 'recovery_required', - request: const {'checklistItemId': 'local-step'}, - ); - await _enqueueBlockedOp( - database, - id: 'patch-step', - entityType: 'task_checklist_item', - operation: 'patch_task_checklist_item', - taskListId: 'list-1', - taskId: 'task-1', - dependsOnOpId: 'create-step', - request: const { - 'checklistItemId': 'local-step', - 'body': {'isChecked': true}, - }, - ); + test('discard when no checklist item was created removes only the exact account chain', () async { + final localProjection = encodeTaskChecklistItems([ + TaskChecklistItemEntity.fromJson(const { + 'id': 'local-step', + 'displayName': 'Uncertain', + 'isChecked': true, + }), + ]); + await database.tasksDao.upsertTask( + _localTask('task-1', checklistItemsJson: localProjection), + ); + await _enqueueBlockedOp( + database, + id: 'create-step', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const {'checklistItemId': 'local-step'}, + ); + await _enqueueBlockedOp( + database, + id: 'patch-step', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + dependsOnOpId: 'create-step', + request: const { + 'checklistItemId': 'local-step', + 'body': {'isChecked': true}, + }, + ); - await _insertAccount(database, accountId: 'other'); - await database.taskListsDao.upsertTaskList( - _localTaskList('list-1', accountId: 'other'), - ); - await database.tasksDao.upsertTask( - _localTask( - 'task-1', - accountId: 'other', - checklistItemsJson: localProjection, - ), - ); - await _enqueueBlockedOp( - database, - id: 'other-create-step', + await _insertAccount(database, accountId: 'other'); + await database.taskListsDao.upsertTaskList( + _localTaskList('list-1', accountId: 'other'), + ); + await database.tasksDao.upsertTask( + _localTask( + 'task-1', accountId: 'other', - entityType: 'task_checklist_item', - operation: 'create_task_checklist_item', - taskListId: 'list-1', - taskId: 'task-1', - localTempId: 'local-step', - state: 'recovery_required', - request: const {'checklistItemId': 'local-step'}, - ); + checklistItemsJson: localProjection, + ), + ); + await _enqueueBlockedOp( + database, + id: 'other-create-step', + accountId: 'other', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const {'checklistItemId': 'local-step'}, + ); - await service.discard('create-step'); + await service.discard('create-step'); - expect(apiClient.checklistPageTokens, [null]); - expect( - decodeTaskChecklistItems( - (await database.tasksDao.listTasks( - 'account', - 'list-1', - )).single.microsoftChecklistItemsJson, - ), - isEmpty, - ); - expect(await database.pendingOpsDao.getOp('create-step'), equals(null)); - expect(await database.pendingOpsDao.getOp('patch-step'), equals(null)); - expect( - await database.pendingOpsDao.getOp('other-create-step'), - isNot(equals(null)), - ); - expect( - decodeTaskChecklistItems( - (await database.tasksDao.listTasks( - 'other', - 'list-1', - )).single.microsoftChecklistItemsJson, - ).single.id, - 'local-step', - ); - }, - ); + expect(apiClient.checklistPageTokens, [null]); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ), + isEmpty, + ); + expect(await database.pendingOpsDao.getOp('create-step'), equals(null)); + expect(await database.pendingOpsDao.getOp('patch-step'), equals(null)); + expect( + await database.pendingOpsDao.getOp('other-create-step'), + isNot(equals(null)), + ); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'other', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single.id, + 'local-step', + ); + }); test('checklist discard remains serialized with replay', () async { final started = Completer(); diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index 3a447928..dc6d20f9 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/features/tasks/domain/task_remote_error.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -485,9 +486,8 @@ void main() { var committed = false; Future replaced(String oldId, String newId) async { callbackCount++; - final ids = (await database.taskListsDao.listTaskLists( - 'account', - )).map((list) => list.id); + final ids = (await database.taskListsDao.listTaskLists('account')) + .map((list) => list.id); committed = oldId == 'temp-list' && newId == 'list-server' && @@ -1104,9 +1104,8 @@ void main() { 'account', 'list-1', )).single; - final item = decodeTaskChecklistItems( - task.microsoftChecklistItemsJson, - ).single; + final item = decodeTaskChecklistItems(task.microsoftChecklistItemsJson) + .single; expect(item.id, 'server-step'); expect(item.completed, isTrue); expect( @@ -1885,9 +1884,8 @@ void main() { ); expect(apiClient.taskListPatchTitles, ['B', 'C']); expect(apiClient.remoteTaskList!.title, 'C'); - final local = (await database.taskListsDao.listTaskLists( - 'account', - )).single; + final local = (await database.taskListsDao.listTaskLists('account')) + .single; expect(local.title, 'C'); expect(local.localDirty, isFalse); }, @@ -2101,9 +2099,8 @@ void main() { nowUtc: () => DateTime.utc(2026, 6, 4), ); await repository.createTaskList('Temporary'); - final temporary = (await database.taskListsDao.listTaskLists( - 'account', - )).singleWhere((list) => list.id.startsWith('local-tasklist-')); + final temporary = (await database.taskListsDao.listTaskLists('account')) + .singleWhere((list) => list.id.startsWith('local-tasklist-')); await repository.renameTaskList(temporary.id, 'Renamed'); await database.tasksDao.upsertTask( TasksCompanion.insert( @@ -3495,9 +3492,8 @@ void main() { nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), ); await repository.createTaskList('Draft list'); - final localList = (await database.taskListsDao.listTaskLists( - 'account', - )).singleWhere((list) => list.id.startsWith('local-tasklist-')); + final localList = (await database.taskListsDao.listTaskLists('account')) + .singleWhere((list) => list.id.startsWith('local-tasklist-')); if (renameBeforeDelete) { await repository.renameTaskList(localList.id, 'Renamed list'); } @@ -3535,9 +3531,8 @@ void main() { nowUtc: () => DateTime.utc(2026, 6, 4, 0, 0, ++mutation), ); await repository.createTaskList('Draft list'); - final localList = (await database.taskListsDao.listTaskLists( - 'account', - )).singleWhere((list) => list.id.startsWith('local-tasklist-')); + final localList = (await database.taskListsDao.listTaskLists('account')) + .singleWhere((list) => list.id.startsWith('local-tasklist-')); await repository.deleteTaskList(localList.id); final graphClient = _ConflictMicrosoftTaskListApiClient( list: const {'id': 'list-server', 'displayName': 'Draft list'}, @@ -4322,9 +4317,8 @@ void main() { 'account', 'list-1', )).single; - final item = decodeTaskChecklistItems( - task.microsoftChecklistItemsJson, - ).single; + final item = decodeTaskChecklistItems(task.microsoftChecklistItemsJson) + .single; expect(applied, 2); expect(checklistClient.checklistCalls, [ 'create:Step', @@ -4408,171 +4402,165 @@ void main() { }, ); - test( - 'checklist claim invalidates a cancellation snapshot before its transaction', - () async { - final checklistClient = _ChecklistTaskRemoteClient(); - final deleteReached = Completer(); - final releaseDelete = Completer(); - final repository = TasksRepository( - database: database, - accountId: 'account', - apiClient: checklistClient, - nowUtc: () => DateTime.utc(2026, 6, 4), - beforeChecklistDeleteTransaction: () async { - deleteReached.complete(); - await releaseDelete.future; - }, - ); - await database.tasksDao.upsertTask(_task('list-1', 'task-1')); - await repository.createSubtask( - taskListId: 'list-1', - parentTaskId: 'task-1', - title: 'Step', - ); - final localItem = decodeTaskChecklistItems( - (await database.tasksDao.listTasks( - 'account', - 'list-1', - )).single.microsoftChecklistItemsJson, - ).single; + test('checklist claim invalidates a cancellation snapshot before its transaction', () async { + final checklistClient = _ChecklistTaskRemoteClient(); + final deleteReached = Completer(); + final releaseDelete = Completer(); + final repository = TasksRepository( + database: database, + accountId: 'account', + apiClient: checklistClient, + nowUtc: () => DateTime.utc(2026, 6, 4), + beforeChecklistDeleteTransaction: () async { + deleteReached.complete(); + await releaseDelete.future; + }, + ); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + title: 'Step', + ); + final localItem = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single; - final deletion = repository.deleteChecklistSubtask( - taskListId: 'list-1', - parentTaskId: 'task-1', - checklistItemId: localItem.id, - ); - await deleteReached.future; - final createGate = Completer(); - checklistClient.createChecklistGate = createGate; - final replay = PendingOpsReplayer( + final deletion = repository.deleteChecklistSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + checklistItemId: localItem.id, + ); + await deleteReached.future; + final createGate = Completer(); + checklistClient.createChecklistGate = createGate; + final replay = PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await _waitFor(() => checklistClient.checklistCalls.isNotEmpty); + releaseDelete.complete(); + await deletion; + + final queued = await database.select(database.pendingOps).get(); + final create = queued.singleWhere( + (operation) => operation.operation == 'create_task_checklist_item', + ); + final delete = queued.singleWhere( + (operation) => operation.operation == 'delete_task_checklist_item', + ); + expect(create.state, 'in_progress'); + expect(delete.dependsOnOpId, create.id); + + createGate.complete(); + expect(await replay, 1); + expect( + await PendingOpsReplayer( database: database, apiClient: checklistClient, accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4, 1), - ).replayDueOps(); - await _waitFor(() => checklistClient.checklistCalls.isNotEmpty); - releaseDelete.complete(); - await deletion; - - final queued = await database.select(database.pendingOps).get(); - final create = queued.singleWhere( - (operation) => operation.operation == 'create_task_checklist_item', - ); - final delete = queued.singleWhere( - (operation) => operation.operation == 'delete_task_checklist_item', - ); - expect(create.state, 'in_progress'); - expect(delete.dependsOnOpId, create.id); + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(checklistClient.checklistCalls, [ + 'create:Step', + 'delete:server-step', + ]); + }); - createGate.complete(); - expect(await replay, 1); - expect( - await PendingOpsReplayer( - database: database, - apiClient: checklistClient, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4, 2), - ).replayDueOps(), - 1, - ); - expect(checklistClient.checklistCalls, [ - 'create:Step', - 'delete:server-step', - ]); - }, - ); + test('checklist deletion resolves a creation acknowledged before its transaction', () async { + final checklistClient = _ChecklistTaskRemoteClient(); + final deleteReached = Completer(); + final releaseDelete = Completer(); + final repository = TasksRepository( + database: database, + accountId: 'account', + apiClient: checklistClient, + nowUtc: () => DateTime.utc(2026, 6, 4), + beforeChecklistDeleteTransaction: () async { + deleteReached.complete(); + await releaseDelete.future; + }, + ); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + await repository.createSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + title: 'Step', + ); + final localItem = decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single; - test( - 'checklist deletion resolves a creation acknowledged before its transaction', - () async { - final checklistClient = _ChecklistTaskRemoteClient(); - final deleteReached = Completer(); - final releaseDelete = Completer(); - final repository = TasksRepository( + final deletion = repository.deleteChecklistSubtask( + taskListId: 'list-1', + parentTaskId: 'task-1', + checklistItemId: localItem.id, + ); + await deleteReached.future; + expect( + await PendingOpsReplayer( database: database, - accountId: 'account', apiClient: checklistClient, - nowUtc: () => DateTime.utc(2026, 6, 4), - beforeChecklistDeleteTransaction: () async { - deleteReached.complete(); - await releaseDelete.future; - }, - ); - await database.tasksDao.upsertTask(_task('list-1', 'task-1')); - await repository.createSubtask( - taskListId: 'list-1', - parentTaskId: 'task-1', - title: 'Step', - ); - final localItem = decodeTaskChecklistItems( + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + decodeTaskChecklistItems( (await database.tasksDao.listTasks( 'account', 'list-1', )).single.microsoftChecklistItemsJson, - ).single; - - final deletion = repository.deleteChecklistSubtask( - taskListId: 'list-1', - parentTaskId: 'task-1', - checklistItemId: localItem.id, - ); - await deleteReached.future; - expect( - await PendingOpsReplayer( - database: database, - apiClient: checklistClient, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4, 1), - ).replayDueOps(), - 1, - ); - expect(await database.select(database.pendingOps).get(), isEmpty); - expect( - decodeTaskChecklistItems( - (await database.tasksDao.listTasks( - 'account', - 'list-1', - )).single.microsoftChecklistItemsJson, - ).single.id, - 'server-step', - ); + ).single.id, + 'server-step', + ); - releaseDelete.complete(); - await deletion; - final pendingDelete = await database - .select(database.pendingOps) - .getSingle(); - expect(pendingDelete.operation, 'delete_task_checklist_item'); - expect( - jsonDecode(pendingDelete.requestJson)['checklistItemId'], - 'server-step', - ); - expect( - decodeTaskChecklistItems( - (await database.tasksDao.listTasks( - 'account', - 'list-1', - )).single.microsoftChecklistItemsJson, - ), - isEmpty, - ); - expect( - await PendingOpsReplayer( - database: database, - apiClient: checklistClient, - accountId: 'account', - nowUtc: () => DateTime.utc(2026, 6, 4, 2), - ).replayDueOps(), - 1, - ); - expect(checklistClient.checklistCalls, [ - 'create:Step', - 'delete:server-step', - ]); - expect(await database.select(database.pendingOps).get(), isEmpty); - }, - ); + releaseDelete.complete(); + await deletion; + final pendingDelete = await database + .select(database.pendingOps) + .getSingle(); + expect(pendingDelete.operation, 'delete_task_checklist_item'); + expect( + jsonDecode(pendingDelete.requestJson)['checklistItemId'], + 'server-step', + ); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ), + isEmpty, + ); + expect( + await PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(checklistClient.checklistCalls, [ + 'create:Step', + 'delete:server-step', + ]); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); test( 'checklist deletion accepts a temporary ID after creation is acknowledged', diff --git a/test/features/sync/sync_engine_test.dart b/test/features/sync/sync_engine_test.dart index 9776da37..5270a2cb 100644 --- a/test/features/sync/sync_engine_test.dart +++ b/test/features/sync/sync_engine_test.dart @@ -1,5 +1,6 @@ import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; + import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/test/features/task_lists/data/task_lists_repository_test.dart b/test/features/task_lists/data/task_lists_repository_test.dart index 11fb2d4e..32613786 100644 --- a/test/features/task_lists/data/task_lists_repository_test.dart +++ b/test/features/task_lists/data/task_lists_repository_test.dart @@ -2,7 +2,9 @@ import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/features/notifications/notification_scheduler.dart'; + import '../../../support/recording_notification_backend.dart'; + import 'dart:convert'; import 'package:drift/drift.dart'; @@ -336,8 +338,7 @@ void main() { id: 'collection', accountId: 'account', hrefKey: '/remote.php/dav/calendars/alex/tasks/', - requestUri: - 'https://cloud.example.test/remote.php/dav/calendars/alex/tasks/', + requestUri: 'https://cloud.example.test/remote.php/dav/calendars/alex/tasks/', displayName: 'Tasks', supportedComponentMask: const Value(2), taskProjectionEnabled: const Value(true), diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index bd1d652e..bab3a05f 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -646,9 +646,8 @@ void main() { 'account', 'list-1', )).single; - final created = decodeTaskChecklistItems( - task.microsoftChecklistItemsJson, - ).single; + final created = decodeTaskChecklistItems(task.microsoftChecklistItemsJson) + .single; var hierarchy = await repository .watchTaskHierarchy('list-1', 'parent') .first; diff --git a/test/features/tasks/domain/task_source_links_test.dart b/test/features/tasks/domain/task_source_links_test.dart index db63f98c..1444e11a 100644 --- a/test/features/tasks/domain/task_source_links_test.dart +++ b/test/features/tasks/domain/task_source_links_test.dart @@ -4,10 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('Google supplied links are deduplicated and unsafe links omitted', () { final links = googleTaskSourceLinks( - assignmentInfoJson: - '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', - linksJson: - '[{"description":"Document","link":"https://docs.google.com/document/d/example"},{"type":"generic","link":"https://example.test/source"},{"type":"email","link":"javascript:alert(1)"}]', + assignmentInfoJson: '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + linksJson: '[{"description":"Document","link":"https://docs.google.com/document/d/example"},{"type":"generic","link":"https://example.test/source"},{"type":"email","link":"javascript:alert(1)"}]', webViewLink: 'https://tasks.google.com/task/example', ); expect(links.map((link) => link.url), [ diff --git a/test/features/tasks/presentation/desktop_time_format_interaction_test.dart b/test/features/tasks/presentation/desktop_time_format_interaction_test.dart index 9cd036a6..f364be64 100644 --- a/test/features/tasks/presentation/desktop_time_format_interaction_test.dart +++ b/test/features/tasks/presentation/desktop_time_format_interaction_test.dart @@ -253,9 +253,8 @@ Future _pump( builder: (context, use24, _) => BusyMaxTimeFormatScope( formatter: BusyMaxTimeFormatter(locale: locale, use24Hour: use24), child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: TextScaler.linear(scale)), + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), child: child!, ), ), diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 5df1859b..8a03e83f 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -44,6 +44,7 @@ import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_client.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_models.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; @@ -106,6 +107,19 @@ class _DelayedAttachmentTestFile extends XFile { Future readAsBytes() async => Uint8List.fromList(const [1, 2, 3]); } +final class _ImmediateTodoAttachmentsClient + implements MicrosoftTodoApiClient, MicrosoftTodoAttachmentsApiClient { + @override + Future listTaskAttachmentsPage({ + required String taskListId, + required String taskId, + String? nextLink, + }) async => const MicrosoftTodoAttachmentsPageDto(attachments: []); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { testWidgets( 'task attachment upload retains its target across selection changes', @@ -161,7 +175,10 @@ void main() { await tester.pumpAndSettle(); final add = find.widgetWithText(FilledButton, 'Attachments').last; await tester.ensureVisible(add); - await tester.tap(add); + await tester.runAsync(() async { + await tester.tap(add); + await Future.delayed(const Duration(milliseconds: 50)); + }); await tester.pump(); expect(file.lengthRequested.isCompleted, isTrue); @@ -172,10 +189,13 @@ void main() { find.byKey(const Key('task-attachments-load')), ); await tester.tap(find.byKey(const Key('task-attachments-load'))); - await tester.pumpAndSettle(); + await _pumpAndSettleGraphIo(tester); expect(find.text('second.txt'), findsOneWidget); - file.releaseLength.complete(3); + await tester.runAsync(() async { + file.releaseLength.complete(3); + await Future.delayed(const Duration(milliseconds: 50)); + }); await tester.pumpAndSettle(); expect(posts, ['/v1.0/me/todo/lists/list-1/tasks/task-1/attachments']); expect(secondTaskLists, 1); @@ -224,24 +244,27 @@ void main() { await tester.pumpAndSettle(); final add = find.widgetWithText(FilledButton, 'Attachments').last; await tester.ensureVisible(add); - await tester.tap(add); - for (var i = 0; i < 10 && !posted; i++) { - await tester.pump(); - } + await tester.runAsync(() async { + await tester.tap(add); + await Future.delayed(const Duration(milliseconds: 50)); + }); expect(posted, isTrue); selection.value = null; await tester.pump(); - response.complete( - http.Response( - jsonEncode({ - 'id': 'attachment-a', - 'name': 'agenda.txt', - 'size': 3, - '@odata.type': '#microsoft.graph.taskFileAttachment', - }), - 201, - ), - ); + await tester.runAsync(() async { + response.complete( + http.Response( + jsonEncode({ + 'id': 'attachment-a', + 'name': 'agenda.txt', + 'size': 3, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }), + 201, + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + }); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect( @@ -300,9 +323,10 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftTodoApiClientForAccountProvider( - 'microsoft:m', - ).overrideWithValue(client), + microsoftTodoApiClientForAccountProvider('microsoft:m') + .overrideWithValue(client), + microsoftMasterCategoriesProvider('microsoft:m') + .overrideWith((ref) async => []), ], child: localizedTestApp( child: Scaffold( @@ -338,10 +362,10 @@ void main() { } final add = find.widgetWithText(FilledButton, 'Attachments').last; await tester.ensureVisible(add); - await tester.tap(add); - for (var i = 0; i < 30 && !held; i++) { - await tester.pump(const Duration(milliseconds: 1)); - } + await tester.runAsync(() async { + await tester.tap(add); + await Future.delayed(const Duration(milliseconds: 50)); + }); try { expect(held, isTrue); expect(coordinator.status(key), AttachmentUploadStatus.committed); @@ -351,9 +375,13 @@ void main() { expect(tester.widget(add).onPressed, isNot(equals(null))); expect(uploads, 1); } finally { - heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); - await tester.pump(); + await tester.runAsync(() async { + heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); } + await _disposeProviderTree(tester); }); testWidgets( @@ -390,14 +418,16 @@ void main() { 'list-1', 'task-1', ); - await coordinator.uploadTask( - client: client, - accountId: 'microsoft:m', - taskListId: 'list-1', - taskId: 'task-1', - name: 'a.txt', - contentType: 'text/plain', - bytes: const [1], + await tester.runAsync( + () => coordinator.uploadTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + name: 'a.txt', + contentType: 'text/plain', + bytes: const [1], + ), ); await tester.pumpWidget( ProviderScope( @@ -405,9 +435,10 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftTodoApiClientForAccountProvider( - 'microsoft:m', - ).overrideWithValue(client), + microsoftTodoApiClientForAccountProvider('microsoft:m') + .overrideWithValue(client), + microsoftMasterCategoriesProvider('microsoft:m') + .overrideWith((ref) async => []), ], child: localizedTestApp( child: Scaffold( @@ -450,14 +481,16 @@ void main() { isTrue, ); expect(coordinator.canSubmit(key), isTrue); - await coordinator.uploadTask( - client: client, - accountId: 'microsoft:m', - taskListId: 'list-1', - taskId: 'task-1', - name: 'b.txt', - contentType: 'text/plain', - bytes: const [2], + await tester.runAsync( + () => coordinator.uploadTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + name: 'b.txt', + contentType: 'text/plain', + bytes: const [2], + ), ); expect(posts, 2); }, @@ -483,7 +516,7 @@ void main() { 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, }), 429, - headers: {'retry-after': '30'}, + headers: {'retry-after': '1'}, ); } return http.Response( @@ -511,9 +544,10 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftTodoApiClientForAccountProvider( - 'microsoft:m', - ).overrideWithValue(client), + microsoftTodoApiClientForAccountProvider('microsoft:m') + .overrideWithValue(client), + microsoftMasterCategoriesProvider('microsoft:m') + .overrideWith((ref) async => []), ], child: localizedTestApp( child: Scaffold( @@ -549,10 +583,10 @@ void main() { } final add = find.widgetWithText(FilledButton, 'Attachments').last; await tester.ensureVisible(add); - await tester.tap(add); - for (var i = 0; i < 20 && posts == 0; i++) { - await tester.pump(const Duration(milliseconds: 1)); - } + await tester.runAsync(() async { + await tester.tap(add); + await Future.delayed(const Duration(milliseconds: 50)); + }); for (var i = 0; i < 3; i++) { await tester.pump(); } @@ -565,14 +599,18 @@ void main() { ), findsOneWidget, ); - now = now.add(const Duration(seconds: 31)); - await tester.pump(const Duration(seconds: 31)); + now = now.add(const Duration(seconds: 2)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1100)), + ); + await tester.pump(); expect(tester.widget(add).onPressed, isNot(equals(null))); - await tester.tap(add); - for (var i = 0; i < 20 && posts < 2; i++) { - await tester.pump(const Duration(milliseconds: 1)); - } + await tester.runAsync(() async { + await tester.tap(add); + await Future.delayed(const Duration(milliseconds: 50)); + }); expect(posts, 2); + await _disposeProviderTree(tester); }, ); @@ -597,24 +635,28 @@ void main() { 'list-1', 'task-1', ); - await expectLater( - coordinator.uploadTask( - client: client, - accountId: 'microsoft:m', - taskListId: 'list-1', - taskId: 'task-1', - name: 'notes.txt', - contentType: 'text/plain', - bytes: [1], + await tester.runAsync( + () => expectLater( + coordinator.uploadTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), ), - throwsA(isA()), ); expect( - await coordinator.reconcileTask( - client: client, - accountId: 'microsoft:m', - taskListId: 'list-1', - taskId: 'task-1', + await tester.runAsync( + () => coordinator.reconcileTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + ), ), AttachmentUploadStatus.unresolved, ); @@ -624,9 +666,10 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftTodoApiClientForAccountProvider( - 'microsoft:m', - ).overrideWithValue(client), + microsoftTodoApiClientForAccountProvider('microsoft:m') + .overrideWithValue(_ImmediateTodoAttachmentsClient()), + microsoftMasterCategoriesProvider('microsoft:m') + .overrideWith((ref) async => []), ], child: localizedTestApp( child: Scaffold( @@ -666,8 +709,10 @@ void main() { expect(coordinator.canSubmit(key), isFalse); await tester.tap(find.text('Retry').last); await tester.pumpAndSettle(); + await tester.pump(); expect(coordinator.canSubmit(key), isTrue); expect(posts, 1); + await _disposeProviderTree(tester); }, ); testWidgets('Linux Microsoft task opens linked resources on demand', ( @@ -716,9 +761,10 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - microsoftTodoApiClientForAccountProvider( - 'microsoft:m', - ).overrideWithValue(client), + microsoftTodoApiClientForAccountProvider('microsoft:m') + .overrideWithValue(client), + microsoftMasterCategoriesProvider('microsoft:m') + .overrideWith((ref) async => []), ], child: localizedTestApp( child: Scaffold( @@ -752,14 +798,15 @@ void main() { await tester.pumpAndSettle(); expect(requests, 0); await tester.tap(find.text('Linked resources')); - await tester.pumpAndSettle(); + await _pumpAndSettleGraphIo(tester); expect(requests, 1); expect(find.text('Launch plan · Planner'), findsOneWidget); await tester.tap(find.byKey(const Key('task-attachments-load'))); - await tester.pumpAndSettle(); + await _pumpAndSettleGraphIo(tester); expect(requests, 2); expect(find.text('Notes.txt'), findsOneWidget); expect(find.text('Task'), findsWidgets); + await _disposeProviderTree(tester); }); testWidgets( @@ -773,8 +820,7 @@ void main() { task: _switchTask( 'assigned', 'Assigned task', - assignmentInfoJson: - '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + assignmentInfoJson: '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', ), taskLists: [ TaskListEntity( @@ -3430,12 +3476,10 @@ Widget _switchingAttachmentEditor( ) => ProviderScope( overrides: [ attachmentUploadCoordinatorProvider.overrideWith((ref) => coordinator), - microsoftTodoApiClientForAccountProvider( - 'microsoft:m', - ).overrideWithValue(client), - microsoftMasterCategoriesProvider( - 'microsoft:m', - ).overrideWith((ref) async => []), + microsoftTodoApiClientForAccountProvider('microsoft:m') + .overrideWithValue(client), + microsoftMasterCategoriesProvider('microsoft:m') + .overrideWith((ref) async => []), ], child: localizedTestApp( child: Scaffold( @@ -3487,6 +3531,23 @@ TaskEntity _switchTask(String id, String title, {String? assignmentInfoJson}) { ); } +Future _drainGraphIo(WidgetTester tester) async { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); +} + +Future _pumpAndSettleGraphIo(WidgetTester tester) async { + await _drainGraphIo(tester); + await tester.pumpAndSettle(); +} + +Future _disposeProviderTree(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); +} + TaskEntity _switchTimedTask(String id, String title) { return TaskEntity( accountId: 'microsoft:m', diff --git a/test/google_tasks/discovery_surface_test.dart b/test/google_tasks/discovery_surface_test.dart index 22150a42..cc49b71a 100644 --- a/test/google_tasks/discovery_surface_test.dart +++ b/test/google_tasks/discovery_surface_test.dart @@ -6,13 +6,10 @@ import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; void main() { test('implemented method set matches cached discovery document', () { - final discovery = - jsonDecode( - File( - 'tool/google_tasks_discovery/cache/tasks_v1_discovery.json', - ).readAsStringSync(), - ) - as Map; + final discovery = jsonDecode( + File('tool/google_tasks_discovery/cache/tasks_v1_discovery.json') + .readAsStringSync(), + ) as Map; final resources = (discovery['resources'] as Map).cast(); final methods = { for (final resourceName in ['tasklists', 'tasks']) diff --git a/test/google_tasks/http/authenticated_http_client_test.dart b/test/google_tasks/http/authenticated_http_client_test.dart index 0c6cb88d..9ad83eef 100644 --- a/test/google_tasks/http/authenticated_http_client_test.dart +++ b/test/google_tasks/http/authenticated_http_client_test.dart @@ -1,4 +1,5 @@ import '../../support/oauth_binding_fixture.dart'; + import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/google_tasks/http/retrying_http_client_test.dart b/test/google_tasks/http/retrying_http_client_test.dart index 0b683bcd..64268f71 100644 --- a/test/google_tasks/http/retrying_http_client_test.dart +++ b/test/google_tasks/http/retrying_http_client_test.dart @@ -1,7 +1,9 @@ import 'package:busymax/src/google_tasks/http/retrying_http_client.dart'; import 'package:flutter_test/flutter_test.dart'; + import 'dart:async'; import 'dart:convert'; + import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -76,41 +78,37 @@ void main() { }, ); } - test( - 'valid short server hint wins over backoff; invalid hint uses bounded backoff', - () async { - for (final hint in ['5', 'invalid', '-5']) { - var calls = 0; - final delays = []; - final client = RetryingHttpClient( - inner: MockClient((r) async { - calls++; - return http.Response( - 'body', - calls == 1 ? 429 : 200, - headers: {'retry-after': hint}, - ); - }), - delay: (d) async { - delays.add(d); - }, - maxRetries: 1, - ); - expect( - (await client.get( - Uri.https('graph.microsoft.com', '/v1.0/me'), - )).statusCode, - 200, - ); - expect( - delays.single, - hint == '5' - ? const Duration(seconds: 5) - : lessThan(const Duration(seconds: 2)), - ); - } - }, - ); + test('valid short server hint wins over backoff; invalid hint uses bounded backoff', () async { + for (final hint in ['5', 'invalid', '-5']) { + var calls = 0; + final delays = []; + final client = RetryingHttpClient( + inner: MockClient((r) async { + calls++; + return http.Response( + 'body', + calls == 1 ? 429 : 200, + headers: {'retry-after': hint}, + ); + }), + delay: (d) async { + delays.add(d); + }, + maxRetries: 1, + ); + expect( + (await client.get(Uri.https('graph.microsoft.com', '/v1.0/me'))) + .statusCode, + 200, + ); + expect( + delays.single, + hint == '5' + ? const Duration(seconds: 5) + : lessThan(const Duration(seconds: 2)), + ); + } + }); test('cancellation aborts waiting for a stalled read transport', () async { final abort = Completer(); final client = RetryingHttpClient( diff --git a/test/google_tasks/oauth/loopback_flow_test.dart b/test/google_tasks/oauth/loopback_flow_test.dart index 92ef4b79..bd9ac6c6 100644 --- a/test/google_tasks/oauth/loopback_flow_test.dart +++ b/test/google_tasks/oauth/loopback_flow_test.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'dart:async'; import 'dart:io'; @@ -78,70 +79,67 @@ void main() { }, ); } - test( - 'cancel during browser launch permits retry and old cleanup cannot close new server', - () async { - final oldLaunched = Completer(), - releaseOld = Completer(), - newLaunched = Completer(); - var launches = 0; - final flow = OAuthLoopbackFlow( - authorizationLauncher: (uri) { - launches++; - if (launches == 1) { - oldLaunched.complete(); - return releaseOld.future; - } - newLaunched.complete(uri); - return Future.value(true); - }, - ); - final a = AuthorizationAttempt(nowUtc: () => DateTime.now().toUtc()); - final b = AuthorizationAttempt(nowUtc: () => DateTime.now().toUtc()); - addTearDown(() => a.dispose()); - addTearDown(() => b.dispose()); - final old = flow.start( - attempt: a, - authorizationEndpoint: _authorizationEndpoint, - clientId: 'fixture', - scope: googleTasksOAuthScope, - ); - final failed = expectLater( - old, - throwsA( - isA().having( - (e) => e.classification, - 'kind', - OAuthFailureKind.cancelled, - ), + test('cancel during browser launch permits retry and old cleanup cannot close new server', () async { + final oldLaunched = Completer(), + releaseOld = Completer(), + newLaunched = Completer(); + var launches = 0; + final flow = OAuthLoopbackFlow( + authorizationLauncher: (uri) { + launches++; + if (launches == 1) { + oldLaunched.complete(); + return releaseOld.future; + } + newLaunched.complete(uri); + return Future.value(true); + }, + ); + final a = AuthorizationAttempt(nowUtc: () => DateTime.now().toUtc()); + final b = AuthorizationAttempt(nowUtc: () => DateTime.now().toUtc()); + addTearDown(() => a.dispose()); + addTearDown(() => b.dispose()); + final old = flow.start( + attempt: a, + authorizationEndpoint: _authorizationEndpoint, + clientId: 'fixture', + scope: googleTasksOAuthScope, + ); + final failed = expectLater( + old, + throwsA( + isA().having( + (e) => e.classification, + 'kind', + OAuthFailureKind.cancelled, ), - ); - await oldLaunched.future; - a.cancel(); - final next = flow.start( - attempt: b, - authorizationEndpoint: _authorizationEndpoint, - clientId: 'fixture', - scope: googleTasksOAuthScope, - ); - final uri = await newLaunched.future; - releaseOld.complete(true); - await failed; - final redirect = Uri.parse(uri.queryParameters['redirect_uri']!); - expect( - (await http.get( - redirect.replace( - queryParameters: { - 'state': uri.queryParameters['state']!, - 'code': 'fresh', - }, - ), - )).statusCode, - HttpStatus.ok, - ); - expect((await next).callback.code, 'fresh'); - }, - ); + ), + ); + await oldLaunched.future; + a.cancel(); + final next = flow.start( + attempt: b, + authorizationEndpoint: _authorizationEndpoint, + clientId: 'fixture', + scope: googleTasksOAuthScope, + ); + final uri = await newLaunched.future; + releaseOld.complete(true); + await failed; + final redirect = Uri.parse(uri.queryParameters['redirect_uri']!); + expect( + (await http.get( + redirect.replace( + queryParameters: { + 'state': uri.queryParameters['state']!, + 'code': 'fresh', + }, + ), + )).statusCode, + HttpStatus.ok, + ); + expect((await next).callback.code, 'fresh'); + }); test('cancelSignIn is idempotent after server already closed', () async { final started = await _startFlow(); @@ -194,9 +192,8 @@ void main() { test('mismatched state cannot terminate legitimate consent', () async { final started = await _startFlow(); expect( - (await http.get( - started.callbackUri(code: 'wrong', state: 'wrong-state'), - )).statusCode, + (await http.get(started.callbackUri(code: 'wrong', state: 'wrong-state'))) + .statusCode, HttpStatus.badRequest, ); expect( @@ -317,9 +314,8 @@ void main() { } test('Windows-reachable OAuth implementation contains no xdg-open', () { - final source = File( - 'lib/src/google_tasks/oauth/oauth_loopback_flow.dart', - ).readAsStringSync(); + final source = File('lib/src/google_tasks/oauth/oauth_loopback_flow.dart') + .readAsStringSync(); expect(source, isNot(contains('xdg-open'))); }); } diff --git a/test/google_tasks/oauth/token_exchange_test.dart b/test/google_tasks/oauth/token_exchange_test.dart index f9adcb58..38fa9d67 100644 --- a/test/google_tasks/oauth/token_exchange_test.dart +++ b/test/google_tasks/oauth/token_exchange_test.dart @@ -1,7 +1,10 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import '../../support/oauth_binding_fixture.dart'; + import 'package:busymax/src/core/auth/oauth_registration.dart'; import 'package:busymax/src/core/auth/registration_staging.dart'; + import 'dart:async'; import 'dart:convert'; @@ -75,9 +78,8 @@ void main() { .get(); expect(rows, hasLength(1)); expect( - DateTime.parse( - rows.single.cooldownUntilUtc!, - ).difference(DateTime.now().toUtc()), + DateTime.parse(rows.single.cooldownUntilUtc!) + .difference(DateTime.now().toUtc()), greaterThan(const Duration(minutes: 119)), ); } @@ -122,52 +124,131 @@ void main() { expect(userInfo?.email, 'google@example.com'); }); + test('existing Google contacts grant enables contacts without replacing the credential', () async { + final store = InMemorySecretStore(); + final tokenSet = const OAuthTokenSetFixture().tokenSet.copyWith( + scopes: {...googleBusyMaxOAuthScopes, googleContactsWriteScope}, + ); + await store.saveOAuthTokenSet( + 'google:subject', + BusyProvider.google, + tokenSet, + ); + final persistence = await seedBoundFixtures(store); + final service = OAuthService( + config: _config, + tokenStore: store, + persistence: persistence, + loopbackFlow: OAuthLoopbackFlow(), + httpClient: MockClient( + (_) async => throw StateError('No network request allowed'), + ), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + var contactsCommits = 0; + + await service.authorizeGoogleContacts( + 'google:subject', + + persistContacts: () async { + contactsCommits++; + }, + ); + + expect(contactsCommits, 1); + expect( + await service.googleContactsAuthorizationHeader('google:subject'), + 'Bearer access', + ); + final credential = await persistence.readCurrentCredential( + 'google:subject', + ); + expect(credential, isA()); + final googleCredential = credential! as GoogleDesktopCredential; + expect(googleCredential.tokenSet.refreshToken, 'refresh'); + expect( + googleCredential.tokenSet.scopes, + contains(googleContactsWriteScope), + ); + }); + test( - 'token exchange without configured client secret does not send client_secret', + 'Google contacts header rejects an account without a contacts grant', () async { - late http.Request captured; + final store = InMemorySecretStore(); + await store.saveOAuthTokenSet( + 'google:subject', + BusyProvider.google, + const OAuthTokenSetFixture().tokenSet, + ); + final persistence = await seedBoundFixtures(store); final service = OAuthService( config: _config, - httpClient: MockClient((request) async { - captured = request; - return http.Response( - jsonEncode({ - 'access_token': 'access', - 'refresh_token': 'refresh', - 'expires_in': 3600, - 'scope': 'https://www.googleapis.com/auth/tasks', - 'token_type': 'Bearer', - }), - 200, - ); - }), - tokenStore: InMemorySecretStore(), + tokenStore: store, + persistence: persistence, loopbackFlow: OAuthLoopbackFlow(), + httpClient: MockClient( + (_) async => throw StateError('No network request allowed'), + ), nowUtc: () => DateTime.utc(2026, 6, 4), ); - final tokenSet = await service.exchangeAuthorizationCode( - code: 'code', - codeVerifier: 'verifier', - redirectUri: 'http://127.0.0.1:1234/', - ); - - expect( - captured.headers['Content-Type'], - 'application/x-www-form-urlencoded', + await expectLater( + service.googleContactsAuthorizationHeader('google:subject'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthContactsConsentRequired', + ), + ), ); - expect(Uri.splitQueryString(captured.body), { - 'client_id': 'client-id.apps.googleusercontent.com', - 'code': 'code', - 'code_verifier': 'verifier', - 'grant_type': 'authorization_code', - 'redirect_uri': 'http://127.0.0.1:1234/', - }); - expect(captured.body, isNot(contains('client_secret'))); - expect(tokenSet.refreshToken, 'refresh'); }, ); + test('token exchange without configured client secret does not send client_secret', () async { + late http.Request captured; + final service = OAuthService( + config: _config, + httpClient: MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'access_token': 'access', + 'refresh_token': 'refresh', + 'expires_in': 3600, + 'scope': 'https://www.googleapis.com/auth/tasks', + 'token_type': 'Bearer', + }), + 200, + ); + }), + tokenStore: InMemorySecretStore(), + loopbackFlow: OAuthLoopbackFlow(), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + final tokenSet = await service.exchangeAuthorizationCode( + code: 'code', + codeVerifier: 'verifier', + redirectUri: 'http://127.0.0.1:1234/', + ); + + expect( + captured.headers['Content-Type'], + 'application/x-www-form-urlencoded', + ); + expect(Uri.splitQueryString(captured.body), { + 'client_id': 'client-id.apps.googleusercontent.com', + 'code': 'code', + 'code_verifier': 'verifier', + 'grant_type': 'authorization_code', + 'redirect_uri': 'http://127.0.0.1:1234/', + }); + expect(captured.body, isNot(contains('client_secret'))); + expect(tokenSet.refreshToken, 'refresh'); + }); + test( 'token exchange uses fallback scopes when response omits scope', () async { diff --git a/test/ical/ical_import_service_test.dart b/test/ical/ical_import_service_test.dart index c7af8471..2d5e377d 100644 --- a/test/ical/ical_import_service_test.dart +++ b/test/ical/ical_import_service_test.dart @@ -78,15 +78,11 @@ void main() { expect(report.queued, 1); expect(report.unsupportedRecurrenceSets, isEmpty); final pending = await database.select(database.pendingOps).get(); - final request = - jsonDecode( - pending - .singleWhere( - (op) => op.operationType == 'event.importException', - ) - .requestJson, - ) - as Map; + final request = jsonDecode( + pending + .singleWhere((op) => op.operationType == 'event.importException') + .requestJson, + ) as Map; expect(request['originalStart'], '2026-09-01'); expect(request['cancelled'], true); expect(request, isNot(contains('remindersJson'))); @@ -159,15 +155,11 @@ void main() { expect(report.queued, 1); expect(report.unsupportedRecurrenceSets, isEmpty); final pending = await database.select(database.pendingOps).get(); - final request = - jsonDecode( - pending - .singleWhere( - (op) => op.operationType == 'event.importException', - ) - .requestJson, - ) - as Map; + final request = jsonDecode( + pending + .singleWhere((op) => op.operationType == 'event.importException') + .requestJson, + ) as Map; expect(request['remindersJson'], offReminders); final masterRemote = {}; @@ -277,164 +269,153 @@ void main() { ); } - test( - 'R1 exported Microsoft exception retains privacy, availability and categories in queued patch', - () async { - await _seedMicrosoftDestination(database); - time_zone_data.initializeTimeZones(); - final master = microsoftCalendarEventFromJson('source-calendar', { - 'id': 'source-master', - 'uid': 'r1-series', - 'subject': 'Master', - 'type': 'seriesMaster', - 'sensitivity': 'normal', - 'showAs': 'busy', - 'categories': ['A'], - 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, - 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, - 'recurrence': { - 'pattern': {'type': 'daily', 'interval': 1}, - 'range': { - 'type': 'numbered', - 'startDate': '2026-08-30', - 'numberOfOccurrences': 2, - 'recurrenceTimeZone': 'UTC', - }, + test('R1 exported Microsoft exception retains privacy, availability and categories in queued patch', () async { + await _seedMicrosoftDestination(database); + time_zone_data.initializeTimeZones(); + final master = microsoftCalendarEventFromJson('source-calendar', { + 'id': 'source-master', + 'uid': 'r1-series', + 'subject': 'Master', + 'type': 'seriesMaster', + 'sensitivity': 'normal', + 'showAs': 'busy', + 'categories': ['A'], + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'numbered', + 'startDate': '2026-08-30', + 'numberOfOccurrences': 2, + 'recurrenceTimeZone': 'UTC', }, - }); - final exception = microsoftCalendarEventFromJson('source-calendar', { - 'id': 'source-exception', - 'uid': 'r1-series', - 'seriesMasterId': 'source-master', - 'originalStart': '2026-08-31T16:00:00Z', - 'subject': 'Moved', - 'sensitivity': 'private', - 'showAs': 'free', - 'categories': ['B'], - 'start': {'dateTime': '2026-08-31T18:00:00', 'timeZone': 'UTC'}, - 'end': {'dateTime': '2026-08-31T19:00:00', 'timeZone': 'UTC'}, - }); - final exported = cloudSeriesToICalendar( - master: master, - exceptions: [exception], - nowUtc: DateTime.utc(2026, 8, 29), - ); - expect(exported, contains('CLASS:PRIVATE')); - expect(exported, contains('TRANSP:TRANSPARENT')); - expect(exported, contains('CATEGORIES:B')); - final preview = importService.parsePreview(utf8.encode(exported)); - final destination = (await importService.writableDestinations()) - .singleWhere((source) => source.accountId == 'microsoft-account'); - expect( - (await importService.importPreview( - preview: preview, - destination: destination, - )).queued, - 1, - ); - final pending = await database.select(database.pendingOps).get(); - final request = - jsonDecode( - pending - .singleWhere( - (op) => op.operationType == 'event.importException', - ) - .requestJson, - ) - as Map; - expect(request['sensitivity'], 'private'); - expect(request['transparencyOrShowAs'], 'free'); - expect(request['categoriesJson'], ['B']); - final masterRemote = { - 'id': 'imported-master', - 'uid': 'r1-series', - 'subject': 'Master', - 'type': 'seriesMaster', - 'sensitivity': 'normal', - 'showAs': 'busy', - 'categories': ['A'], - 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, - 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, - }; - final occurrenceRemote = { - 'id': 'imported-occurrence', - 'uid': 'r1-series', - 'seriesMasterId': 'imported-master', - 'originalStart': '2026-08-31T16:00:00Z', - 'occurrenceId': 'oid-r1', - 'subject': 'Master', - 'sensitivity': 'normal', - 'showAs': 'busy', - 'categories': ['A'], - 'start': {'dateTime': '2026-08-31T16:00:00', 'timeZone': 'UTC'}, - 'end': {'dateTime': '2026-08-31T17:00:00', 'timeZone': 'UTC'}, - }; - final patches = >[]; - final client = MicrosoftCalendarApiClient( - httpClient: MockClient((httpRequest) async { - if (httpRequest.method == 'POST') { - return http.Response(jsonEncode(masterRemote), 201); - } - if (httpRequest.url.path.endsWith('/instances')) { - return http.Response( - jsonEncode({ - 'value': [occurrenceRemote], - }), - 200, - ); - } - if (httpRequest.method == 'GET' && - httpRequest.url.queryParameters.containsKey(r'$expand')) { - return http.Response( - jsonEncode({ - ...masterRemote, - 'exceptionOccurrences': [], - 'cancelledOccurrences': [], - }), - 200, - ); - } - if (httpRequest.method == 'PATCH') { - final patch = (jsonDecode(httpRequest.body) as Map) - .cast(); - patches.add(patch); - occurrenceRemote.addAll(patch); - } - return http.Response(jsonEncode(occurrenceRemote), 200); - }), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - responseTimeZone: 'UTC', - ); - final replayer = CalendarPendingOpsReplayer( - database: database, - client: client, - accountId: 'microsoft-account', - nowUtc: () => DateTime.utc(2026, 8, 29), - ); - await replayer.replayDueOps(); - await replayer.replayDueOps(); - expect(patches, hasLength(1)); - expect(patches.single['sensitivity'], 'private'); - expect(patches.single['showAs'], 'free'); - expect(patches.single['categories'], ['B']); - expect(masterRemote['sensitivity'], 'normal'); - expect(masterRemote['showAs'], 'busy'); - final reloaded = (await database.select(database.calendarEvents).get()) - .singleWhere( - (event) => event.providerEventId == 'imported-occurrence', + }, + }); + final exception = microsoftCalendarEventFromJson('source-calendar', { + 'id': 'source-exception', + 'uid': 'r1-series', + 'seriesMasterId': 'source-master', + 'originalStart': '2026-08-31T16:00:00Z', + 'subject': 'Moved', + 'sensitivity': 'private', + 'showAs': 'free', + 'categories': ['B'], + 'start': {'dateTime': '2026-08-31T18:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-31T19:00:00', 'timeZone': 'UTC'}, + }); + final exported = cloudSeriesToICalendar( + master: master, + exceptions: [exception], + nowUtc: DateTime.utc(2026, 8, 29), + ); + expect(exported, contains('CLASS:PRIVATE')); + expect(exported, contains('TRANSP:TRANSPARENT')); + expect(exported, contains('CATEGORIES:B')); + final preview = importService.parsePreview(utf8.encode(exported)); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + expect( + (await importService.importPreview( + preview: preview, + destination: destination, + )).queued, + 1, + ); + final pending = await database.select(database.pendingOps).get(); + final request = jsonDecode( + pending + .singleWhere((op) => op.operationType == 'event.importException') + .requestJson, + ) as Map; + expect(request['sensitivity'], 'private'); + expect(request['transparencyOrShowAs'], 'free'); + expect(request['categoriesJson'], ['B']); + final masterRemote = { + 'id': 'imported-master', + 'uid': 'r1-series', + 'subject': 'Master', + 'type': 'seriesMaster', + 'sensitivity': 'normal', + 'showAs': 'busy', + 'categories': ['A'], + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + }; + final occurrenceRemote = { + 'id': 'imported-occurrence', + 'uid': 'r1-series', + 'seriesMasterId': 'imported-master', + 'originalStart': '2026-08-31T16:00:00Z', + 'occurrenceId': 'oid-r1', + 'subject': 'Master', + 'sensitivity': 'normal', + 'showAs': 'busy', + 'categories': ['A'], + 'start': {'dateTime': '2026-08-31T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-31T17:00:00', 'timeZone': 'UTC'}, + }; + final patches = >[]; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((httpRequest) async { + if (httpRequest.method == 'POST') { + return http.Response(jsonEncode(masterRemote), 201); + } + if (httpRequest.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode({ + 'value': [occurrenceRemote], + }), + 200, ); - expect(reloaded.visibility, 'private'); - expect(reloaded.transparencyOrShowAs, 'free'); - expect(jsonDecode(reloaded.categoriesJson!), ['B']); - }, - ); + } + if (httpRequest.method == 'GET' && + httpRequest.url.queryParameters.containsKey(r'$expand')) { + return http.Response( + jsonEncode({ + ...masterRemote, + 'exceptionOccurrences': [], + 'cancelledOccurrences': [], + }), + 200, + ); + } + if (httpRequest.method == 'PATCH') { + final patch = (jsonDecode(httpRequest.body) as Map) + .cast(); + patches.add(patch); + occurrenceRemote.addAll(patch); + } + return http.Response(jsonEncode(occurrenceRemote), 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + expect(patches, hasLength(1)); + expect(patches.single['sensitivity'], 'private'); + expect(patches.single['showAs'], 'free'); + expect(patches.single['categories'], ['B']); + expect(masterRemote['sensitivity'], 'normal'); + expect(masterRemote['showAs'], 'busy'); + final reloaded = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.providerEventId == 'imported-occurrence'); + expect(reloaded.visibility, 'private'); + expect(reloaded.transparencyOrShowAs, 'free'); + expect(jsonDecode(reloaded.categoriesJson!), ['B']); + }); - test( - 'R1 Google same-visibility exception retains transparency and omits categories', - () async { - final preview = importService.parsePreview( - utf8.encode( - _calendar(''' + test('R1 Google same-visibility exception retains transparency and omits categories', () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' BEGIN:VEVENT UID:r1-google DTSTART:20260830T160000Z @@ -462,26 +443,25 @@ DTEND:20260901T170000Z SUMMARY:Inherited END:VEVENT '''), - ), - ); - final report = await importService.importPreview( - preview: preview, - destination: (await importService.writableDestinations()).single, - ); - expect(report.queued, 1); - expect(report.fieldsIntentionallyOmitted, contains('categories')); - final requests = (await database.select(database.pendingOps).get()) - .where((op) => op.operationType == 'event.importException') - .map((op) => jsonDecode(op.requestJson) as Map) - .toList(); - expect(requests, hasLength(2)); - expect(requests.first['sensitivity'], 'private'); - expect(requests.first['transparencyOrShowAs'], 'opaque'); - expect(requests.first, isNot(contains('categoriesJson'))); - expect(requests.last, isNot(contains('sensitivity'))); - expect(requests.last, isNot(contains('transparencyOrShowAs'))); - }, - ); + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 1); + expect(report.fieldsIntentionallyOmitted, contains('categories')); + final requests = (await database.select(database.pendingOps).get()) + .where((op) => op.operationType == 'event.importException') + .map((op) => jsonDecode(op.requestJson) as Map) + .toList(); + expect(requests, hasLength(2)); + expect(requests.first['sensitivity'], 'private'); + expect(requests.first['transparencyOrShowAs'], 'opaque'); + expect(requests.first, isNot(contains('categoriesJson'))); + expect(requests.last, isNot(contains('sensitivity'))); + expect(requests.last, isNot(contains('transparencyOrShowAs'))); + }); test( 'D Google mixed recurrence privacy is rejected before creation', @@ -522,12 +502,10 @@ END:VEVENT }, ); - test( - 'R1 unsupported exception classification rejects the set before master creation', - () async { - final preview = importService.parsePreview( - utf8.encode( - _calendar(''' + test('R1 unsupported exception classification rejects the set before master creation', () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' BEGIN:VEVENT UID:r1-unsupported DTSTART:20260830T160000Z @@ -543,17 +521,16 @@ DTEND:20260831T170000Z CLASS:SECRET END:VEVENT '''), - ), - ); - final report = await importService.importPreview( - preview: preview, - destination: (await importService.writableDestinations()).single, - ); - expect(report.queued, 0); - expect(report.unsupportedRecurrenceSets, hasLength(1)); - expect(await database.select(database.pendingOps).get(), isEmpty); - }, - ); + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { test( @@ -733,13 +710,10 @@ END:VEVENT ); } - test( - 'R2 zero-offset alarms are supported without accepting after-start or end-relative alarms', - () { - IcalImportPreview previewFor(String trigger) => - importService.parsePreview( - utf8.encode( - _calendar(''' + test('R2 zero-offset alarms are supported without accepting after-start or end-relative alarms', () { + IcalImportPreview previewFor(String trigger) => importService.parsePreview( + utf8.encode( + _calendar(''' BEGIN:VEVENT UID:r2-trigger DTSTART:20260830T160000Z @@ -751,30 +725,25 @@ $trigger END:VALARM END:VEVENT '''), - ), - ); - for (final trigger in [ - 'TRIGGER:-PT0M', - 'TRIGGER:PT0M', - 'TRIGGER:-PT10M', - ]) { - expect( - previewFor(trigger).fieldsThatWillBeOmitted, - isNot(contains('unsupported alarms')), - ); - } - for (final trigger in [ - 'TRIGGER:PT10M', - 'TRIGGER;RELATED=END:-PT0M', - 'TRIGGER:invalid', - ]) { - expect( - previewFor(trigger).fieldsThatWillBeOmitted, - contains('unsupported alarms'), - ); - } - }, - ); + ), + ); + for (final trigger in ['TRIGGER:-PT0M', 'TRIGGER:PT0M', 'TRIGGER:-PT10M']) { + expect( + previewFor(trigger).fieldsThatWillBeOmitted, + isNot(contains('unsupported alarms')), + ); + } + for (final trigger in [ + 'TRIGGER:PT10M', + 'TRIGGER;RELATED=END:-PT0M', + 'TRIGGER:invalid', + ]) { + expect( + previewFor(trigger).fieldsThatWillBeOmitted, + contains('unsupported alarms'), + ); + } + }); test('Microsoft PUBLIC import replays as normal sensitivity', () async { await _seedMicrosoftDestination(database); @@ -882,24 +851,18 @@ ${classification}END:VEVENT )).queued, 1, ); - final request = - jsonDecode( - (await database.select(database.pendingOps).get()) - .single - .requestJson, - ) - as Map; + final request = jsonDecode( + (await database.select(database.pendingOps).get()).single.requestJson, + ) as Map; expect(request['sensitivity'], scenario.expected); }, ); } - test( - 'Google PUBLIC classification keeps visibility and Microsoft rejects unknown', - () async { - final googlePreview = importService.parsePreview( - utf8.encode( - _calendar(''' + test('Google PUBLIC classification keeps visibility and Microsoft rejects unknown', () async { + final googlePreview = importService.parsePreview( + utf8.encode( + _calendar(''' BEGIN:VEVENT UID:google-public DTSTART:20260830T160000Z @@ -908,28 +871,28 @@ SUMMARY:Google public CLASS:PUBLIC END:VEVENT '''), - ), - ); - final google = (await importService.writableDestinations()).singleWhere( - (source) => source.accountId == 'google-account', - ); - expect( - (await importService.importPreview( - preview: googlePreview, - destination: google, - )).queued, - 1, - ); - expect( - jsonDecode( - (await database.select(database.pendingOps).get()).single.requestJson, - )['visibility'], - 'public', - ); - await _seedMicrosoftDestination(database); - final unsupported = importService.parsePreview( - utf8.encode( - _calendar(''' + ), + ); + final google = (await importService.writableDestinations()).singleWhere( + (source) => source.accountId == 'google-account', + ); + expect( + (await importService.importPreview( + preview: googlePreview, + destination: google, + )).queued, + 1, + ); + expect( + jsonDecode( + (await database.select(database.pendingOps).get()).single.requestJson, + )['visibility'], + 'public', + ); + await _seedMicrosoftDestination(database); + final unsupported = importService.parsePreview( + utf8.encode( + _calendar(''' BEGIN:VEVENT UID:unknown-class DTSTART:20260830T160000Z @@ -938,19 +901,19 @@ SUMMARY:Unknown CLASS:SECRET END:VEVENT '''), - ), - ); - final microsoft = (await importService.writableDestinations()) - .singleWhere((source) => source.accountId == 'microsoft-account'); - final report = await importService.importPreview( - preview: unsupported, - destination: microsoft, - ); - expect(report.queued, 0); - expect(report.unsupportedRecurrenceSets, hasLength(1)); - expect((await database.select(database.pendingOps).get()), hasLength(1)); - }, - ); + ), + ); + final microsoft = (await importService.writableDestinations()).singleWhere( + (source) => source.accountId == 'microsoft-account', + ); + final report = await importService.importPreview( + preview: unsupported, + destination: microsoft, + ); + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect((await database.select(database.pendingOps).get()), hasLength(1)); + }); test( 'preview performs no mutation and reports omitted scheduling fields', @@ -1481,15 +1444,18 @@ END:VEVENT updatedAtLocal: 1, ), ); - final masterEnd = DateTime.parse( - scenario.master, - ).add(const Duration(days: 1)).toIso8601String().substring(0, 10); - final originalEnd = DateTime.parse( - scenario.original, - ).add(const Duration(days: 1)).toIso8601String().substring(0, 10); - final movedEnd = DateTime.parse( - scenario.moved, - ).add(const Duration(days: 1)).toIso8601String().substring(0, 10); + final masterEnd = DateTime.parse(scenario.master) + .add(const Duration(days: 1)) + .toIso8601String() + .substring(0, 10); + final originalEnd = DateTime.parse(scenario.original) + .add(const Duration(days: 1)) + .toIso8601String() + .substring(0, 10); + final movedEnd = DateTime.parse(scenario.moved) + .add(const Duration(days: 1)) + .toIso8601String() + .substring(0, 10); final sourceMaster = microsoftCalendarEventFromJson('source', { 'id': 'source-master', 'uid': 'all-day-ms', diff --git a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart index 03af6e43..76641545 100644 --- a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart @@ -14,6 +14,62 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + for (final refreshFails in [false, true]) { + test( + 'attachment streaming refreshes ordinary 401 (failure=$refreshFails)', + () async { + var requests = 0, refreshes = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + expect( + request.url.path.endsWith('/attachments/file/\$value'), + isTrue, + ); + return requests == 1 + ? http.Response('{}', 401) + : http.Response.bytes([1, 2, 3], 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + + authorizationHeaderProvider: () async => + refreshes == 0 ? 'Bearer old' : 'Bearer new', + unauthorizedRefreshProvider: () async { + refreshes++; + if (refreshFails) throw StateError('fixture refresh failed'); + }, + ); + final download = client.downloadEventAttachment( + calendarId: 'cal', + eventId: 'event', + attachment: const MicrosoftEventAttachment( + id: 'file', + name: 'fixture.bin', + kind: MicrosoftEventAttachmentKind.file, + ), + ); + if (refreshFails) { + await expectLater( + download, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ), + ), + ); + expect(requests, 1); + } else { + expect(await download, [1, 2, 3]); + expect(requests, 2); + } + expect(refreshes, 1); + }, + ); + } + test( 'primary permission 429 retains code and Retry-After without retrying', () async { @@ -53,6 +109,33 @@ void main() { }, ); + test('primary permission ignores an unusable Retry-After value', () async { + final client = MicrosoftCalendarApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, + }), + 429, + headers: {'retry-after': 'invalid'}, + ), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await expectLater( + client.addPrimaryCalendarPermission( + email: 'friend@example.test', + role: 'read', + ), + throwsA( + isA() + .having((e) => e.statusCode, 'status', 429) + .having((e) => e.retryAfter, 'retry-after', isNull), + ), + ); + }); + test('primary sharing authorization fails before Graph dispatch', () async { const failure = OAuthException('TokenUnavailable', 'Token unavailable'); var requests = 0; @@ -194,8 +277,7 @@ void main() { }, ], if (request.url.queryParameters[r'$skiptoken'] == null) - '@odata.nextLink': - 'https://graph.microsoft.com/v1.0/me/calendar/calendarPermissions?%24skiptoken=next', + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/me/calendar/calendarPermissions?%24skiptoken=next', }); } if (request.method == 'DELETE') return http.Response('', 204); @@ -251,35 +333,37 @@ void main() { ); }, ); - test('master category lookup paginates and keeps every named color', () async { - final requests = []; - final client = MicrosoftCalendarApiClient( - httpClient: MockClient((request) async { - requests.add(request); - if (request.url.queryParameters[r'$skiptoken'] == 'next') { + test( + 'master category lookup paginates and keeps every named color', + () async { + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.queryParameters[r'$skiptoken'] == 'next') { + return _json({ + 'value': [ + {'id': 'c2', 'displayName': 'Personal', 'color': 'preset1'}, + ], + }); + } return _json({ 'value': [ - {'id': 'c2', 'displayName': 'Personal', 'color': 'preset1'}, + {'id': 'c1', 'displayName': 'Work', 'color': 'preset7'}, ], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/me/outlook/masterCategories?%24skiptoken=next', }); - } - return _json({ - 'value': [ - {'id': 'c1', 'displayName': 'Work', 'color': 'preset7'}, - ], - '@odata.nextLink': - 'https://graph.microsoft.com/v1.0/me/outlook/masterCategories?%24skiptoken=next', - }); - }), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - responseTimeZone: 'UTC', - ); - final categories = await client.listMasterCategories(); - expect(categories.map((item) => item.displayName), ['Work', 'Personal']); - expect(categories.map((item) => item.color), ['preset7', 'preset1']); - expect(requests, hasLength(2)); - expect(requests.first.url.path, '/v1.0/me/outlook/masterCategories'); - }); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final categories = await client.listMasterCategories(); + expect(categories.map((item) => item.displayName), ['Work', 'Personal']); + expect(categories.map((item) => item.color), ['preset7', 'preset1']); + expect(requests, hasLength(2)); + expect(requests.first.url.path, '/v1.0/me/outlook/masterCategories'); + }, + ); test('malformed master category response does not look empty', () async { final client = MicrosoftCalendarApiClient( @@ -290,25 +374,22 @@ void main() { await expectLater(client.listMasterCategories(), throwsFormatException); }); - test( - 'master categories use the optional category grant, not ordinary calendar authorization', - () async { - final headers = []; - final client = MicrosoftCalendarApiClient( - httpClient: MockClient((request) async { - headers.add(request.headers['Authorization'] ?? ''); - return _json({'value': []}); - }), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - responseTimeZone: 'UTC', - authorizationHeaderProvider: () async => 'Bearer calendar', - categoryAuthorizationHeaderProvider: () async => 'Bearer categories', - ); - expect(await client.listMasterCategories(), isEmpty); - await client.listCalendars(); - expect(headers, ['Bearer categories', 'Bearer calendar']); - }, - ); + test('master categories use the optional category grant, not ordinary calendar authorization', () async { + final headers = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + headers.add(request.headers['Authorization'] ?? ''); + return _json({'value': []}); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer calendar', + categoryAuthorizationHeaderProvider: () async => 'Bearer categories', + ); + expect(await client.listMasterCategories(), isEmpty); + await client.listCalendars(); + expect(headers, ['Bearer categories', 'Bearer calendar']); + }); test( 'series exception snapshot retains moved and cancelled identities', @@ -489,8 +570,7 @@ void main() { '', 201, headers: { - 'Location': - "https://outlook.office.com/api/v2.0/Events('event')/Attachments('new-id')", + 'Location': "https://outlook.office.com/api/v2.0/Events('event')/Attachments('new-id')", }, ) : _json({ @@ -547,8 +627,7 @@ void main() { 'size': 5, }, ], - '@odata.nextLink': - 'https://graph.microsoft.com/v1.0/me/calendars/cal/events/event/attachments?\$skiptoken=next', + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/me/calendars/cal/events/event/attachments?\$skiptoken=next', }); }); final attachments = await client.listEventAttachments( diff --git a/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart b/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart index c0671766..fb8ad0be 100644 --- a/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart +++ b/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart @@ -11,78 +11,74 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test( - 'restricted delegated private item hides cached details and cannot queue deletion', - () async { - final database = AppDatabase(NativeDatabase.memory()); - addTearDown(database.close); - await database - .into(database.accounts) - .insert( - AccountsCompanion.insert( - id: 'microsoft:a', - provider: BusyProvider.microsoft.storageValue, - authority: 'https://login.microsoftonline.com/common', - providerAccountId: 'a', - credentialKind: 'oauth', - authState: const Value('signed_in'), - grantedScopes: const Value(''), - createdAtUtc: '2026-07-01T00:00:00.000Z', - updatedAtUtc: '2026-07-01T00:00:00.000Z', - ), - ); - final calendarId = const MicrosoftSharedPrimaryCalendarAddress( - owner: 'owner@example.com', - graphCalendarId: 'owner-calendar', - ).sourceCalendarId; - final repository = CalendarRepository(database: database); - await repository.upsertSource( - accountId: 'microsoft:a', - source: CalendarSourceDto( - provider: BusyProvider.microsoft, - providerCalendarId: calendarId, - summary: 'Owner calendar', - readOnly: false, - rawJson: const {'canViewPrivateItems': false}, - ), - ); - await repository.upsertEvent( - accountId: 'microsoft:a', - event: CalendarEventDto( - provider: BusyProvider.microsoft, - providerCalendarId: calendarId, - providerEventId: 'private-event', - title: 'Sensitive title', - description: 'Sensitive description', - location: 'Sensitive location', - startDateTime: '2026-07-15T09:00:00', - endDateTime: '2026-07-15T10:00:00', - startTimeZone: 'UTC', - endTimeZone: 'UTC', - visibility: 'private', - webLink: 'https://outlook.example/private', - attachmentsJson: const [ - {'fileUrl': 'https://files.example/private', 'title': 'Secret'}, - ], - rawJson: const {'hasAttachments': true}, - ), - ); - final items = await ScheduleRepository( - database, - ).listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))); - final item = items.single as CalendarScheduleItem; - expect(item.title, isNot(contains('Sensitive'))); - expect(item.description, isNull); - expect(item.location, isNull); - expect(item.eventLinkUrl, isNull); - expect(item.attachmentLinks, isEmpty); - expect(item.capabilities.canEdit, isFalse); - expect(item.capabilities.canDelete, isFalse); - await expectLater( - repository.deleteLocalEvent(item.id), - throwsA(isA()), - ); - expect(await database.select(database.pendingOps).get(), isEmpty); - }, - ); + test('restricted delegated private item hides cached details and cannot queue deletion', () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft:a', + provider: BusyProvider.microsoft.storageValue, + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'a', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-07-01T00:00:00.000Z', + updatedAtUtc: '2026-07-01T00:00:00.000Z', + ), + ); + final calendarId = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.com', + graphCalendarId: 'owner-calendar', + ).sourceCalendarId; + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'microsoft:a', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + summary: 'Owner calendar', + readOnly: false, + rawJson: const {'canViewPrivateItems': false}, + ), + ); + await repository.upsertEvent( + accountId: 'microsoft:a', + event: CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + providerEventId: 'private-event', + title: 'Sensitive title', + description: 'Sensitive description', + location: 'Sensitive location', + startDateTime: '2026-07-15T09:00:00', + endDateTime: '2026-07-15T10:00:00', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + visibility: 'private', + webLink: 'https://outlook.example/private', + attachmentsJson: const [ + {'fileUrl': 'https://files.example/private', 'title': 'Secret'}, + ], + rawJson: const {'hasAttachments': true}, + ), + ); + final items = await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))); + final item = items.single as CalendarScheduleItem; + expect(item.title, isNot(contains('Sensitive'))); + expect(item.description, isNull); + expect(item.location, isNull); + expect(item.eventLinkUrl, isNull); + expect(item.attachmentLinks, isEmpty); + expect(item.capabilities.canEdit, isFalse); + expect(item.capabilities.canDelete, isFalse); + await expectLater( + repository.deleteLocalEvent(item.id), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }); } diff --git a/test/microsoft_todo/api/microsoft_todo_api_client_test.dart b/test/microsoft_todo/api/microsoft_todo_api_client_test.dart index 43a6d80c..784d2669 100644 --- a/test/microsoft_todo/api/microsoft_todo_api_client_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_api_client_test.dart @@ -10,6 +10,57 @@ import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/features/schedule/presentation/attachment_upload_coordinator.dart'; void main() { + for (final refreshFails in [false, true]) { + test( + 'attachment streaming refreshes ordinary 401 (failure=$refreshFails)', + () async { + var requests = 0, refreshes = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + requests++; + expect( + request.url.path.endsWith('/attachments/file/\$value'), + isTrue, + ); + return requests == 1 + ? http.Response('{}', 401) + : http.Response.bytes([1, 2, 3], 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + + authorizationHeaderProvider: () async => + refreshes == 0 ? 'Bearer old' : 'Bearer new', + unauthorizedRefreshProvider: () async { + refreshes++; + if (refreshFails) throw StateError('fixture refresh failed'); + }, + ); + final download = client.downloadTaskAttachment( + taskListId: 'list', + taskId: 'task', + attachmentId: 'file', + ); + if (refreshFails) { + await expectLater( + download, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ), + ), + ); + expect(requests, 1); + } else { + expect(await download, [1, 2, 3]); + expect(requests, 2); + } + expect(refreshes, 1); + }, + ); + } + test( 'fresh direct task 429 retains retry delay and allows one explicit retry', () async { @@ -99,251 +150,74 @@ void main() { heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); }, ); - test( - 'lost nonfinal task chunk resumes its Graph session and keeps bearer scoped', - () async { - var sessions = 0; - var statusReads = 0; - var finalChunkAcknowledged = false; - final heldRefresh = Completer(); - final refreshStarted = Completer(); - final ranges = []; - final bytes = List.filled(4 * 1024 * 1024, 65); - final client = MicrosoftTodoRestApiClient( - httpClient: MockClient((request) async { - if (request.method == 'GET' && - request.url.path.endsWith('/attachments')) { - if (finalChunkAcknowledged) { - refreshStarted.complete(); - return heldRefresh.future; - } - return http.Response(jsonEncode({'value': []}), 200); - } - if (request.method == 'POST') { - sessions++; - return http.Response( - jsonEncode({ - 'uploadUrl': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachmentSessions/session', - 'expirationDateTime': '2099-01-01T00:00:00Z', - 'nextExpectedRanges': ['0-'], - }), - 201, - ); - } - if (request.method == 'GET' && - request.url.path.endsWith('/attachmentSessions/session')) { - statusReads++; - expect(request.headers['authorization'], 'Bearer token'); - return http.Response( - jsonEncode({ - 'nextExpectedRanges': ['2097152-'], - }), - 200, - ); - } - if (request.method == 'PUT') { - expect(request.url.path.endsWith('/content'), isTrue); - expect(request.headers['authorization'], 'Bearer token'); - final range = - request.headers['content-range'] ?? - request.headers['Content-Range']!; - ranges.add(range); - if (range.startsWith('bytes 0-')) { - throw http.ClientException('first response lost'); - } - finalChunkAcknowledged = true; - return http.Response( - '', - 201, - headers: { - 'Location': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/task-upload-id', - }, - ); - } - return http.Response('{}', 404); - }), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - authorizationHeaderProvider: () async => 'Bearer token', - ); - final coordinator = AttachmentUploadCoordinator(); - final key = AttachmentUploadCoordinator.taskKey( - 'account', - 'list', - 'task', - ); - await expectLater( - coordinator.uploadTask( - client: client, - accountId: 'account', - taskListId: 'list', - taskId: 'task', - name: 'large.bin', - contentType: 'application/octet-stream', - bytes: bytes, - ), - throwsA(isA()), - ); - expect(coordinator.status(key), AttachmentUploadStatus.unresolved); - var completed = false; - final resumed = coordinator - .reconcileTask( - client: client, - accountId: 'account', - taskListId: 'list', - taskId: 'task', - ) - .then((status) { - completed = true; - return status; - }); - await refreshStarted.future; - for (var i = 0; i < 4; i++) { - await Future.value(); - } - try { - expect(completed, isTrue); - expect(await resumed, AttachmentUploadStatus.committed); - } finally { - heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); - await resumed; - } - for (var i = 0; i < 4; i++) { - await Future.value(); - } - expect(coordinator.confirmedId(key), equals(null)); - expect(coordinator.canSubmit(key), isTrue); - expect(sessions, 1); - expect(statusReads, 1); - expect(ranges, [ - 'bytes 0-2097151/${bytes.length}', - 'bytes 2097152-4194303/${bytes.length}', - ]); - }, - ); - test( - 'unfinished task session cancellation is Graph authenticated and releases retry', - () async { - var cancellations = 0; - final client = MicrosoftTodoRestApiClient( - httpClient: MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'value': []}), 200); - } - if (request.method == 'POST') { - return http.Response( - jsonEncode({ - 'uploadUrl': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachmentSessions/session', - 'expirationDateTime': '2099-01-01T00:00:00Z', - 'nextExpectedRanges': ['0-'], - }), - 201, - ); - } - if (request.method == 'PUT') { - throw http.ClientException('chunk response lost'); - } - if (request.method == 'DELETE') { - cancellations++; - expect( - request.url.path.endsWith('/attachmentSessions/session'), - isTrue, - ); - expect(request.headers['authorization'], 'Bearer token'); - return http.Response('', 204); - } - return http.Response('{}', 404); - }), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - authorizationHeaderProvider: () async => 'Bearer token', - ); - final coordinator = AttachmentUploadCoordinator(); - final key = AttachmentUploadCoordinator.taskKey( - 'account', - 'list', - 'task', - ); - await expectLater( - coordinator.uploadTask( - client: client, - accountId: 'account', - taskListId: 'list', - taskId: 'task', - name: 'large.bin', - contentType: 'application/octet-stream', - bytes: List.filled(4 * 1024 * 1024, 65), - ), - throwsA(isA()), - ); - expect(coordinator.hasResumableSession(key), isTrue); - await coordinator.cancelTask( - client: client, - accountId: 'account', - taskListId: 'list', - taskId: 'task', - ); - expect(cancellations, 1); - expect(coordinator.canSubmit(key), isTrue); - }, - ); - test( - 'lost task session response cannot start another upload before reconciliation', - () async { - var visible = false; - var sessions = 0; - var chunks = 0; - final bytes = List.filled(3 * 1024 * 1024, 65); - final client = MicrosoftTodoRestApiClient( - httpClient: MockClient((request) async { - if (request.method == 'GET' && - request.url.path.endsWith('/attachments')) { - return http.Response( - jsonEncode({ - 'value': visible - ? [ - { - 'id': 'file-1', - 'name': 'large.bin', - 'size': bytes.length, - '@odata.type': '#microsoft.graph.taskFileAttachment', - }, - ] - : [], - }), - 200, - ); - } - if (request.method == 'POST' && - request.url.path.endsWith('/createUploadSession')) { - sessions++; - return http.Response( - jsonEncode({ - 'uploadUrl': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/session', - 'nextExpectedRanges': ['0-'], - 'expirationDateTime': '2099-01-01T00:00:00Z', - }), - 200, - ); + test('lost nonfinal task chunk resumes its Graph session and keeps bearer scoped', () async { + var sessions = 0; + var statusReads = 0; + var finalChunkAcknowledged = false; + final heldRefresh = Completer(); + final refreshStarted = Completer(); + final ranges = []; + final bytes = List.filled(4 * 1024 * 1024, 65); + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + if (finalChunkAcknowledged) { + refreshStarted.complete(); + return heldRefresh.future; } - if (request.method == 'PUT') { - chunks++; - if (chunks == 2) throw http.ClientException('final response lost'); - return http.Response( - jsonEncode({ - 'nextExpectedRanges': ['2097152-'], - }), - 200, - ); + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST') { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachmentSessions/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'GET' && + request.url.path.endsWith('/attachmentSessions/session')) { + statusReads++; + expect(request.headers['authorization'], 'Bearer token'); + return http.Response( + jsonEncode({ + 'nextExpectedRanges': ['2097152-'], + }), + 200, + ); + } + if (request.method == 'PUT') { + expect(request.url.path.endsWith('/content'), isTrue); + expect(request.headers['authorization'], 'Bearer token'); + final range = + request.headers['content-range'] ?? + request.headers['Content-Range']!; + ranges.add(range); + if (range.startsWith('bytes 0-')) { + throw http.ClientException('first response lost'); } - return http.Response('{}', 404); - }), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - ); - final coordinator = AttachmentUploadCoordinator(); - Future upload() => coordinator.uploadTask( + finalChunkAcknowledged = true; + return http.Response( + '', + 201, + headers: { + 'Location': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/task-upload-id', + }, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey('account', 'list', 'task'); + await expectLater( + coordinator.uploadTask( client: client, accountId: 'account', taskListId: 'list', @@ -351,87 +225,244 @@ void main() { name: 'large.bin', contentType: 'application/octet-stream', bytes: bytes, - ); - await expectLater( - upload(), - throwsA(isA()), - ); - await expectLater( - upload(), - throwsA(isA()), - ); - expect(sessions, 1); - expect(chunks, 2); - visible = true; - await expectLater( - coordinator.reconcileTask( + ), + throwsA(isA()), + ); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + var completed = false; + final resumed = coordinator + .reconcileTask( client: client, accountId: 'account', taskListId: 'list', taskId: 'task', - ), - throwsA(isA()), - ); - }, - ); - test('task upload session uses task endpoint and Graph-only bearer', () async { - final requests = []; - final client = _client((request) { - requests.add(request); - if (request.method == 'POST') { - return _json({ - 'uploadUrl': - 'https://graph.microsoft.com/v1.0/users/owner/todo/lists/list/tasks/task/attachmentSessions/session', - 'nextExpectedRanges': ['0-'], - 'expirationDateTime': '2099-01-01T00:00:00Z', + ) + .then((status) { + completed = true; + return status; }); - } - return request.headers['content-range']?.startsWith('bytes 2097152-') == - true - ? http.Response( - '', - 201, - headers: { - 'Location': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/new-id', - }, - ) - : _json({ + await refreshStarted.future; + for (var i = 0; i < 4; i++) { + await Future.value(); + } + try { + expect(completed, isTrue); + expect(await resumed, AttachmentUploadStatus.committed); + } finally { + heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); + await resumed; + } + for (var i = 0; i < 20 && coordinator.confirmedId(key) != null; i++) { + await Future.delayed(Duration.zero); + } + expect(coordinator.confirmedId(key), equals(null)); + expect(coordinator.canSubmit(key), isTrue); + expect(sessions, 1); + expect(statusReads, 1); + expect(ranges, [ + 'bytes 0-2097151/${bytes.length}', + 'bytes 2097152-4194303/${bytes.length}', + ]); + }); + test('unfinished task session cancellation is Graph authenticated and releases retry', () async { + var cancellations = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST') { + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachmentSessions/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'PUT') { + throw http.ClientException('chunk response lost'); + } + if (request.method == 'DELETE') { + cancellations++; + expect( + request.url.path.endsWith('/attachmentSessions/session'), + isTrue, + ); + expect(request.headers['authorization'], 'Bearer token'); + return http.Response('', 204); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey('account', 'list', 'task'); + await expectLater( + coordinator.uploadTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: List.filled(4 * 1024 * 1024, 65), + ), + throwsA(isA()), + ); + expect(coordinator.hasResumableSession(key), isTrue); + await coordinator.cancelTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + ); + expect(cancellations, 1); + expect(coordinator.canSubmit(key), isTrue); + }); + test('lost task session response cannot start another upload before reconciliation', () async { + var visible = false; + var sessions = 0; + var chunks = 0; + final bytes = List.filled(3 * 1024 * 1024, 65); + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response( + jsonEncode({ + 'value': visible + ? [ + { + 'id': 'file-1', + 'name': 'large.bin', + 'size': bytes.length, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }, + ] + : [], + }), + 200, + ); + } + if (request.method == 'POST' && + request.url.path.endsWith('/createUploadSession')) { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/session', + 'nextExpectedRanges': ['0-'], + 'expirationDateTime': '2099-01-01T00:00:00Z', + }), + 200, + ); + } + if (request.method == 'PUT') { + chunks++; + if (chunks == 2) throw http.ClientException('final response lost'); + return http.Response( + jsonEncode({ 'nextExpectedRanges': ['2097152-'], - }); - }); - final id = await client.uploadTaskFileAttachment( + }), + 200, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + ); + final coordinator = AttachmentUploadCoordinator(); + Future upload() => coordinator.uploadTask( + client: client, + accountId: 'account', taskListId: 'list', taskId: 'task', name: 'large.bin', contentType: 'application/octet-stream', - bytes: List.filled(3 * 1024 * 1024, 65), + bytes: bytes, ); - expect(id, 'new-id'); - expect( - requests.first.url.path, - '/v1.0/me/todo/lists/list/tasks/task/attachments/createUploadSession', + await expectLater( + upload(), + throwsA(isA()), ); - expect((jsonDecode(requests.first.body) as Map)['attachmentInfo'], { - 'attachmentType': 'file', - 'name': 'large.bin', - 'size': 3 * 1024 * 1024, - }); - expect( - requests.skip(1).map((request) => request.url.path), - everyElement( - '/v1.0/users/owner/todo/lists/list/tasks/task/attachmentSessions/session/content', - ), + await expectLater( + upload(), + throwsA(isA()), ); - expect( - requests - .skip(1) - .every( - (request) => request.headers['authorization'] == 'Bearer token', - ), - isTrue, + expect(sessions, 1); + expect(chunks, 2); + visible = true; + await expectLater( + coordinator.reconcileTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + ), + throwsA(isA()), ); }); + test( + 'task upload session uses task endpoint and Graph-only bearer', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'POST') { + return _json({ + 'uploadUrl': 'https://graph.microsoft.com/v1.0/users/owner/todo/lists/list/tasks/task/attachmentSessions/session', + 'nextExpectedRanges': ['0-'], + 'expirationDateTime': '2099-01-01T00:00:00Z', + }); + } + return request.headers['content-range']?.startsWith('bytes 2097152-') == + true + ? http.Response( + '', + 201, + headers: { + 'Location': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/new-id', + }, + ) + : _json({ + 'nextExpectedRanges': ['2097152-'], + }); + }); + final id = await client.uploadTaskFileAttachment( + taskListId: 'list', + taskId: 'task', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: List.filled(3 * 1024 * 1024, 65), + ); + expect(id, 'new-id'); + expect( + requests.first.url.path, + '/v1.0/me/todo/lists/list/tasks/task/attachments/createUploadSession', + ); + expect((jsonDecode(requests.first.body) as Map)['attachmentInfo'], { + 'attachmentType': 'file', + 'name': 'large.bin', + 'size': 3 * 1024 * 1024, + }); + expect( + requests.skip(1).map((request) => request.url.path), + everyElement( + '/v1.0/users/owner/todo/lists/list/tasks/task/attachmentSessions/session/content', + ), + ); + expect( + requests + .skip(1) + .every( + (request) => request.headers['authorization'] == 'Bearer token', + ), + isTrue, + ); + }, + ); test( 'task attachments use task-scoped metadata, content and mutation endpoints', @@ -546,49 +577,51 @@ void main() { }, ); - test('linked resources use task-scoped endpoint and safe pagination', () async { - final requests = []; - final client = _client((request) { - requests.add(request); - return _json({ - 'value': [ - { - 'id': 'resource-1', - 'applicationName': 'Planner', - 'displayName': 'Project', - 'webUrl': 'https://example.test/project', - }, - ], - '@odata.nextLink': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources?\$skiptoken=next', + test( + 'linked resources use task-scoped endpoint and safe pagination', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + return _json({ + 'value': [ + { + 'id': 'resource-1', + 'applicationName': 'Planner', + 'displayName': 'Project', + 'webUrl': 'https://example.test/project', + }, + ], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources?\$skiptoken=next', + }); }); - }); - final first = await client.listLinkedResourcesPage( - taskListId: 'list-1', - taskId: 'task-1', - ); - expect(first.resources.single.displayName, 'Project'); - expect(first.resources.single.applicationName, 'Planner'); - expect( - requests.single.url.path, - '/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources', - ); - await client.listLinkedResourcesPage( - taskListId: 'list-1', - taskId: 'task-1', - nextLink: first.nextLink, - ); - expect(requests, hasLength(2)); - await expectLater( - client.listLinkedResourcesPage( + final first = await client.listLinkedResourcesPage( taskListId: 'list-1', taskId: 'task-1', - nextLink: 'https://attacker.test/steal', - ), - throwsFormatException, - ); - expect(requests, hasLength(2)); - }); + ); + expect(first.resources.single.displayName, 'Project'); + expect(first.resources.single.applicationName, 'Planner'); + expect( + requests.single.url.path, + '/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources', + ); + await client.listLinkedResourcesPage( + taskListId: 'list-1', + taskId: 'task-1', + nextLink: first.nextLink, + ); + expect(requests, hasLength(2)); + await expectLater( + client.listLinkedResourcesPage( + taskListId: 'list-1', + taskId: 'task-1', + nextLink: 'https://attacker.test/steal', + ), + throwsFormatException, + ); + expect(requests, hasLength(2)); + }, + ); test('list lists sends GET /me/todo/lists', () async { late http.Request captured; diff --git a/test/microsoft_todo/api/microsoft_todo_api_models_test.dart b/test/microsoft_todo/api/microsoft_todo_api_models_test.dart index 96723612..bc4ac4e6 100644 --- a/test/microsoft_todo/api/microsoft_todo_api_models_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_api_models_test.dart @@ -49,9 +49,8 @@ void main() { throwsFormatException, ); expect( - MicrosoftTodoLinkedResourcesPageDto.fromJson(const { - 'value': [], - }).resources, + MicrosoftTodoLinkedResourcesPageDto.fromJson(const {'value': []}) + .resources, isEmpty, ); expect( diff --git a/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart b/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart index f0769da4..a290ab4a 100644 --- a/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart @@ -8,46 +8,47 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { - test('a malformed later page fails the whole linked-resource lookup', () async { - final client = MicrosoftTodoRestApiClient( - httpClient: MockClient( - (request) async => http.Response( - jsonEncode( - request.url.queryParameters.containsKey(r'$skiptoken') - ? {'error': 'incomplete'} - : { - 'value': [ - {'id': 'first', 'webUrl': 'https://example.test/task'}, - ], - '@odata.nextLink': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/linkedResources?\$skiptoken=next', - }, + test( + 'a malformed later page fails the whole linked-resource lookup', + () async { + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient( + (request) async => http.Response( + jsonEncode( + request.url.queryParameters.containsKey(r'$skiptoken') + ? {'error': 'incomplete'} + : { + 'value': [ + {'id': 'first', 'webUrl': 'https://example.test/task'}, + ], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/linkedResources?\$skiptoken=next', + }, + ), + 200, ), - 200, ), - ), - baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), - authorizationHeaderProvider: () async => 'Bearer token', - ); - final container = ProviderContainer( - overrides: [ - microsoftTodoApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), - ], - ); - addTearDown(container.dispose); - await expectLater( - container.read( - microsoftTaskLinkedResourcesProvider(( - accountId: 'microsoft:a', - taskListId: 'list', - taskId: 'task', - )).future, - ), - throwsFormatException, - ); - }); + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final container = ProviderContainer( + overrides: [ + microsoftTodoApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + await expectLater( + container.read( + microsoftTaskLinkedResourcesProvider(( + accountId: 'microsoft:a', + taskListId: 'list', + taskId: 'task', + )).future, + ), + throwsFormatException, + ); + }, + ); test( 'on-demand provider paginates, filters unsafe and duplicate links', @@ -89,8 +90,7 @@ void main() { 'webUrl': 'javascript:alert(1)', }, ], - '@odata.nextLink': - 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/linkedResources?\$skiptoken=next', + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/linkedResources?\$skiptoken=next', }, ), 200, @@ -101,9 +101,8 @@ void main() { ); final container = ProviderContainer( overrides: [ - microsoftTodoApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftTodoApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], ); addTearDown(container.dispose); diff --git a/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart index 2ad03d7a..12436041 100644 --- a/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart +++ b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart @@ -1,5 +1,7 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import '../../support/oauth_binding_fixture.dart'; + import 'dart:async'; import 'dart:convert'; @@ -14,6 +16,91 @@ import 'package:busymax/src/microsoft_todo/oauth/microsoft_oauth_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; void main() { + for (final responseMode in ['omitted', 'read', 'partial', 'revoked']) { + test( + 'contacts claims renewal $responseMode omits scope and honors actual returned authorization', + () async { + final store = InMemorySecretStore(); + final priorScopes = { + ...microsoftTodoOAuthScopes.split(' '), + microsoftContactsWriteScope, + microsoftSharedCalendarScope, + microsoftCategoryScope, + }; + final prior = OAuthTokenSet( + accessToken: 'old', + refreshToken: 'working-refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: priorScopes, + ); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + prior, + ); + final persistence = await seedBoundFixtures(store); + late http.Request captured; + final service = MicrosoftOAuthService( + config: _config, + tokenStore: store, + persistence: persistence, + loopbackFlow: OAuthLoopbackFlow( + authorizationLauncher: (_) async => throw StateError('No browser'), + ), + nowUtc: () => DateTime.utc(2026, 6, 6), + httpClient: MockClient((request) async { + captured = request; + if (responseMode == 'revoked') { + return http.Response(jsonEncode({'error': 'invalid_grant'}), 400); + } + return http.Response( + jsonEncode({ + 'access_token': 'renewed', + 'refresh_token': 'rotated', + 'expires_in': 3600, + 'id_token': fixtureMicrosoftIdToken, + if (responseMode != 'omitted') + 'scope': + '$microsoftTodoOAuthScopes ' + '${responseMode == 'read' ? microsoftContactsReadScope : ''}', + }), + 200, + ); + }), + ); + final operation = service.microsoftContactsAuthorizationHeader( + 'microsoft:user', + claims: 'synthetic-claims', + ); + if (responseMode == 'partial' || responseMode == 'revoked') { + await expectLater(operation, throwsA(isA())); + } else { + expect(await operation, 'Bearer renewed'); + } + final requested = Uri.splitQueryString(captured.body); + expect(requested, isNot(contains('scope'))); + expect(requested['claims'], 'synthetic-claims'); + expect(requested['client_id'], fixtureMicrosoftClient); + expect(requested['refresh_token'], 'working-refresh'); + final saved = (await store.readOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + ))!; + if (responseMode == 'revoked') { + expect(saved.refreshToken, prior.refreshToken); + expect(saved.scopes, priorScopes); + } else { + expect(saved.refreshToken, 'rotated'); + if (responseMode == 'omitted') { + expect(saved.scopes, priorScopes); + } else { + expect(saved.scopes, isNot(contains(microsoftContactsWriteScope))); + } + } + }, + ); + } test( 'short Graph optional scopes authorize their existing account', () async { @@ -32,6 +119,7 @@ void main() { 'Calendars.ReadWrite', 'Calendars.ReadWrite.Shared', 'MailboxSettings.Read', + 'Contacts.ReadWrite', }, ), ); @@ -57,8 +145,13 @@ void main() { await service.categoryAuthorizationHeaderForAccount('microsoft:user'), 'Bearer short-access', ); + expect( + await service.microsoftContactsAuthorizationHeader('microsoft:user'), + 'Bearer short-access', + ); await service.authorizeSharedCalendarAccess('microsoft:user'); await service.authorizeCategoryAccess('microsoft:user'); + await service.authorizeMicrosoftContacts('microsoft:user'); expect( (await store.readOAuthTokenSet( 'microsoft:user', @@ -70,7 +163,7 @@ void main() { ); test( - 'short optional grants are requested on refresh but loss is honored', + 'refresh omits requested scopes and honors loss of optional grants', () async { late http.Request captured; final service = _service((request) async { @@ -97,89 +190,90 @@ void main() { 'Calendars.ReadWrite', 'Calendars.ReadWrite.Shared', 'MailboxSettings.Read', + 'Contacts.ReadWrite', }, ), ); - final requested = Uri.splitQueryString(captured.body)['scope']!; - expect(requested, contains(microsoftSharedCalendarScope)); - expect(requested, contains(microsoftCategoryScope)); + expect(Uri.splitQueryString(captured.body), isNot(contains('scope'))); expect(token.refreshToken, 'keep-refresh'); expect(token.scopes, isNot(contains('Calendars.ReadWrite.Shared'))); expect(token.scopes, isNot(contains('MailboxSettings.Read'))); + expect(token.scopes, isNot(contains('Contacts.ReadWrite'))); }, ); for (final optional in [ microsoftSharedCalendarScope, microsoftCategoryScope, + microsoftContactsReadScope, + microsoftContactsWriteScope, ]) { - test( - 'desktop consent accepts short and mixed Graph grants for $optional', - () async { - final store = InMemorySecretStore(); - final flow = _ConsentFlow(); - final prior = optional == microsoftSharedCalendarScope - ? microsoftCategoryScope - : microsoftSharedCalendarScope; - final priorShort = prior.substring( - 'https://graph.microsoft.com/'.length, - ); - final optionalShort = optional.substring( - 'https://graph.microsoft.com/'.length, - ); - await store.saveOAuthTokenSet( - 'microsoft:user', - BusyProvider.microsoft, - OAuthTokenSet( - accessToken: 'old', - refreshToken: 'old-refresh', - expiresAtUtc: DateTime.utc(2026, 6, 7), - tokenType: 'Bearer', - scopes: { - 'User.Read', - 'Tasks.ReadWrite', - 'Calendars.ReadWrite', - priorShort, - }, - ), - ); - final service = MicrosoftOAuthService( - config: _config, - persistence: await seedBoundFixtures(store), - httpClient: MockClient( - (request) async => request.method == 'GET' - ? http.Response(jsonEncode({'id': 'user'}), 200) - : http.Response( - jsonEncode({ - 'id_token': fixtureMicrosoftIdToken, - 'access_token': 'consented', - 'expires_in': 3600, - 'scope': - 'User.Read https://graph.microsoft.com/Tasks.ReadWrite ' - 'Calendars.ReadWrite $optionalShort $priorShort', - }), - 200, - ), - ), - tokenStore: store, - loopbackFlow: flow, - nowUtc: () => DateTime.utc(2026, 6, 6), - ); - if (optional == microsoftSharedCalendarScope) { - await service.authorizeSharedCalendarAccess('microsoft:user'); - } else { - await service.authorizeCategoryAccess('microsoft:user'); - } - expect(flow.requestedScope, contains(optional)); - expect(flow.requestedScope, contains(prior)); - final saved = (await store.readOAuthTokenSet( - 'microsoft:user', - BusyProvider.microsoft, - ))!; - expect(saved.accessToken, 'consented'); - expect(saved.refreshToken, 'old-refresh'); - expect(saved.scopes, containsAll([optionalShort, priorShort])); - }, - ); + test('desktop consent accepts short and mixed Graph grants for $optional', () async { + final store = InMemorySecretStore(); + final flow = _ConsentFlow(); + final prior = optional == microsoftSharedCalendarScope + ? microsoftCategoryScope + : microsoftSharedCalendarScope; + final priorShort = prior.substring('https://graph.microsoft.com/'.length); + final optionalShort = optional.substring( + 'https://graph.microsoft.com/'.length, + ); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + OAuthTokenSet( + accessToken: 'old', + refreshToken: 'old-refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: { + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + priorShort, + }, + ), + ); + final service = MicrosoftOAuthService( + config: _config, + persistence: await seedBoundFixtures(store), + httpClient: MockClient( + (request) async => request.method == 'GET' + ? http.Response(jsonEncode({'id': 'user'}), 200) + : http.Response( + jsonEncode({ + 'id_token': fixtureMicrosoftIdToken, + 'access_token': 'consented', + 'expires_in': 3600, + 'scope': + 'User.Read https://graph.microsoft.com/Tasks.ReadWrite ' + 'Calendars.ReadWrite $optionalShort $priorShort', + }), + 200, + ), + ), + tokenStore: store, + loopbackFlow: flow, + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + await _authorizeOptional(service, optional); + expect( + flow.requestedScope, + contains( + optional == microsoftContactsWriteScope + ? microsoftContactsReadScope + : optional, + ), + ); + expect(flow.requestedScope, isNot(contains(microsoftContactsWriteScope))); + expect(flow.requestedScope, contains(prior)); + final saved = (await store.readOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + ))!; + expect(saved.accessToken, 'consented'); + expect(saved.refreshToken, 'old-refresh'); + expect(saved.scopes, containsAll([optionalShort, priorShort])); + }); } test( @@ -197,6 +291,8 @@ void main() { scopes: const { 'https://example.test/Calendars.ReadWrite.Shared', 'https://example.test/MailboxSettings.Read', + 'https://example.test/Contacts.Read', + 'https://example.test/Contacts.ReadWrite', }, ), ); @@ -218,12 +314,22 @@ void main() { service.categoryAuthorizationHeaderForAccount('microsoft:user'), throwsA(isA()), ); + await expectLater( + service.microsoftContactsAuthorizationHeader('microsoft:user'), + throwsA(isA()), + ); + await expectLater( + service.microsoftContactsAuthorizationHeader('microsoft:user'), + throwsA(isA()), + ); }, ); for (final optional in [ microsoftSharedCalendarScope, microsoftCategoryScope, + microsoftContactsReadScope, + microsoftContactsWriteScope, ]) { for (final denial in [ 'cancelled', @@ -283,9 +389,7 @@ void main() { nowUtc: () => DateTime.utc(2026, 6, 6), ); await expectLater( - optional == microsoftSharedCalendarScope - ? service.authorizeSharedCalendarAccess('microsoft:user') - : service.authorizeCategoryAccess('microsoft:user'), + _authorizeOptional(service, optional), throwsA(isA()), ); final saved = (await store.readOAuthTokenSet( @@ -355,7 +459,7 @@ void main() { expect(tokenSet.refreshToken, 'refresh'); }); - test('refresh does not send client_secret and sends scopes', () async { + test('refresh omits client_secret and optional scope', () async { late http.Request captured; final service = _service((request) async { captured = request; @@ -376,7 +480,7 @@ void main() { expect(body['client_id'], fixtureMicrosoftClient); expect(body['grant_type'], 'refresh_token'); expect(body['refresh_token'], 'refresh'); - expect(body['scope'], microsoftTodoOAuthScopes); + expect(body, isNot(contains('scope'))); expect(body.containsKey('client_secret'), isFalse); }); @@ -407,10 +511,7 @@ void main() { scopes: const {microsoftSharedCalendarScope}, ), ); - expect( - Uri.splitQueryString(captured.body)['scope'], - contains(microsoftSharedCalendarScope), - ); + expect(Uri.splitQueryString(captured.body), isNot(contains('scope'))); expect(token.scopes, contains(microsoftSharedCalendarScope)); }, ); @@ -457,40 +558,34 @@ void main() { }, ); - test( - 'category consent remains account-scoped and survives refresh scope selection', - () async { - late http.Request captured; - final service = _service((request) async { - captured = request; - return http.Response( - jsonEncode({ - 'id_token': fixtureMicrosoftIdToken, - 'access_token': 'renewed', - 'refresh_token': 'refresh', - 'expires_in': 3600, - 'token_type': 'Bearer', - 'scope': '$microsoftTodoOAuthScopes $microsoftCategoryScope', - }), - 200, - ); - }); - final token = await service.refreshToken( - OAuthTokenSet( - accessToken: 'access', - refreshToken: 'refresh', - expiresAtUtc: DateTime.utc(2026, 6, 6), - tokenType: 'Bearer', - scopes: const {microsoftCategoryScope}, - ), - ); - expect( - Uri.splitQueryString(captured.body)['scope'], - contains(microsoftCategoryScope), + test('category consent remains account-scoped and survives refresh scope selection', () async { + late http.Request captured; + final service = _service((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'id_token': fixtureMicrosoftIdToken, + 'access_token': 'renewed', + 'refresh_token': 'refresh', + 'expires_in': 3600, + 'token_type': 'Bearer', + 'scope': '$microsoftTodoOAuthScopes $microsoftCategoryScope', + }), + 200, ); - expect(token.scopes, contains(microsoftCategoryScope)); - }, - ); + }); + final token = await service.refreshToken( + OAuthTokenSet( + accessToken: 'access', + refreshToken: 'refresh', + expiresAtUtc: DateTime.utc(2026, 6, 6), + tokenType: 'Bearer', + scopes: const {microsoftCategoryScope}, + ), + ); + expect(Uri.splitQueryString(captured.body), isNot(contains('scope'))); + expect(token.scopes, contains(microsoftCategoryScope)); + }); test('refresh failure preserves the token endpoint status', () async { final service = _service((request) async { @@ -601,6 +696,23 @@ void main() { ); } +Future _authorizeOptional(MicrosoftOAuthService service, String scope) => + switch (scope) { + microsoftSharedCalendarScope => service.authorizeSharedCalendarAccess( + 'microsoft:user', + ), + microsoftCategoryScope => service.authorizeCategoryAccess( + 'microsoft:user', + ), + microsoftContactsReadScope => service.authorizeMicrosoftContacts( + 'microsoft:user', + ), + microsoftContactsWriteScope => service.authorizeMicrosoftContacts( + 'microsoft:user', + ), + _ => throw ArgumentError.value(scope, 'scope'), + }; + MicrosoftOAuthService _service( Future Function(http.Request request) handler, ) { diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index 24586e98..27815c96 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -1181,7 +1181,8 @@ void main() { await _scrollUntilBuilt(tester, action); await tester.ensureVisible(action); await tester.tap(action); - await tester.pumpAndSettle(); + await tester.pump(); + await _pumpAndSettleGraphIo(tester); expect( find.text('No busy periods reported for this interval'), findsOneWidget, @@ -1337,8 +1338,7 @@ void main() { pendingDelete: false, pendingMove: false, rawJson: '{}', - assignmentInfoJson: - '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + assignmentInfoJson: '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', updatedLocalAtUtc: '2026-09-14T00:00:00.000Z', ); var mutationStarted = false; @@ -1467,12 +1467,12 @@ void main() { await tester.pumpAndSettle(); expect(requests, 0); await tester.tap(find.text('Linked resources')); - await tester.pumpAndSettle(); + await _pumpAndSettleGraphIo(tester); expect(requests, 1); expect(find.text('Launch plan · Planner'), findsOneWidget); expect(find.text('Message · Outlook'), findsNothing); await tester.tap(find.byKey(const Key('task-attachments-load'))); - await tester.pumpAndSettle(); + await _pumpAndSettleGraphIo(tester); expect(requests, 2); expect(find.text('Notes.txt'), findsOneWidget); expect(find.text('Task'), findsWidgets); @@ -1512,24 +1512,28 @@ void main() { taskListId, taskId, ); - await expectLater( - coordinator.uploadTask( - client: todoClient, - accountId: accountId, - taskListId: taskListId, - taskId: taskId, - name: 'notes.txt', - contentType: 'text/plain', - bytes: [1], + await tester.runAsync( + () => expectLater( + coordinator.uploadTask( + client: todoClient, + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), ), - throwsA(isA()), ); expect( - await coordinator.reconcileTask( - client: todoClient, - accountId: accountId, - taskListId: taskListId, - taskId: taskId, + await tester.runAsync( + () => coordinator.reconcileTask( + client: todoClient, + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + ), ), AttachmentUploadStatus.unresolved, ); @@ -1734,6 +1738,7 @@ void main() { findsOneWidget, ); expect(tester.takeException(), isNull); + await harness.dispose(); }); } @@ -2578,12 +2583,11 @@ void main() { expect(operations, hasLength(1)); expect(operations.single.operationType, 'dav.update'); final queuedBody = - DavMutationPatch.fromJsonString( - operations.single.mutationPatchJson!, - ).applyTo( - operations.single.baselineRawIcs!, - nowUtc: DateTime.now().toUtc(), - ); + DavMutationPatch.fromJsonString(operations.single.mutationPatchJson!) + .applyTo( + operations.single.baselineRawIcs!, + nowUtc: DateTime.now().toUtc(), + ); expect(queuedBody, contains('TRIGGER:-PT45M')); expect(queuedBody, contains('X-KEEP:first')); expect(queuedBody, contains('X-KEEP:second')); @@ -2724,14 +2728,10 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, 'Save')); await tester.pumpAndSettle(); - final request = - jsonDecode( - (await harness.database - .select(harness.database.pendingOps) - .getSingle()) - .requestJson, - ) - as Map; + final request = jsonDecode( + (await harness.database.select(harness.database.pendingOps).getSingle()) + .requestJson, + ) as Map; expect(request['eventType'], 'focusTime'); expect( (request['googleStatusProperties'] as Map)['autoDeclineMode'], @@ -2791,14 +2791,10 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, 'Save')); await tester.pumpAndSettle(); - final request = - jsonDecode( - (await harness.database - .select(harness.database.pendingOps) - .getSingle()) - .requestJson, - ) - as Map; + final request = jsonDecode( + (await harness.database.select(harness.database.pendingOps).getSingle()) + .requestJson, + ) as Map; expect(request['categoriesJson'], ['Unknown', 'Work']); await tester.pump(const Duration(seconds: 6)); }); @@ -2865,6 +2861,14 @@ void main() { }); } +Future _pumpAndSettleGraphIo(WidgetTester tester) async { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); +} + TaskEntity _movableTask({String taskListId = 'source-list'}) => TaskEntity( accountId: 'google:move', taskListId: taskListId, @@ -3426,13 +3430,16 @@ final class _PermissionOnlyNotifications extends Fake } final class _AndroidAppHarness { - const _AndroidAppHarness(this.tester, this.container, this.database); + _AndroidAppHarness(this.tester, this.container, this.database); final WidgetTester tester; final ProviderContainer container; final AppDatabase database; + bool _disposed = false; Future dispose() async { + if (_disposed) return; + _disposed = true; await tester.pumpWidget(const SizedBox.shrink()); container.dispose(); await database.close(); diff --git a/test/platform/android/android_authorization_broker_test.dart b/test/platform/android/android_authorization_broker_test.dart index b6e85c8a..c49738e2 100644 --- a/test/platform/android/android_authorization_broker_test.dart +++ b/test/platform/android/android_authorization_broker_test.dart @@ -39,9 +39,8 @@ void main() { }); await expectLater( - broker( - InMemorySecretStore(), - ).authorizationHeader(BusyProvider.google, 'google:account'), + broker(InMemorySecretStore()) + .authorizationHeader(BusyProvider.google, 'google:account'), throwsA( isA().having( (error) => error.code, @@ -73,9 +72,8 @@ void main() { }); await expectLater( - broker( - InMemorySecretStore(), - ).authorizationHeader(BusyProvider.microsoft, 'microsoft:account'), + broker(InMemorySecretStore()) + .authorizationHeader(BusyProvider.microsoft, 'microsoft:account'), throwsA( isA().having( (error) => error.code, @@ -105,37 +103,34 @@ void main() { expect(removal.arguments, containsPair('revoke', false)); }); - test( - 'optional Outlook category header requires the grant even with an opaque token', - () async { - var includeCategory = false; - messenger.setMockMethodCallHandler(channel, (call) async { - expect(call.method, 'authorizeMicrosoftSilent'); - expect( - (call.arguments as Map)['scopes'], - contains('MailboxSettings.Read'), - ); - return { - 'accessToken': 'opaque-token-value', - 'nativeAccountId': 'native', - 'scopes': [ - 'User.Read', - 'Tasks.ReadWrite', - 'Calendars.ReadWrite', - if (includeCategory) 'MailboxSettings.Read', - ], - }; - }); - final subject = broker(InMemorySecretStore()); - await expectLater( - subject.microsoftCategoryAuthorizationHeader('microsoft:account'), - throwsA(isA()), - ); - includeCategory = true; + test('optional Outlook category header requires the grant even with an opaque token', () async { + var includeCategory = false; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'authorizeMicrosoftSilent'); expect( - await subject.microsoftCategoryAuthorizationHeader('microsoft:account'), - 'Bearer opaque-token-value', + (call.arguments as Map)['scopes'], + contains('MailboxSettings.Read'), ); - }, - ); + return { + 'accessToken': 'opaque-token-value', + 'nativeAccountId': 'native', + 'scopes': [ + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + if (includeCategory) 'MailboxSettings.Read', + ], + }; + }); + final subject = broker(InMemorySecretStore()); + await expectLater( + subject.microsoftCategoryAuthorizationHeader('microsoft:account'), + throwsA(isA()), + ); + includeCategory = true; + expect( + await subject.microsoftCategoryAuthorizationHeader('microsoft:account'), + 'Bearer opaque-token-value', + ); + }); } diff --git a/test/platform/android/android_contacts_enrollment_test.dart b/test/platform/android/android_contacts_enrollment_test.dart new file mode 100644 index 00000000..437b68e9 --- /dev/null +++ b/test/platform/android/android_contacts_enrollment_test.dart @@ -0,0 +1,271 @@ +import 'dart:convert'; + +import 'package:busymax/src/android/android_authorization.dart'; +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; +import 'package:busymax/src/contacts/busymax_contacts_store.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/core/auth/oauth_registration.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +const tenant = '11111111-1111-1111-1111-111111111111'; +const client = '33333333-3333-3333-3333-333333333333'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('busymax.contacts.real-native-broker.test'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final scenario in [ + 'first-read', + 'broader-only', + 'both', + 'partial', + 'wrong-account', + 'cancel', + 'persistence', + ]) { + test( + '$provider real native broker $scenario commits truthful grants and read-only link atomically', + () async { + final db = AppDatabase.memoryForTests(); + final secrets = InMemorySecretStore(); + final accounts = AccountsRepository(database: db); + final contactStore = BusyMaxContactsStore(db); + final id = provider.name; + final base = provider == BusyProvider.google + ? [googleTasksReadWriteScope, googleCalendarReadWriteScope] + : ['User.Read', 'Tasks.ReadWrite', 'Calendars.ReadWrite']; + final read = provider == BusyProvider.google + ? googleContactsReadScope + : 'Contacts.Read'; + final write = provider == BusyProvider.google + ? googleContactsWriteScope + : 'Contacts.ReadWrite'; + final broad = scenario == 'broader-only' || scenario == 'both'; + final returned = [ + ...base, + if (provider == BusyProvider.microsoft) ...[ + 'Calendars.ReadWrite.Shared', + 'MailboxSettings.Read', + ], + if (scenario != 'partial' && scenario != 'broader-only') read, + if (broad) write, + ]; + final bindings = {}; + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + final args = (call.arguments as Map?) ?? {}; + switch (call.method) { + case 'acquireAccountGate': + return 'lease'; + case 'releaseAccountGate': + return null; + case 'microsoftRegistrationIdentity': + return { + 'packageName': 'io.busystack.busymax', + 'signatureHash': 'synthetic', + 'redirectUri': 'msauth://io.busystack.busymax/synthetic', + 'retiringClientId': '22222222-2222-2222-2222-222222222222', + }; + case 'readAuthorizationBinding': + return bindings.isEmpty ? null : bindings; + case 'bindAuthorization': + bindings.addAll(Map.from(args)); + return null; + case 'clearAuthorizationBinding': + bindings.clear(); + return null; + case 'cancelInteractiveAuthorization': + return null; + case 'authorizeGoogleInteractive': + case 'authorizeMicrosoftInteractive': + case 'authorizeGoogleSilent': + case 'authorizeMicrosoftSilent': + final requested = (args['scopes'] as List).cast(); + expect(requested, containsAll(base)); + expect(requested, contains(read)); + expect(requested, isNot(contains(write))); + if (scenario == 'cancel') { + throw PlatformException(code: 'android/auth-cancelled'); + } + return { + 'nativeAccountId': 'native', + 'accessToken': scenario == 'wrong-account' + ? 'wrong' + : 'native-token', + 'authority': 'https://login.microsoftonline.com/$tenant', + 'scopes': returned, + }; + default: + throw StateError('Unexpected ${call.method}'); + } + }); + final transport = MockClient( + (r) async => http.Response( + jsonEncode( + r.url.host == 'people.googleapis.com' + ? { + 'connections': [], + 'contactGroups': [], + 'nextSyncToken': 'sync', + } + : r.url.path.contains('/contacts') || + r.url.path.contains('contactFolders') + ? {'value': []} + : provider == BusyProvider.google + ? { + 'sub': r.headers['authorization'] == 'Bearer wrong' + ? 'wrong' + : 'owner', + } + : { + 'id': r.headers['authorization'] == 'Bearer wrong' + ? 'wrong' + : 'owner', + }, + ), + 200, + headers: {'content-type': 'application/json'}, + ), + ); + final broker = AndroidAuthorizationBroker( + platform: BusyMaxAndroidPlatform(methodChannel: channel), + config: BuildConfig.forAndroid(), + secretStore: secrets, + database: db, + httpClient: transport, + ); + final controller = BusyMaxContactsController( + store: contactStore, + accounts: accounts, + authorization: broker, + httpClient: transport, + readDavSecret: (_) async => null, + writeDavSecret: (_, _) async {}, + deleteDavSecret: (_) async {}, + readLinkedDavCredential: (_) async => null, + launchBrowser: (_) async => false, + readAuthorizationGeneration: broker.persistence!.generation, + ); + addTearDown(() async { + await controller.close(); + await db.close(); + messenger.setMockMethodCallHandler(channel, null); + }); + final initial = [...base, if (broad) ...returned.skip(base.length)]; + await accounts.upsertSignedInAccount( + id: id, + provider: provider, + providerAccountId: 'owner', + tenantId: provider == BusyProvider.microsoft ? tenant : null, + grantedScopes: initial.join(' '), + ); + final NativeOAuthCredential credential = + provider == BusyProvider.google + ? const GoogleAndroidCredential( + nativeAccountId: 'native', + subject: 'owner', + generation: 1, + ) + : MicrosoftAndroidCredential( + nativeAccountId: 'native', + subject: 'owner', + generation: 1, + tenantId: tenant, + authority: 'https://login.microsoftonline.com/$tenant', + transitionEligible: false, + registration: MicrosoftPublicRegistration( + clientId: client, + audience: MicrosoftAudience.personalAndOrganizations, + platform: AuthenticationPlatform.android, + ), + ); + await broker.persistence!.commit( + accountId: id, + expectedGeneration: 0, + candidate: credential, + requireExisting: true, + persistAccount: () async {}, + ); + if (scenario == 'persistence') { + await db.customStatement( + "CREATE TRIGGER reject_contact_link BEFORE INSERT ON bm_contact_account_links BEGIN SELECT RAISE(ABORT,'fixture persistence'); END", + ); + } + final failed = [ + 'partial', + 'wrong-account', + 'cancel', + 'persistence', + ].contains(scenario); + if (failed) { + await expectLater( + controller.enableLinkedContacts(id), + throwsA(isA()), + ); + expect( + (await accounts.accountById(id))!.grantedScopes, + initial.join(' '), + ); + expect( + await contactStore.read((tx) => tx.account('contacts:$id')), + isNull, + ); + expect( + (await broker.persistence!.readCurrentCredential( + id, + ) as NativeOAuthCredential).generation, + 1, + ); + } else { + await controller.enableLinkedContacts(id); + await controller.synchronizeAccount('contacts:$id'); + final contact = (await contactStore.read( + (tx) => tx.account('contacts:$id'), + ))!; + expect( + contact.grantedScopes, + anyOf(contains(read), contains(write)), + ); + expect(contact.grantedScopes.contains(write), broad); + final parent = (await accounts.accountById(id))!.grantedScopes + .split(' ') + .toSet(); + expect(parent, { + for (final scope in returned) + provider == BusyProvider.microsoft + ? 'https://graph.microsoft.com/$scope' + : scope, + }); + final interactives = calls.where( + (c) => c.method.endsWith('Interactive'), + ); + expect(interactives.length, broad ? 0 : 1); + expect( + (await broker.persistence!.readCurrentCredential( + id, + ) as NativeOAuthCredential).generation, + broad ? 1 : 2, + ); + } + expect( + calls.where((c) => c.method == 'removeAuthorization'), + isEmpty, + ); + }, + ); + } + } +} diff --git a/test/platform/android/android_event_attendee_edit_test.dart b/test/platform/android/android_event_attendee_edit_test.dart index 22a3da1e..bd7094d3 100644 --- a/test/platform/android/android_event_attendee_edit_test.dart +++ b/test/platform/android/android_event_attendee_edit_test.dart @@ -242,9 +242,11 @@ void main() { {'method': 'popup', 'minutes': 60}, ], }; - final changed = - androidEditGooglePopupReminder(original, overrideIndex: 2, minutes: 10) - as Map; + final changed = androidEditGooglePopupReminder( + original, + overrideIndex: 2, + minutes: 10, + ) as Map; expect(changed['overrides'], [ {'method': 'popup', 'minutes': 0, 'providerTag': 'retain'}, {'method': 'email', 'minutes': 30}, @@ -256,9 +258,11 @@ void main() { {'method': 'email', 'minutes': 30}, {'method': 'popup', 'minutes': 10}, ]); - final added = - androidEditGooglePopupReminder(removed, overrideIndex: null, minutes: 5) - as Map; + final added = androidEditGooglePopupReminder( + removed, + overrideIndex: null, + minutes: 5, + ) as Map; expect((added['overrides'] as List).length, 3); expect((added['overrides'] as List)[0], {'method': 'email', 'minutes': 30}); }); diff --git a/test/platform/android/android_registration_transition_test.dart b/test/platform/android/android_registration_transition_test.dart index 44135cbe..df1cfb5f 100644 --- a/test/platform/android/android_registration_transition_test.dart +++ b/test/platform/android/android_registration_transition_test.dart @@ -1,6 +1,8 @@ import 'package:busymax/src/core/auth/authorization_attempt.dart'; + import 'dart:convert'; import 'dart:async'; + import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/android/android_authorization.dart'; import 'package:busymax/src/config/build_config.dart'; @@ -174,42 +176,39 @@ void main() { await broker.nativeCredential('opaque', BusyProvider.microsoft); } - test( - 'Android cancellation captured before recovery prevents later native dispatch and fresh retry succeeds', - () async { - final cancellation = AuthorizationCancellation(); - final operation = repository.signInWithMicrosoft( - request: AuthorizationRequest.newConnection( - own(), - cancellation: cancellation, - ), - ); - final rejected = expectLater( - operation, - throwsA( - isA().having( - (e) => e.classification, - 'cancelled', - OAuthFailureKind.cancelled, - ), + test('Android cancellation captured before recovery prevents later native dispatch and fresh retry succeeds', () async { + final cancellation = AuthorizationCancellation(); + final operation = repository.signInWithMicrosoft( + request: AuthorizationRequest.newConnection( + own(), + cancellation: cancellation, + ), + ); + final rejected = expectLater( + operation, + throwsA( + isA().having( + (e) => e.classification, + 'cancelled', + OAuthFailureKind.cancelled, ), - ); - cancellation.cancel(); - await rejected; - expect( - calls.where((c) => c.method == 'authorizeMicrosoftInteractive'), - isEmpty, - ); - expect(await db.select(db.accounts).get(), isEmpty); - await repository.signInWithMicrosoft( - request: AuthorizationRequest.newConnection(own()), - ); - expect( - calls.where((c) => c.method == 'authorizeMicrosoftInteractive'), - hasLength(1), - ); - }, - ); + ), + ); + cancellation.cancel(); + await rejected; + expect( + calls.where((c) => c.method == 'authorizeMicrosoftInteractive'), + isEmpty, + ); + expect(await db.select(db.accounts).get(), isEmpty); + await repository.signInWithMicrosoft( + request: AuthorizationRequest.newConnection(own()), + ); + expect( + calls.where((c) => c.method == 'authorizeMicrosoftInteractive'), + hasLength(1), + ); + }); for (final shared in [false, true]) { for (final phase in ['identity', 'native']) { test( @@ -281,9 +280,9 @@ void main() { request: AuthorizationRequest.reconnect('opaque'), ); release.complete(); - final after = - await secrets.readCredential('opaque') - as MicrosoftAndroidCredential; + final after = await secrets.readCredential( + 'opaque', + ) as MicrosoftAndroidCredential; expect(after.registration.clientId, shared ? clientA : clientB); expect(after.summary.showRetirementNotice, shared); expect(after.generation, (before['generation'] as int) + 1); @@ -291,90 +290,84 @@ void main() { ); } } - test( - 'original native binding upgrades idempotently and user-owned migration selects its own MSAL registration', - () async { - await seedShared(); - final original = - await secrets.readCredential('opaque') as MicrosoftAndroidCredential; - expect(original.summary.showRetirementNotice, true); - await broker.nativeCredential('opaque', BusyProvider.microsoft); - await repository.signInWithMicrosoft( - request: AuthorizationRequest.replace('opaque', own()), - ); - final migrated = - await secrets.readCredential('opaque') as MicrosoftAndroidCredential; - expect(migrated.registration.clientId, clientB); - expect(migrated.summary.showRetirementNotice, false); - expect(bindings['opaque']!['clientId'], clientB); - expect(migrated.toJson().keys, isNot(contains('refreshToken'))); - await broker.authorizationHeader(BusyProvider.microsoft, 'opaque'); - await broker.authorizeCategoryAccess('opaque'); - expect(bindings['opaque']!['clientId'], clientB); - expect( - calls - .where((c) => c.method == 'authorizeMicrosoftInteractive') - .every((c) => (c.arguments as Map)['clientId'] == clientB), - true, - ); - expect(calls.any((c) => c.method == 'removeAuthorization'), false); - expect( - await AccountsRepository(database: db).accountById('microsoft:subject'), - isNull, - ); - }, - ); - test( - 'late original-client silent result cannot replace migrated token selection', - () async { - await seedShared(); - silentStarted = Completer(); - silentRelease = Completer(); - final pending = broker.authorizationHeader( - BusyProvider.microsoft, - 'opaque', - ); - final rejected = expectLater( - pending, - throwsA( - isA().having( - (error) => error.classification, - 'kind', - OAuthFailureKind.stale, - ), + test('original native binding upgrades idempotently and user-owned migration selects its own MSAL registration', () async { + await seedShared(); + final original = + await secrets.readCredential('opaque') as MicrosoftAndroidCredential; + expect(original.summary.showRetirementNotice, true); + await broker.nativeCredential('opaque', BusyProvider.microsoft); + await repository.signInWithMicrosoft( + request: AuthorizationRequest.replace('opaque', own()), + ); + final migrated = + await secrets.readCredential('opaque') as MicrosoftAndroidCredential; + expect(migrated.registration.clientId, clientB); + expect(migrated.summary.showRetirementNotice, false); + expect(bindings['opaque']!['clientId'], clientB); + expect(migrated.toJson().keys, isNot(contains('refreshToken'))); + await broker.authorizationHeader(BusyProvider.microsoft, 'opaque'); + await broker.authorizeCategoryAccess('opaque'); + expect(bindings['opaque']!['clientId'], clientB); + expect( + calls + .where((c) => c.method == 'authorizeMicrosoftInteractive') + .every((c) => (c.arguments as Map)['clientId'] == clientB), + true, + ); + expect(calls.any((c) => c.method == 'removeAuthorization'), false); + expect( + await AccountsRepository(database: db).accountById('microsoft:subject'), + isNull, + ); + }); + test('late original-client silent result cannot replace migrated token selection', () async { + await seedShared(); + silentStarted = Completer(); + silentRelease = Completer(); + final pending = broker.authorizationHeader( + BusyProvider.microsoft, + 'opaque', + ); + final rejected = expectLater( + pending, + throwsA( + isA().having( + (error) => error.classification, + 'kind', + OAuthFailureKind.stale, ), - ); - await silentStarted!.future; - final originalCall = calls.lastWhere( - (c) => c.method == 'authorizeMicrosoftSilent', - ); - expect((originalCall.arguments as Map)['clientId'], clientA); - expect( - (originalCall.arguments as Map)['nativeAccountId'], - 'original-native', - ); - await repository.signInWithMicrosoft( - request: AuthorizationRequest.replace('opaque', own()), - ); - silentRelease!.complete(); - await rejected; - interactiveStarted = null; - interactiveRelease = null; - identityStarted = null; - identityRelease = null; - silentStarted = null; - silentRelease = null; - expect( - await broker.authorizationHeader(BusyProvider.microsoft, 'opaque'), - 'Bearer token-$clientB', - ); - final currentCall = calls.lastWhere( - (c) => c.method == 'authorizeMicrosoftSilent', - ); - expect((currentCall.arguments as Map)['clientId'], clientB); - expect(calls.any((c) => c.method == 'removeAuthorization'), false); - }, - ); + ), + ); + await silentStarted!.future; + final originalCall = calls.lastWhere( + (c) => c.method == 'authorizeMicrosoftSilent', + ); + expect((originalCall.arguments as Map)['clientId'], clientA); + expect( + (originalCall.arguments as Map)['nativeAccountId'], + 'original-native', + ); + await repository.signInWithMicrosoft( + request: AuthorizationRequest.replace('opaque', own()), + ); + silentRelease!.complete(); + await rejected; + interactiveStarted = null; + interactiveRelease = null; + identityStarted = null; + identityRelease = null; + silentStarted = null; + silentRelease = null; + expect( + await broker.authorizationHeader(BusyProvider.microsoft, 'opaque'), + 'Bearer token-$clientB', + ); + final currentCall = calls.lastWhere( + (c) => c.method == 'authorizeMicrosoftSilent', + ); + expect((currentCall.arguments as Map)['clientId'], clientB); + expect(calls.any((c) => c.method == 'removeAuthorization'), false); + }); test( 'native wrong-account migration preserves the previous binding and warning', () async { @@ -388,101 +381,85 @@ void main() { ); expect(bindings['opaque']!['clientId'], clientA); expect( - (await secrets.readCredential('opaque') as MicrosoftAndroidCredential) - .summary - .showRetirementNotice, + (await secrets.readCredential( + 'opaque', + ) as MicrosoftAndroidCredential).summary.showRetirementNotice, true, ); }, ); - test( - 'new Android Microsoft setup cannot disguise the original native registration', - () async { - final handle = staging.stageMicrosoft( - clientId: clientA, - audience: MicrosoftAudience.personalAndOrganizations, - platform: AuthenticationPlatform.android, - ); - await expectLater( - repository.signInWithMicrosoft( - request: AuthorizationRequest.newConnection(handle), - ), - throwsA(isA()), - ); - expect( - calls.any((c) => c.method == 'authorizeMicrosoftInteractive'), - false, - ); - expect(await db.select(db.accounts).get(), isEmpty); - }, - ); - test( - 'user-owned Android Microsoft setup needs no shared build client or exported native tokens', - () async { - await repository.signInWithMicrosoft( - request: AuthorizationRequest.newConnection(own()), - ); - final saved = - await secrets.readCredential('microsoft:subject') - as MicrosoftAndroidCredential; - expect(saved.registration.clientId, clientB); - expect(saved.summary.showRetirementNotice, false); - expect(saved.toJson().keys, isNot(contains('accessToken'))); - await broker.authorizationHeader( - BusyProvider.microsoft, - 'microsoft:subject', - ); - expect(bindings['microsoft:subject']!['clientId'], clientB); - }, - ); - test( - 'native Google origin never offers a Desktop migration or retirement notice', - () { - const saved = GoogleAndroidCredential( - nativeAccountId: 'native', - subject: 'subject', - generation: 1, - ); - expect(saved.summary.origin, RegistrationOrigin.nativeGoogleAndroid); - expect(saved.summary.showRetirementNotice, false); - expect( - SecretRecord.fromJson(saved.toJson()), - isA(), - ); - }, - ); - test( - 'database failure restores an unresolved native alias after candidate binding', - () async { - await seedShared(); - await secrets.deleteCredential('opaque'); - bindings['opaque']!['originalRegistration'] = false; - final previous = Map.from(bindings['opaque']!); - await db.customStatement( - "CREATE TRIGGER reject_candidate BEFORE UPDATE ON accounts BEGIN SELECT RAISE(ABORT, 'injected'); END", - ); - await expectLater( - repository.signInWithMicrosoft( - request: AuthorizationRequest.replace('opaque', own()), - ), - throwsA(isA()), - ); - expect( - bindings['opaque']!['nativeAccountId'], - previous['nativeAccountId'], - ); - expect(bindings['opaque']!['clientId'], clientA); - expect(await secrets.readCredential('opaque'), isNull); - expect(await broker.persistence!.generation('opaque'), 0); - expect( - (await AccountsRepository( - database: db, - ).accountById('opaque'))!.providerAccountId, - 'subject', - ); - expect(calls.any((c) => c.method == 'removeAuthorization'), false); - }, - ); + test('new Android Microsoft setup cannot disguise the original native registration', () async { + final handle = staging.stageMicrosoft( + clientId: clientA, + audience: MicrosoftAudience.personalAndOrganizations, + platform: AuthenticationPlatform.android, + ); + await expectLater( + repository.signInWithMicrosoft( + request: AuthorizationRequest.newConnection(handle), + ), + throwsA(isA()), + ); + expect( + calls.any((c) => c.method == 'authorizeMicrosoftInteractive'), + false, + ); + expect(await db.select(db.accounts).get(), isEmpty); + }); + test('user-owned Android Microsoft setup needs no shared build client or exported native tokens', () async { + await repository.signInWithMicrosoft( + request: AuthorizationRequest.newConnection(own()), + ); + final saved = await secrets.readCredential( + 'microsoft:subject', + ) as MicrosoftAndroidCredential; + expect(saved.registration.clientId, clientB); + expect(saved.summary.showRetirementNotice, false); + expect(saved.toJson().keys, isNot(contains('accessToken'))); + await broker.authorizationHeader( + BusyProvider.microsoft, + 'microsoft:subject', + ); + expect(bindings['microsoft:subject']!['clientId'], clientB); + }); + test('native Google origin never offers a Desktop migration or retirement notice', () { + const saved = GoogleAndroidCredential( + nativeAccountId: 'native', + subject: 'subject', + generation: 1, + ); + expect(saved.summary.origin, RegistrationOrigin.nativeGoogleAndroid); + expect(saved.summary.showRetirementNotice, false); + expect( + SecretRecord.fromJson(saved.toJson()), + isA(), + ); + }); + test('database failure restores an unresolved native alias after candidate binding', () async { + await seedShared(); + await secrets.deleteCredential('opaque'); + bindings['opaque']!['originalRegistration'] = false; + final previous = Map.from(bindings['opaque']!); + await db.customStatement( + "CREATE TRIGGER reject_candidate BEFORE UPDATE ON accounts BEGIN SELECT RAISE(ABORT, 'injected'); END", + ); + await expectLater( + repository.signInWithMicrosoft( + request: AuthorizationRequest.replace('opaque', own()), + ), + throwsA(isA()), + ); + expect(bindings['opaque']!['nativeAccountId'], previous['nativeAccountId']); + expect(bindings['opaque']!['clientId'], clientA); + expect(await secrets.readCredential('opaque'), isNull); + expect(await broker.persistence!.generation('opaque'), 0); + expect( + (await AccountsRepository(database: db).accountById('opaque'))! + .providerAccountId, + 'subject', + ); + expect(calls.any((c) => c.method == 'removeAuthorization'), false); + }); test( 'silent acquisition repairs stale alias from secure registration binding', () async { diff --git a/test/platform/attendee_contacts_context_test.dart b/test/platform/attendee_contacts_context_test.dart new file mode 100644 index 00000000..f23d968e --- /dev/null +++ b/test/platform/attendee_contacts_context_test.dart @@ -0,0 +1,269 @@ +import 'dart:async'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_event_editor_dialog.dart'; +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/contacts_fixture.dart'; +import '../support/memory_settings_store.dart'; +import '../test_localized_app.dart'; +import 'contacts_settings_routes_test.dart' show complete, settle; + +void main() { + for (final platform in ['Linux', 'Windows', 'Android']) { + for (final scenario in [ + 'switch-visible', + 'switch-delayed', + 'disable-visible', + 'disable-delayed', + 'remove-visible', + 'remove-delayed', + 'dispose-delayed', + ]) { + final delayed = scenario.endsWith('delayed'); + testWidgets('$platform fences $scenario suggestions', (tester) async { + tester.view.physicalSize = const Size(1800, 2400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fixture = ContactsFixture(AppDatabase.memoryForTests()); + final repository = CalendarRepository(database: fixture.database); + await tester.runAsync(() async { + await fixture.addParent(BusyProvider.google); + await fixture.addParent(BusyProvider.microsoft); + await fixture.controller.enableLinkedContacts('google'); + await fixture.controller.synchronizeAccount('contacts:google'); + for (final provider in [ + BusyProvider.google, + BusyProvider.microsoft, + ]) { + await repository.upsertSource( + accountId: provider.name, + source: CalendarSourceDto( + provider: provider, + providerCalendarId: provider.name, + summary: provider == BusyProvider.google + ? 'Calendar A' + : 'Calendar B', + accessRole: 'owner', + ), + ); + } + }); + final sources = (await tester.runAsync( + () => + repository.watchSourcesForAccounts(['google', 'microsoft']).first, + ))!; + final a = sources.firstWhere((s) => s.accountId == 'google'); + final b = sources.firstWhere((s) => s.accountId == 'microsoft'); + final draft = + EventEditorDraft.newEvent( + accountId: a.accountId, + sourceId: a.id, + providerCalendarId: a.providerCalendarId, + start: DateTime(2026, 10, 10, 12), + end: DateTime(2026, 10, 10, 13), + ).copyWith( + title: 'Keep my draft', + attendees: const [ + EventAttendeeDraft( + email: 'manual@example.test', + displayName: 'Retained', + responseStatus: 'accepted', + ), + ], + ); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(fixture.database), + busyMaxContactsControllerProvider.overrideWithValue( + fixture.controller, + ), + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + initialAppSettingsProvider.overrideWithValue( + AppSettings.defaults(), + ), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await complete(tester, fixture.controller.close); + container.dispose(); + await tester.runAsync(fixture.database.close); + }); + final Widget editor = platform == 'Linux' + ? EventEditor( + initialDraft: draft, + sources: sources, + onSave: (_) {}, + onCancel: () {}, + ) + : AndroidEventEditor(sources: sources, draft: draft); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Consumer( + builder: (context, ref, _) => fluent.Button( + child: const Text('Open'), + onPressed: () => showWindowsEventEditorDialog( + context, + ref, + initialAccountId: a.accountId, + initialSourceId: a.id, + ), + ), + ), + ) + : localizedTestApp( + child: platform == 'Linux' + ? Scaffold(body: editor) + : editor, + ), + ), + ); + if (platform == 'Windows') { + await tester.tap(find.text('Open')); + } + await settle(tester); + if (platform == 'Linux') { + await tester.ensureVisible(find.text('Add Guest').first); + await tester.tap(find.text('Add Guest').first); + await tester.pump(); + } + final actualSource = (await complete( + tester, + fixture.controller.sourceSettings, + )).single.source; + final entered = Completer(); + final release = Completer(); + Future? blocker; + addTearDown(() { + if (!release.isCompleted) release.complete(); + }); + if (delayed) { + blocker = fixture.store.write((tx) async { + entered.complete(); + await release.future; + }); + await complete(tester, () => entered.future); + } + final guestInput = platform == 'Windows' + ? find + .byWidgetPredicate( + (w) => w is fluent.TextBox && w.placeholder == 'Add Guest', + ) + .last + : find.byWidgetPredicate( + (w) => + w is TextField && + w.decoration?.labelText == 'Add guest email' || + w is TextField && w.decoration?.labelText == 'Guests', + ); + // Each route uses its production attendee input, located by the + // localized label/controller rather than an alternative lookup widget. + final input = guestInput.evaluate().isNotEmpty + ? guestInput.first + : find.byType(EditableText).last; + await tester.ensureVisible(input); + await tester.enterText( + input, + platform == 'Android' ? 'manual@example.test, ada' : 'ada', + ); + await settle(tester); + if (!delayed) expect(find.textContaining('Ada Fixture'), findsWidgets); + if (scenario.startsWith('switch') && platform == 'Windows') { + final combo = tester.widget>( + find.byType(fluent.ComboBox), + ); + combo.onChanged!(b); + } else if (scenario.startsWith('switch') && platform == 'Android') { + final dropdown = tester.widget>( + find.byType(DropdownButtonFormField).first, + ); + dropdown.onChanged!(b.id); + } else if (scenario.startsWith('switch')) { + // Drive the existing account selector's public selection callback. + final selector = find.byWidgetPredicate( + (w) => + w.runtimeType.toString() == 'BusyMaxComboRow' && + (w as dynamic).title == 'Account', + ); + (tester.widget(selector) as dynamic).onSelected('microsoft'); + } + Future? eligibilityChange; + if (scenario.startsWith('disable')) { + final source = delayed + ? null + : (await complete( + tester, + fixture.controller.sourceSettings, + )).single.source; + // Source key comes from the provider fixture; a delayed lookup is + // queued behind the held adapter transaction, so this durable change + // completes before the UI can present its old result. + final key = source?.key ?? actualSource.key; + eligibilityChange = fixture.controller.setSourceEnabled( + key, + enabled: false, + ); + } + if (scenario.startsWith('remove')) { + eligibilityChange = fixture.controller.removeContactsAccount( + 'contacts:google', + ); + } + if (scenario.startsWith('dispose')) { + await tester.pumpWidget(const SizedBox()); + } + await tester.pump(); + if (scenario.startsWith('switch') || scenario.startsWith('dispose')) { + expect(find.textContaining('Ada Fixture'), findsNothing); + } + if (delayed) { + release.complete(); + await complete(tester, () => blocker!); + } + if (eligibilityChange != null) { + await complete(tester, () => eligibilityChange!); + } + await settle(tester); + expect(find.textContaining('Ada Fixture'), findsNothing); + if (!scenario.startsWith('dispose') && platform == 'Linux') { + expect(find.text('manual@example.test'), findsWidgets); + } + if (!scenario.startsWith('dispose') && platform == 'Android') { + expect( + tester + .widget( + find.descendant( + of: input, + matching: find.byType(EditableText), + ), + ) + .controller + .text, + contains('manual@example.test'), + ); + } + await tester.pumpWidget(const SizedBox()); + await settle(tester); + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index 6541873e..7a6f8785 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -247,9 +247,8 @@ void main() { expect(shown, 1); expect(agendas, 1); - final source = File( - 'lib/src/platform/busymax_tray_service.dart', - ).readAsStringSync(); + final source = File('lib/src/platform/busymax_tray_service.dart') + .readAsStringSync(); expect(source, isNot(contains('onContextMenu:'))); }); @@ -363,9 +362,8 @@ void main() { }); test('Linux desktop identity matches the displayed BusyMax window', () { - final desktop = File( - 'linux/io.busystack.busymax.desktop', - ).readAsStringSync(); + final desktop = File('linux/io.busystack.busymax.desktop') + .readAsStringSync(); final cmake = File('linux/CMakeLists.txt').readAsStringSync(); final runner = File('linux/runner/my_application.cc').readAsStringSync(); diff --git a/test/platform/contacts_settings_routes_test.dart b/test/platform/contacts_settings_routes_test.dart new file mode 100644 index 00000000..6b1558c6 --- /dev/null +++ b/test/platform/contacts_settings_routes_test.dart @@ -0,0 +1,460 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../support/dav_contacts_fixture.dart'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/android/presentation/android_settings_screen.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/features/notifications/notification_reconciler.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_settings_page.dart'; +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/contacts_fixture.dart'; +import '../support/memory_settings_store.dart'; +import '../test_localized_app.dart'; + +Future settle(WidgetTester tester) async { + for (var i = 0; i < 12; i++) { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + } + await tester.pumpAndSettle(); +} + +Future complete(WidgetTester tester, Future Function() action) async { + T? value; + Object? error; + StackTrace? stack; + var done = false; + action().then( + (result) { + value = result; + done = true; + }, + onError: (Object cause, StackTrace trace) { + error = cause; + stack = trace; + done = true; + }, + ); + for (var i = 0; i < 150 && !done; i++) { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + } + expect(done, isTrue, reason: 'The real route operation must complete'); + if (error != null) Error.throwWithStackTrace(error!, stack!); + return value as T; +} + +Future contactActionsReady(WidgetTester tester, bool windows) async { + bool ready() { + final actions = find.ancestor( + of: find.text('Remove').first, + matching: windows ? find.byType(fluent.Button) : find.byType(TextButton), + ); + if (actions.evaluate().isEmpty) return false; + return (tester.widget(actions.first) as dynamic).onPressed != null; + } + + for (var i = 0; i < 150 && !ready(); i++) { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + } + expect( + ready(), + isTrue, + reason: 'Native contact actions must settle after the owned enrollment and binding finish', + ); +} + +void main() { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity_status'), + (_) async => null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (_) async => ['wifi'], + ); + }); + for (final windows in [true, false]) { + for (final nextcloud in [false, true]) { + testWidgets( + '${windows ? 'Windows' : 'Android'} Settings completes independent ${nextcloud ? 'Nextcloud' : 'CardDAV'} read-only setup and cancellation', + (tester) async { + tester.view.physicalSize = const Size(1600, 2200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final f = ContactsFixture( + AppDatabase.memoryForTests(), + realDav: true, + ); + final dav = DavFixture(); + f.respondDav = (request) async { + if (request.url.path.endsWith('/index.php/login/v2')) { + return http.Response( + jsonEncode({ + 'login': server.resolve('login').toString(), + 'poll': { + 'token': 'fixture', + 'endpoint': server.resolve('poll').toString(), + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + if (request.url.path.endsWith('/poll')) { + return http.Response( + jsonEncode({ + 'server': server.toString(), + 'loginName': 'actual-user', + 'appPassword': 'fixture', + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return dav.respond(request); + }; + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(f.database), + busyMaxContactsControllerProvider.overrideWithValue(f.controller), + localSettingsStoreProvider.overrideWithValue( + MemorySettingsStore(), + ), + initialAppSettingsProvider.overrideWithValue( + AppSettings.defaults(), + ), + accountManagementStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + calendarSourcesStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + scheduleTaskListsProvider.overrideWith((ref) async => const []), + webCalSubscriptionsProvider.overrideWith( + (ref) => Stream.value(const []), + ), + davConflictsStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + if (!windows) + buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), + if (!windows) + androidNotificationServiceProvider.overrideWithValue( + AndroidNotificationService( + database: f.database, + settings: AppSettings.defaults, + ), + ), + notificationReconcilerProvider.overrideWithValue( + CallbackNotificationReconciler(() async {}), + ), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + await complete(tester, f.controller.close); + container.dispose(); + await tester.runAsync(f.database.close); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: windows + ? const fluent.FluentApp( + localizationsDelegates: [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: WindowsSettingsPage(), + ) + : localizedTestApp(child: const AndroidSettingsScreen()), + ), + ); + await settle(tester); + final add = find.text( + nextcloud ? 'Add Nextcloud contacts' : 'Add CardDAV contacts', + ); + await tester.ensureVisible(add); + await tester.tap(add); + await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(find.text('Cancel').last); + await settle(tester); + expect( + await complete( + tester, + () => f.controller.store.read((tx) => tx.accounts()), + ), + isEmpty, + ); + await tester.tap(add); + await tester.pump(const Duration(milliseconds: 400)); + final fields = windows + ? find.byType(fluent.TextBox) + : find.byType(TextField); + await tester.enterText(fields.at(0), 'Independent fixture'); + await tester.enterText(fields.at(1), server.toString()); + if (!nextcloud) { + await tester.enterText(fields.at(2), 'actual-user'); + await tester.enterText(fields.at(3), 'fixture'); + } + final enrolled = f.store.changes + .asyncMap((_) => f.store.read((tx) => tx.count())) + .firstWhere((count) => count > 0); + await tester.tap(find.text('Connect').last); + await complete(tester, () => enrolled); + await settle(tester); + final account = (await complete( + tester, + () => f.controller.store.read((tx) => tx.accounts()), + )).single; + await complete( + tester, + () => f.controller.synchronizeAccount(account.id), + ); + await settle(tester); + expect(account.grantedScopes, contains('carddav:read')); + expect( + await complete( + tester, + () => f.controller.suggestAttendees( + 'one', + busyMaxAccountId: 'calendar-owner', + ), + ), + isNotEmpty, + ); + final setting = (await complete( + tester, + f.controller.sourceSettings, + )).first; + final toggle = find.byKey( + ValueKey('contacts-source:${setting.source.key}'), + ); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await settle(tester); + expect( + (await complete(tester, f.controller.sourceSettings)).first.enabled, + isFalse, + ); + expect( + (await complete( + tester, + () => f.controller.store.read((tx) => tx.account(account.id)), + ))!.enabled, + isTrue, + ); + expect(find.text('Enable contact editing'), findsNothing); + expect(find.textContaining('Read and write access'), findsNothing); + expect( + find.textContaining('Read-only contacts for attendee suggestions'), + findsWidgets, + ); + await contactActionsReady(tester, windows); + await tester.ensureVisible(find.text('Remove').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove').first); + await settle(tester); + expect( + await complete( + tester, + () => f.controller.store.read((tx) => tx.accounts()), + ), + isEmpty, + ); + expect(f.davSecrets, isEmpty); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + await complete(tester, f.controller.close); + container.dispose(); + }, + ); + } + } + for (final windows in [true, false]) { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + testWidgets( + '${windows ? 'Windows' : 'Android'} Settings enrolls $provider read-only, selects and removes', + (tester) async { + tester.view.physicalSize = const Size(1600, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fixture = ContactsFixture(AppDatabase.memoryForTests()); + await tester.runAsync(() => fixture.addParent(provider)); + final parents = await tester.runAsync( + () => fixture.accounts.accountById(provider.name), + ); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(fixture.database), + signedInSyncRunnerProvider.overrideWithValue((_, _) async {}), + allAccountsSyncRunnerProvider.overrideWithValue(() async {}), + notificationReconcilerProvider.overrideWithValue( + CallbackNotificationReconciler(() async {}), + ), + if (!windows) + androidNotificationServiceProvider.overrideWithValue( + AndroidNotificationService( + database: fixture.database, + settings: AppSettings.defaults, + ), + ), + busyMaxContactsControllerProvider.overrideWithValue( + fixture.controller, + ), + localSettingsStoreProvider.overrideWithValue( + MemorySettingsStore(), + ), + initialAppSettingsProvider.overrideWithValue( + AppSettings.defaults(), + ), + if (!windows) + buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), + accountManagementStreamProvider.overrideWith( + (ref) => Stream.value([parents!]), + ), + calendarSourcesStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + scheduleTaskListsProvider.overrideWith((ref) async => const []), + webCalSubscriptionsProvider.overrideWith( + (ref) => Stream.value(const []), + ), + davConflictsStreamProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + container.dispose(); + await complete(tester, fixture.controller.close); + await tester.runAsync(fixture.database.close); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: windows + ? const fluent.FluentApp( + localizationsDelegates: [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: WindowsSettingsPage(), + ) + : localizedTestApp(child: const AndroidSettingsScreen()), + ), + ); + await settle(tester); + expect( + find.text( + 'Use cached contacts from this account when adding event guests.', + ), + findsOneWidget, + ); + await tester.ensureVisible( + find.text('Enable contact suggestions').first, + ); + await tester.tap(find.text('Enable contact suggestions').first); + await settle(tester); + final id = 'contacts:${provider.name}'; + await complete( + tester, + () => fixture.controller.synchronizeAccount(id), + ); + await settle(tester); + expect(find.textContaining('Read-only'), findsWidgets); + expect( + await complete( + tester, + () => fixture.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + ), + ), + hasLength(1), + ); + // Select the exact source by its provider-discovered name. + final settings = (await complete( + tester, + fixture.controller.sourceSettings, + )); + final sourceText = find.byKey( + ValueKey('contacts-source:${settings.single.source.key}'), + ); + expect(sourceText, findsOneWidget); + await tester.ensureVisible(sourceText); + await tester.tap(sourceText); + await settle(tester); + expect( + (await complete( + tester, + fixture.controller.sourceSettings, + )).single.enabled, + isFalse, + ); + expect( + await complete( + tester, + () => fixture.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + ), + ), + isEmpty, + ); + await tester.tap(sourceText); + await settle(tester); + expect(find.text('Enable contact editing'), findsNothing); + expect(find.textContaining('Read and write access'), findsNothing); + await contactActionsReady(tester, windows); + await tester.ensureVisible(find.text('Remove').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove').first); + await settle(tester); + expect( + await complete( + tester, + () => fixture.controller.suggestAttendees( + 'ada', + busyMaxAccountId: provider.name, + ), + ), + isEmpty, + ); + expect( + await complete( + tester, + () => fixture.store.read((tx) => tx.account(id)), + ), + isNull, + ); + await tester.pumpWidget(const SizedBox()); + await complete(tester, fixture.controller.close); + container.dispose(); + }, + ); + } + } +} diff --git a/test/platform/gtk_animation_settings_service_test.dart b/test/platform/gtk_animation_settings_service_test.dart index c0ded5ba..9d3c5988 100644 --- a/test/platform/gtk_animation_settings_service_test.dart +++ b/test/platform/gtk_animation_settings_service_test.dart @@ -19,9 +19,8 @@ void main() { addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); expect( - await const GtkAnimationSettingsService( - channel: channel, - ).getAnimationsEnabled(), + await const GtkAnimationSettingsService(channel: channel) + .getAnimationsEnabled(), isFalse, ); }); @@ -42,9 +41,10 @@ void main() { ); addTearDown(() => messenger.setMockStreamHandler(events, null)); - final values = const GtkAnimationSettingsService( - events: events, - ).watchAnimationsEnabled().take(2).toList(); + final values = const GtkAnimationSettingsService(events: events) + .watchAnimationsEnabled() + .take(2) + .toList(); await pumpEventQueue(); sink!.success(false); @@ -73,9 +73,8 @@ void main() { 'native GTK bridge observes and tears down animation preference state', () { final source = File('linux/runner/my_application.cc').readAsStringSync(); - final frame = File( - 'lib/src/app/linux/linux_page_frame.dart', - ).readAsStringSync(); + final frame = File('lib/src/app/linux/linux_page_frame.dart') + .readAsStringSync(); expect(source, contains('"notify::gtk-enable-animations"')); expect(source, contains('send_gtk_animation_settings_event(self);')); diff --git a/test/platform/gtk_font_service_test.dart b/test/platform/gtk_font_service_test.dart index 3ed5d1b1..87be0516 100644 --- a/test/platform/gtk_font_service_test.dart +++ b/test/platform/gtk_font_service_test.dart @@ -145,9 +145,8 @@ void main() { await const GtkFontService(channel: fontChannel).getGtkFont(), isNull, ); - final colors = await const GtkThemeService( - channel: themeChannel, - ).getGtkThemeColors(); + final colors = await const GtkThemeService(channel: themeChannel) + .getGtkThemeColors(); expect(colors?.brightness, Brightness.light); expect(colors?.window, isNull); expect(colors?.view, isNull); @@ -189,9 +188,9 @@ void main() { .setMockStreamHandler(events, null); }); - final settings = await const GtkFontService( - fontSettingsEvents: events, - ).watchGtkFont().first; + final settings = await const GtkFontService(fontSettingsEvents: events) + .watchGtkFont() + .first; expect(settings, const GtkFontSettings(family: 'GTK Test Sans', size: 11)); }); @@ -217,9 +216,8 @@ void main() { .setMockStreamHandler(events, null); }); - final stream = const GtkFontService( - fontSettingsEvents: events, - ).watchGtkFont(); + final stream = const GtkFontService(fontSettingsEvents: events) + .watchGtkFont(); final valuesFuture = stream.take(2).toList(); await pumpEventQueue(); @@ -249,9 +247,9 @@ void main() { .setMockStreamHandler(events, null); }); - final settings = await const GtkFontService( - fontSettingsEvents: events, - ).watchGtkFont().first; + final settings = await const GtkFontService(fontSettingsEvents: events) + .watchGtkFont() + .first; expect(settings, isNull); }); @@ -296,9 +294,8 @@ void main() { .setMockMethodCallHandler(channel, null); }); - final colors = await const GtkThemeService( - channel: channel, - ).getGtkThemeColors(); + final colors = await const GtkThemeService(channel: channel) + .getGtkThemeColors(); expect(colors?.brightness, Brightness.dark); expect(colors?.window, const Color(0xFF202020)); @@ -433,9 +430,8 @@ void main() { .setMockMethodCallHandler(channel, null); }); - final colors = await const GtkThemeService( - channel: channel, - ).getGtkThemeColors(); + final colors = await const GtkThemeService(channel: channel) + .getGtkThemeColors(); expect(colors?.brightness, Brightness.light); expect(colors?.window, const Color(0xFFFAFAFA)); @@ -464,9 +460,8 @@ void main() { .setMockMethodCallHandler(channel, null); }); - final colors = await const GtkThemeService( - channel: channel, - ).getGtkThemeColors(); + final colors = await const GtkThemeService(channel: channel) + .getGtkThemeColors(); expect(colors?.window, const Color(0xFFFAFAFA)); expect(colors?.searchEntry, isNull); @@ -497,9 +492,8 @@ void main() { .setMockMethodCallHandler(channel, null); }); - final colors = await const GtkThemeService( - channel: channel, - ).getGtkThemeColors(); + final colors = await const GtkThemeService(channel: channel) + .getGtkThemeColors(); expect(colors?.searchEntry, isNotNull); expect(colors?.searchEntry?.focused.radius, 12); @@ -527,9 +521,8 @@ void main() { .setMockMethodCallHandler(channel, null); }); - final colors = await const GtkThemeService( - channel: channel, - ).getGtkThemeColors(); + final colors = await const GtkThemeService(channel: channel) + .getGtkThemeColors(); expect(colors?.brightness, Brightness.dark); expect(colors?.searchEntry, isNull); @@ -593,9 +586,8 @@ void main() { .setMockStreamHandler(events, null); }); - final stream = const GtkThemeService( - themeColorsEvents: events, - ).watchGtkThemeColors(); + final stream = const GtkThemeService(themeColorsEvents: events) + .watchGtkThemeColors(); final valuesFuture = stream.take(2).toList(); await pumpEventQueue(); @@ -631,9 +623,9 @@ void main() { .setMockStreamHandler(events, null); }); - final colors = await const GtkThemeService( - themeColorsEvents: events, - ).watchGtkThemeColors().first; + final colors = await const GtkThemeService(themeColorsEvents: events) + .watchGtkThemeColors() + .first; expect(colors, isNull); }); diff --git a/test/platform/linux_autostart_service_test.dart b/test/platform/linux_autostart_service_test.dart index a153fb6d..aa0ecef3 100644 --- a/test/platform/linux_autostart_service_test.dart +++ b/test/platform/linux_autostart_service_test.dart @@ -147,34 +147,30 @@ void main() { await service.setEnabled(false); expect(await service.isEnabled(), isFalse); expect( - await File( - path.join(configHome, 'autostart', busyMaxAutostartFileName), - ).exists(), + await File(path.join(configHome, 'autostart', busyMaxAutostartFileName)) + .exists(), isFalse, ); }); - test( - 'Snap reports the GNOME startup launch as externally managed', - () async { - environment.addAll({ - 'SNAP': '/snap/busymax/current', - 'GIO_LAUNCHED_DESKTOP_FILE': - '/home/example/.config/autostart/busymax_busymax.desktop', - 'GIO_LAUNCHED_DESKTOP_FILE_PID': '123', - }); - expect(await service.state(), DesktopAutostartState.enabledExternally); - expect(await service.isEnabled(), isTrue); - expect((await service.state()).canChange, isFalse); - for (final enabled in [true, false]) { - await expectLater(service.setEnabled(enabled), throwsStateError); - } - expect(await Directory(configHome).exists(), isFalse); - // The external entry still controls startup even if a Snap entry exists. - await writeEntry(configHome, activeEntry); - expect(await service.state(), DesktopAutostartState.enabledExternally); - }, - ); + test('Snap reports the GNOME startup launch as externally managed', () async { + environment.addAll({ + 'SNAP': '/snap/busymax/current', + 'GIO_LAUNCHED_DESKTOP_FILE': + '/home/example/.config/autostart/busymax_busymax.desktop', + 'GIO_LAUNCHED_DESKTOP_FILE_PID': '123', + }); + expect(await service.state(), DesktopAutostartState.enabledExternally); + expect(await service.isEnabled(), isTrue); + expect((await service.state()).canChange, isFalse); + for (final enabled in [true, false]) { + await expectLater(service.setEnabled(enabled), throwsStateError); + } + expect(await Directory(configHome).exists(), isFalse); + // The external entry still controls startup even if a Snap entry exists. + await writeEntry(configHome, activeEntry); + expect(await service.state(), DesktopAutostartState.enabledExternally); + }); test('Snap ignores inherited GIO metadata from another process', () async { environment.addAll({ diff --git a/test/platform/native_event_attachments_test.dart b/test/platform/native_event_attachments_test.dart index 8bf35b49..dd63db23 100644 --- a/test/platform/native_event_attachments_test.dart +++ b/test/platform/native_event_attachments_test.dart @@ -57,59 +57,89 @@ class _AttachmentTestFileSelector extends FileSelectorPlatform { ); } +Future _pumpStreamedResponse(WidgetTester tester) async { + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 20)); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + await tester.pumpAndSettle(); +} + void main() { - test( - 'R3 missing retry header backs off repeated direct 429s without creating uncertainty', - () async { - var now = DateTime.utc(2026, 10, 1); - var submissions = 0; - final coordinator = AttachmentUploadCoordinator(now: () => now); - addTearDown(coordinator.dispose); - final key = AttachmentUploadCoordinator.eventKey( - 'account', - 'calendar', - 'event', - ); - Future upload() => coordinator.upload( - key: key, - name: 'a.txt', - size: 1, - contentType: 'text/plain', - bytes: const [1], - list: () async => const [], - submit: (_) async { - submissions++; - if (submissions <= 2) { - throw const MicrosoftCalendarApiError( - statusCode: 429, - code: 'TooManyRequests', - message: 'Throttled', - ); - } - return 'confirmed'; - }, - ); - await expectLater(upload(), throwsA(isA())); - final first = coordinator.retryAfter(key)!; - expect(first, greaterThanOrEqualTo(const Duration(seconds: 2))); - expect(first, lessThan(const Duration(seconds: 3))); - await expectLater( - upload(), - throwsA(isA()), - ); - expect(submissions, 1); - now = now.add(first + const Duration(milliseconds: 1)); - await expectLater(upload(), throwsA(isA())); - final second = coordinator.retryAfter(key)!; - expect(second, greaterThanOrEqualTo(const Duration(seconds: 4))); - expect(second, lessThan(const Duration(seconds: 5))); - expect(coordinator.needsReconciliation(key), isFalse); - now = now.add(second + const Duration(milliseconds: 1)); - await upload(); - expect(submissions, 3); - expect(coordinator.canSubmit(key), isTrue); - }, - ); + TestWidgetsFlutterBinding.ensureInitialized(); + + const yaruWindowChannel = MethodChannel('yaru_window'); + const yaruEventsChannel = EventChannel('yaru_window/events'); + + setUp(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + yaruWindowChannel, + (call) async => call.method == 'state' ? {} : null, + ); + messenger.setMockStreamHandler( + yaruEventsChannel, + MockStreamHandler.inline(onListen: (_, _) {}), + ); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(yaruWindowChannel, null); + messenger.setMockStreamHandler(yaruEventsChannel, null); + }); + + test('R3 missing retry header backs off repeated direct 429s without creating uncertainty', () async { + var now = DateTime.utc(2026, 10, 1); + var submissions = 0; + final coordinator = AttachmentUploadCoordinator(now: () => now); + addTearDown(coordinator.dispose); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.upload( + key: key, + name: 'a.txt', + size: 1, + contentType: 'text/plain', + bytes: const [1], + list: () async => const [], + submit: (_) async { + submissions++; + if (submissions <= 2) { + throw const MicrosoftCalendarApiError( + statusCode: 429, + code: 'TooManyRequests', + message: 'Throttled', + ); + } + return 'confirmed'; + }, + ); + await expectLater(upload(), throwsA(isA())); + final first = coordinator.retryAfter(key)!; + expect(first, greaterThanOrEqualTo(const Duration(seconds: 2))); + expect(first, lessThan(const Duration(seconds: 3))); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(submissions, 1); + now = now.add(first + const Duration(milliseconds: 1)); + await expectLater(upload(), throwsA(isA())); + final second = coordinator.retryAfter(key)!; + expect(second, greaterThanOrEqualTo(const Duration(seconds: 4))); + expect(second, lessThan(const Duration(seconds: 5))); + expect(coordinator.needsReconciliation(key), isFalse); + now = now.add(second + const Duration(milliseconds: 1)); + await upload(); + expect(submissions, 3); + expect(coordinator.canSubmit(key), isTrue); + }); test( 'R3 throttled chunk does not discard an existing upload session', @@ -307,9 +337,8 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftCalendarApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftCalendarApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -348,7 +377,14 @@ void main() { await tester.pump(); } await tester.tap(find.byKey(const Key('event-attachment-add'))); - for (var i = 0; i < 20 && posts == 0; i++) { + for ( + var i = 0; + i < 50 && + (posts == 0 || + coordinator.status(key) == AttachmentUploadStatus.submitting); + i++ + ) { + await tester.runAsync(() => Future.delayed(Duration.zero)); await tester.pump(const Duration(milliseconds: 1)); } for (var i = 0; i < 4; i++) { @@ -383,10 +419,18 @@ void main() { await tester.pump(const Duration(seconds: 31)); expect(coordinator.canSubmit(key), isTrue); await tester.tap(find.byKey(const Key('event-attachment-add'))); - for (var i = 0; i < 20 && posts < 2; i++) { + for ( + var i = 0; + i < 50 && + (posts < 2 || + coordinator.status(key) == AttachmentUploadStatus.submitting); + i++ + ) { + await tester.runAsync(() => Future.delayed(Duration.zero)); await tester.pump(const Duration(milliseconds: 1)); } expect(posts, 2); + expect(coordinator.status(key), AttachmentUploadStatus.committed); if (platform == 'Windows') { await tester.pump(const Duration(milliseconds: 200)); } @@ -544,9 +588,8 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftCalendarApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftCalendarApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -588,6 +631,7 @@ void main() { } await tester.tap(find.byKey(const Key('event-attachment-add'))); for (var i = 0; i < 30 && !held; i++) { + await tester.runAsync(() => Future.delayed(Duration.zero)); await tester.pump(const Duration(milliseconds: 1)); } expect(held, isTrue, reason: 'uploads=$uploads lists=$listRequests'); @@ -632,6 +676,7 @@ void main() { } else { heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); } + await tester.runAsync(() => Future.delayed(Duration.zero)); for (var i = 0; i < 4; i++) { await tester.pump(); } @@ -863,8 +908,7 @@ void main() { '', 201, headers: { - 'Location': - 'https://outlook.office.com/events/event/attachments/confirmed-id', + 'Location': 'https://outlook.office.com/events/event/attachments/confirmed-id', }, ); } @@ -1280,14 +1324,16 @@ void main() { responseTimeZone: 'UTC', ); final coordinator = AttachmentUploadCoordinator(); - await coordinator.uploadEvent( - client: client, - accountId: 'microsoft:a', - calendarId: 'remote-calendar', - eventId: 'remote-event', - name: 'a.txt', - contentType: 'text/plain', - bytes: const [1], + await tester.runAsync( + () => coordinator.uploadEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + name: 'a.txt', + contentType: 'text/plain', + bytes: const [1], + ), ); await tester.pumpWidget( ProviderScope( @@ -1295,9 +1341,8 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftCalendarApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftCalendarApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], child: localizedTestApp( child: Scaffold( @@ -1313,15 +1358,21 @@ void main() { ), ); await tester.tap(find.text('Open attachments')); - await tester.pumpAndSettle(); + for (var i = 0; i < 4; i++) { + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(const Duration(milliseconds: 100)); + } final add = tester.widget( find.byKey(const Key('event-attachment-add')), ); expect(add.onPressed, isNot(equals(null))); await tester.tap(find.text('Close')); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 400)); await tester.tap(find.text('Open attachments')); - await tester.pumpAndSettle(); + for (var i = 0; i < 4; i++) { + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(const Duration(milliseconds: 100)); + } expect( tester .widget(find.byKey(const Key('event-attachment-add'))) @@ -1479,24 +1530,28 @@ void main() { 'remote-calendar', 'remote-event', ); - await expectLater( - coordinator.uploadEvent( - client: client, - accountId: 'microsoft:a', - calendarId: 'remote-calendar', - eventId: 'remote-event', - name: 'notes.txt', - contentType: 'text/plain', - bytes: [1], + await tester.runAsync( + () => expectLater( + coordinator.uploadEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), ), - throwsA(isA()), ); expect( - await coordinator.reconcileEvent( - client: client, - accountId: 'microsoft:a', - calendarId: 'remote-calendar', - eventId: 'remote-event', + await tester.runAsync( + () => coordinator.reconcileEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + ), ), AttachmentUploadStatus.unresolved, ); @@ -1506,9 +1561,8 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftCalendarApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftCalendarApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -1543,14 +1597,26 @@ void main() { ), ); await tester.tap(find.text('Open attachments')); + await tester.pump(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); await tester.pumpAndSettle(); expect(find.text('Retry'), findsOneWidget); expect(find.byKey(const Key('event-attachment-add')), findsOneWidget); expect(coordinator.canSubmit(key), isFalse); await tester.tap(find.text('Retry')); + await tester.pump(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); await tester.pumpAndSettle(); expect(coordinator.canSubmit(key), isFalse); await tester.tap(find.text('Retry').last); + await tester.pump(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); await tester.pumpAndSettle(); expect(coordinator.canSubmit(key), isTrue); expect(posts, 1); @@ -1804,9 +1870,8 @@ void main() { ProviderScope( overrides: [ calendarRepositoryProvider.overrideWithValue(repository), - googleCalendarApiClientForAccountProvider( - 'google:a', - ).overrideWithValue(client), + googleCalendarApiClientForAccountProvider('google:a') + .overrideWithValue(client), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -1900,9 +1965,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - microsoftCalendarApiClientForAccountProvider( - 'google:a', - ).overrideWithValue(graph), + microsoftCalendarApiClientForAccountProvider('google:a') + .overrideWithValue(graph), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -1989,9 +2053,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ - microsoftCalendarApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftCalendarApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -2004,7 +2067,7 @@ void main() { ); expect(requests, 0); await tester.tap(find.text('Open attachments')); - await tester.pumpAndSettle(); + await _pumpStreamedResponse(tester); expect(requests, 1); expect(find.text('Agenda.pdf'), findsOneWidget); expect(find.text('4 B'), findsOneWidget); @@ -2068,15 +2131,23 @@ void main() { 'remote-calendar', 'remote-event', ); - await coordinator.uploadEvent( - client: client, - accountId: 'microsoft:a', - calendarId: 'remote-calendar', - eventId: 'remote-event', - name: 'Agenda.pdf', - contentType: 'application/pdf', - bytes: const [1, 2, 3, 4], + await tester.runAsync( + () => coordinator.uploadEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + name: 'Agenda.pdf', + contentType: 'application/pdf', + bytes: const [1, 2, 3, 4], + ), ); + await tester.runAsync(() async { + for (var i = 0; i < 20 && failRefresh; i++) { + await Future.delayed(Duration.zero); + } + }); + expect(failRefresh, isFalse); expect(coordinator.confirmedId(uploadKey), 'file'); expect(coordinator.canSubmit(uploadKey), isTrue); await tester.pumpWidget( @@ -2085,9 +2156,8 @@ void main() { attachmentUploadCoordinatorProvider.overrideWith( (ref) => coordinator, ), - microsoftCalendarApiClientForAccountProvider( - 'microsoft:a', - ).overrideWithValue(client), + microsoftCalendarApiClientForAccountProvider('microsoft:a') + .overrideWithValue(client), ], child: platform == 'Windows' ? fluent.FluentApp( @@ -2122,7 +2192,7 @@ void main() { ), ); await tester.tap(find.text('Open attachments')); - await tester.pumpAndSettle(); + await _pumpStreamedResponse(tester); if (platform == 'Android') { await tester.tap(find.byTooltip('Delete').last); } else { @@ -2140,7 +2210,17 @@ void main() { } await tester.pumpAndSettle(); await tester.tap(find.text('Delete').last); - await tester.pumpAndSettle(); + await _pumpStreamedResponse(tester); + await tester.runAsync(() async { + for ( + var i = 0; + i < 20 && coordinator.confirmedId(uploadKey) != null; + i++ + ) { + await Future.delayed(Duration.zero); + } + }); + await tester.pump(); expect(deletes, 1); expect(posts, 1); expect(coordinator.confirmedId(uploadKey), equals(null)); diff --git a/test/platform/windows/windows_account_workspace_routing_test.dart b/test/platform/windows/windows_account_workspace_routing_test.dart index 5ea2dec1..8cb0f34b 100644 --- a/test/platform/windows/windows_account_workspace_routing_test.dart +++ b/test/platform/windows/windows_account_workspace_routing_test.dart @@ -1,10 +1,13 @@ import '../../support/desktop_connection_fixture.dart'; import '../../support/native_registration_reader_fixture.dart'; + import 'package:busymax/src/core/auth/oauth_registration.dart'; import 'package:busymax/src/core/auth/registration_file_reader.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; + import 'dart:async'; import 'dart:convert'; + import 'package:busymax/src/webcal/webcal_http_client.dart'; import 'package:busymax/src/webcal/webcal_subscription_service.dart'; @@ -92,9 +95,9 @@ void main() { await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await _pumpConnectionPage(tester); expect( - GoRouterState.of( - tester.element(find.byType(WindowsSettingsPage)), - ).uri.path, + GoRouterState.of(tester.element(find.byType(WindowsSettingsPage))) + .uri + .path, '/settings', ); expect( @@ -130,9 +133,9 @@ void main() { break; } } - final record = - await tester.runAsync(() => fixture.secrets.readCredential(id)) - as BoundOAuthSecretRecord; + final record = await tester.runAsync( + () => fixture.secrets.readCredential(id), + ) as BoundOAuthSecretRecord; expect( record.registration.summary().origin, method == DesktopConnectionMethod.busyMax @@ -183,9 +186,9 @@ void main() { break; } } - final replaced = - await tester.runAsync(() => fixture.secrets.readCredential(id)) - as BoundOAuthSecretRecord; + final replaced = await tester.runAsync( + () => fixture.secrets.readCredential(id), + ) as BoundOAuthSecretRecord; expect(replaced.generation, 3); expect(replaced.subject, record.subject); expect( diff --git a/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart b/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart index d3aff305..42841a85 100644 --- a/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart +++ b/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart @@ -106,17 +106,16 @@ void main() { ), ), ); - await tester.tap(find.text('Open sharing')); - await tester.pumpAndSettle(); + await _tapAndSettleGraphIo(tester, find.text('Open sharing')); await tester.enterText( find.byKey(const Key('windows-sharing-recipient')), 'friend@example.test', ); await tester.ensureVisible(find.byKey(const Key('windows-sharing-add'))); - await tester.tap(find.byKey(const Key('windows-sharing-add'))); - for (var i = 0; i < 4; i++) { - await tester.pump(); - } + await _tapAndSettleGraphIo( + tester, + find.byKey(const Key('windows-sharing-add')), + ); expect(posts, 1); expect( find.text( @@ -148,10 +147,7 @@ void main() { expect(tester.widget