From 42dd355454cfc5e86bd10f0c58ea786267f20b5f Mon Sep 17 00:00:00 2001 From: Albert G Date: Sun, 11 Oct 2026 04:50:03 -0700 Subject: [PATCH 1/8] Feature/shared contacts package (#27) * Integrate shared contacts Graph and DAV * Pin final shared contacts revision * Ignore generated source artifacts * Fix/CI and managed Microsoft OAuth (#23) * Separate CI from production releases and fix managed Microsoft OAuth * Use notification timezone in daily summary test fixtures * Release/v0.2.4 (#24) * Release/v0.2.4 (#26) * Bump version to v0.2.4 * Update Flutter to 3.47.5 and Dart to 3.13.4 across toolchain, tests, and documentation. * Fix/essential correctness and usability (#19) * Add tests for providers * Add tests for CalDAV attachment URI and event URL projection * Add tests for Windows Microsoft event and task handling * Add tests for Microsoft calendar and task features * Add tests for Microsoft task details and attachment handling * Add Android dialogs for event attachments and recurrence editing * Add Microsoft task and event attachment providers * Preserve Microsoft meeting body HTML during edits to maintain meeting state * Handle non-list attachments in Google Calendar mapper to ensure consistent data structure * Add support for event attachments in Microsoft Calendar API client * Add Microsoft Todo API support for linked resources and task attachments * Add support for conference links in CalDAV event projections * Add event attachment links support in schedule items * Add Windows dialogs for cloud availability and event attachments * Add cloud calendar range coverage service and enhance event retrieval logic * Add support for rich text descriptions in event editor and enhance availability dialog * Add Linux event attachments dialog and implement attachment download functionality * Add Google task assignment policy and source link handling * Add multilingual support for linked resources and attachments in event management * Enhance live provider testing documentation for cloud-range synchronization, guest availability checks, and task-source navigation * Add provider tests: * Add unit tests for Microsoft meeting HTML preservation and encoded meeting URL parsing. * Add test coverage for Google Calendar attachment mutations and private-copy `iCalUID` import replaying. * Add test coverage for Microsoft Calendar event attachments, large file upload sessions, and series exception snapshots. * Add tests for delegated/shared primary calendar access, shared consent OAuth scope retention, and restricted private item projection. * Add tests for Microsoft To-Do file attachment uploads and strict parsing of malformed detail collection responses. * Update Microsoft Calendar mapping tests to ensure category names are not treated as provider color IDs. * Add test coverage for moved recurrence exception replay and cancelled occurrence persistence during iCal import. * * Refactor `cloudCoverageCompleteFor` and `ensureCloudCoverage` to accept schedule filters for source-aware range coverage checks. * Ensure cached schedule items are returned immediately while background cloud coverage sync completes. * Update schedule event detail interval labels to display formatted local time rather than raw event time zone strings. * Fix Microsoft Teams meeting HTML description replay during patch operations to prevent duplicate and stale content. * Enhance Microsoft sync engine to correctly preserve owner-context shared calendars and isolate explicit search sources during range coverage fetches. * Update task details pane test to trigger attachment loading by key. * * Add test coverage for confirming Microsoft event attachment deletion across native platforms. * Add test coverage for Android event details to verify local time projection and timezone display handling. * Add unit tests for preserving unsupported Google popup reminder overrides during edits and removals. * Add validation tests for the Android recurrence dialog to prevent saving invalid intervals. * Update tenant ID initialization and attachment widget key assumptions in Android adaptive app tests. * * Add tenant ID parameter to Microsoft account setup in Windows event preservation tests. * Add time zone display assertions to Windows schedule commands tests. * Refactor task attachment finder to use widget key in Windows task editor safety tests. * * Add Microsoft shared calendar support on Android, including interactive OAuth consent and settings UI options. * Add support for uploading and deleting attachments for Microsoft events and To Do tasks on Android. * Enhance Google event reminder management on Android with support for multiple custom popup reminders. * Improve recurrence dialog input validation for interval and count, and correct date boundary constraints. * Update schedule range coverage checks to account for active search filters. * Restrict Microsoft free/busy querying capability checks to work/school tenant accounts. * Enhance Microsoft shared calendar support, attachment uploads, and provider API handling * Support Microsoft shared primary calendars, delegated mailbox routing, and shared calendar OAuth consent. * Implement file attachment upload sessions for Microsoft Calendar events and Microsoft To-Do tasks. * Add `PrivateCalendarImportClient` and `changeEventAttachmentReferences` to Google Calendar API client. * Refactor Microsoft online meeting HTML parsing to cleanly separate provider blocks using balanced HTML tag inspection. * Determine Microsoft free/busy availability eligibility using explicit tenant IDs rather than decoding Graph access tokens. * Add `getSeriesExceptionSnapshot` to query Microsoft recurring event exception identities. * Harden Microsoft Graph API collection and attachment JSON deserialization against malformed payloads. * Support detached recurrence exceptions in iCalendar import * Add support for importing detached and cancelled recurrence exceptions for Google and Microsoft calendar destinations. * Validate exception timezones, interval durations, and date/time type compatibility during import preparation. * Parse and map modified exception properties including title, description, location, start/end times, and reminders to `ImportedEventException`. * Refactor cloud coverage tracking and add privacy masking for shared Microsoft events * Refactor cloud coverage logic in `ScheduleRepository` to be filter-aware, non-blocking, and debounced with rate-limiting. * Include cloud coverage state changes in `watchChanges()` stream emissions. * Mask title, event details, and attachments for private or owner-restricted Microsoft shared calendar events and disable their edit/delete capabilities. * * Add `microsoftSharedCalendarAuthorizationHeader` support to `AccountTokenBroker`. * Add `storedCalendarEventOverlapsUtcRange` utility in `stored_temporal_projection` to check cached event overlaps against UTC provider windows. * Add shared Microsoft calendar support, attachment management, and import exception handling * Add support for opening, syncing, and managing permissions for shared Microsoft primary calendars. * Implement file attachment upload and deletion for Microsoft events and To-Do tasks. * Handle recurring event import exceptions and Google iCal UID imports in calendar pending operations replayer. * Preserve Microsoft online meeting HTML subtrees during description editing and guard against unsafe body updates. * Enforce write permissions and private item access checks on shared Microsoft calendars across mutations and notifications. * Update schedule event details time formatting to display device-local time with offset and timezone labels. * Refactor cloud calendar range coverage and sync engine to support source-specific range requests. * Persist Microsoft `tenantId` in auth repository to validate work/school availability capabilities. * Add Microsoft shared calendar service and update range coverage filtering * Add `microsoftSharedCalendarServiceProvider` to support Microsoft shared calendar operations. * Update `microsoftCalendarApiClientForAccountProvider` to supply account tenant ID and shared calendar authorization header callback. * Forward account and source filters to `ensureCloudCoverage` within `scheduleRepositoryProvider`. * Add attachment management and shared calendar support on Windows * Add file upload and deletion capabilities for Microsoft event and task attachments on Windows. * Add support for opening Microsoft shared calendars in Windows settings. * Restrict Microsoft attendee availability lookups to work/school accounts and ensure proper event description editing. * Filter schedule cloud coverage checks and enhance loading feedback in the Windows schedule view. * Add localization strings for shared calendars and linked resources across supported languages. * Add run configuration for BusyMax on Linux * Add unit tests for DAV URI attachment patch operations * Add tests for Google Calendar ACL management and event label retrieval * Add tests for primary calendar permissions and master category retrieval * Add tests for category authorization header and consent persistence * Add tests for attachment management, recurring event handling, and Google ACL operations * Add Linux tests for calendar label selection, working location, and Microsoft master category assignment * Add Windows tests for category selection, working locations, and Google label queuing in event editor * Add label and category management features for Microsoft and Google calendars, including event permissions, label properties, and authorization updates. * Add federated recipient support and DAV URI attachment patch implementation * Add support for Microsoft category authorization header in AccountTokenBroker * Add Google primary-calendar status event validation, label management, and sharing service implementation * Add support for Microsoft master category retrieval and Google assignment deletion confirmation * Enhance task handling by introducing robust Google assignment validation across create, update, move, and delete operations. * Add recurring event series export functionality with Google and Microsoft calendar support. * Add cloud calendar sharing management functionality with UI support and account-based source eligibility checks * Add support for Google calendar status event handling and dynamic retry interval for schedule coverage checks * Add Google event label and Microsoft master category providers, including category authorization support * Add Windows cloud calendar sharing dialog and recurring event series export functionality for Google and Microsoft calendars * Add support for Google event labels, Microsoft master categories, and enhanced handling of status events with UI updates and category authorization * Add localized strings for calendar sharing, Google event types, working locations, and chat statuses in supported languages * Enhance documentation for calendar sharing and event management, including ACL grants, federation support, and status event handling across Google, Microsoft, and Nextcloud * Add tests for calendar sync, attachment upload handling, and advanced status/event operations across Google, Microsoft, and DAV contexts. * Add support for Google status type context in calendar event mutations * Validate Google status event types and enhance mutation mapping logic * Validate and handle editable DAV reminder rows in mutation logic * Add attachment upload coordinator provider to app bootstrap * Handle locally removed Microsoft shared calendar owners in repository and service logic * Handle DAV reminder rows in notification scheduling logic * Integrate attachment upload reconciliation and Microsoft series timezone support into event and calendar logic. * Validate and handle deleted calendar sources, enhance all-day event timezone logic, and integrate Google status event type context into calendar mutations. * Convert `_CalendarSettingsRow` to `ConsumerWidget` and add support for removing Microsoft shared calendars. * Integrate attachment upload reconciliation and update task editor to handle upload progress and errors. * Integrate attachment upload reconciliation into task and event dialogs, add support for removing Microsoft shared calendars, and convert `_WindowsCalendarSettingsRow` to `ConsumerWidget`. * Integrate attachment upload reconciliation for tasks and events, add support for editable Nextcloud reminders, and extend deletion logic for Microsoft shared calendars. * Add translations for shared calendar removal confirmation and unresolved attachment upload status. * Add tool to generate POSIX continuation rules from TZif footers in IANA tzdata files * Integrate attachment upload reconciliation tests for tasks and events across platforms, handle unresolved upload states, and enhance timezone logic in calendar series exports. * Add attachment upload session handling for tasks and events, support resuming and canceling uploads, and improve progress tracking and error handling. * Enable session handling and manual resolution for attachment uploads, add support for canceling and resuming uploads, and refine timezone logic in calendar series exports. * Enable manual resolution and session handling for attachment uploads, add cancel and retry options, and improve error handling in task and event dialogs. * Enable manual resolution, cancel, and retry options for attachment uploads in task and event dialogs, and improve session handling. * Add attachment removal support for tasks and events to correctly clear pending upload confirmations and improve reconciliation handling. * Fix disposal handling in AttachmentUploadCoordinator to prevent notifications after disposal and improve async refresh handling. * * Harden cloud calendar sharing to safely handle interrupted network responses, track unresolved mutation outcomes, and provide retry mechanisms across Android, Linux, and Windows. * Refactor Microsoft Graph scope validation to accurately handle short and mixed scope spellings, preventing unnecessary OAuth reconnections and consent prompts. * Restrict edits on Gmail-derived Google events to supported fields (color, reminders, visibility, attendees) and prevent unsupported calendar moves or type changes. * Add mapping for iCalendar classifications (`PUBLIC`, `PRIVATE`, `CONFIDENTIAL`) to Microsoft event sensitivity levels during import, and validate destination support. * Add comprehensive test coverage for calendar sharing state recovery, Gmail event mutation limits, iCalendar sensitivity mapping, and Microsoft OAuth scope parsing. * Treat pre-dispatch request failures as known uncommitted errors in `CloudCalendarSharingService` to prevent sharing operations from incorrectly entering an unresolved state when network requests fail prior to dispatch. * Rename `_isConfirmedRejection` to `_isKnownUncommittedFailure` and handle `RequestNotDispatchedException` as a safe uncommitted error state. * Maintain active grant states and restore UI mutation controls when calendar sharing network requests fail before transmission. * Add unit and widget test coverage for pre-dispatch network failures during calendar grant additions, updates, and revocations across Android, Linux, and Windows. * Harden cloud calendar sharing against authentication pre-dispatch failures and status code ambiguity * Wrap authorization header resolution and token refresh errors in `GoogleCalendarApiClient` and `MicrosoftCalendarApiClient` with `KnownUnsentRequestException`. * Exclude HTTP 408, 409, and 429 status codes from default 4xx pre-dispatch error classification in `CloudCalendarSharingService` to avoid treating ambiguous responses as unsent. * Add unit tests covering pre-dispatch authentication failures, failed 401 token refreshes, and ambiguous HTTP status codes during calendar sharing operations. * Handle rate limiting and cooldowns in cloud calendar sharing * Add `parseHttpRetryAfter` utility to extract retry delays from HTTP headers. * Update Google and Microsoft Calendar API error handlers to parse retry hints and identify rate-limiting status. * Implement cooldown tracking and backoff logic in `CloudCalendarSharingService` to prevent invalid retry mutations during rate limits. * Disable sharing actions and display rate-limit feedback in Android, Linux, and Windows calendar sharing views with automatic re-activation upon cooldown expiration. * Add localized error string for rate-limited calendar sharing across supported locales. * Implement rate limiting for attachment uploads and refine iCalendar import metadata * Add HTTP 429 Retry-After parsing to Microsoft Todo API error handling. * Support rate-limiting cooldowns and delay tracking in `AttachmentUploadCoordinator`. * Display localized rate-limit status messages in event and task attachment views across Android, Linux, and Windows. * Refactor iCalendar import logic to preserve classifications, transparency, categories, and zero-minute alarms across Google and Microsoft providers. * Add unit and widget tests for attachment upload rate-limiting backoff and iCalendar exception metadata replay. * * Resolve civil wall times using draft time zones for cloud availability queries across Android, Linux, and Windows. * Hide the recurrence rule editor for recurring event instances in Android and Windows event dialogs. * Handle Microsoft delta view removals in the calendar sync engine by verifying event existence before marking deleted locally. * Add targeted instance lookup with `originalStart` to `GoogleCalendarApiClient` and pending operation replay logic. * Enforce matching visibility across series masters and occurrences for Google Calendar iCal imports. * Exclude non-`START` relative triggers (e.g., `RELATED=END`) from display alarm projections in DAV and Android parsers. * Fix event label change detection in the Windows event editor dialog. * Fix all-day cloud availability time zones and handle missing Google default reminders * Pass calendar time zone to guest availability dialogs across Android, Linux, and Windows to ensure all-day event intervals resolve accurately using the selected source zone. * Support absent or null `defaultReminders` in Google calendar series exports by defaulting to an empty list instead of throwing a format exception. * Add unit and widget test coverage for all-day availability time zone resolution and Google series export reminder handling. * Fix Nextcloud federated share principal href encoding Avoid URI-encoding base64-encoded identifiers when constructing Nextcloud remote-user principal `href`s, ensuring trailing base64 padding is preserved without percent-encoding. * Feature/user owned cloud projects (#20) * Implemented OAuth-registration transition, account-safety corrections, and synchronization improvements. - Eligible shared accounts retain refresh/reconnect, with account-specific migration warnings. New connections use user-owned setup; Android Google remains native. - Added identity-checked staging, recoverable commits, generation guards, account-specific MSAL selection, persisted sync eligibility, and throttling handling. * Harden OAuth account lifecycle and sync recovery * Feature/remove onboarding (#21) * Refactor onboarding flow and remove dedicated sign-in screens * Remove `SignInScreen` and `WindowsSignInPage` along with their associated routing logic. * Update application startup to open the empty schedule workspace directly instead of a sign-in screen. * Add "Add account" button to the schedule sidebar across Linux and Windows for unauthenticated or subscription-only states. * Redirect account addition actions from the workspace to the settings screen. * Refactor settings screens to handle initial account setup, error messaging, and cancellation directly. * Remove unused onboarding localization strings and introduce translations for "Add account". * Add acceptance test for the new account workspace navigation and focus handling. * Update existing tests to reflect the new unauthenticated startup behavior and sidebar interactions. * Fix account session reconciliation and Windows workspace controls * Refactor DAV task capabilities to use a StreamProvider * Introduce `watchCollectionByTaskListId` in `DavSettingsRepository` to provide a reactive stream for collection settings. * Update `davTaskCollectionCapabilitiesProvider` from a `FutureProvider` to a `StreamProvider` to enable real-time updates. * Add comprehensive test coverage for dynamic DAV privilege changes and active editor session updates on Windows. * Update existing tests across platforms to support the new stream-based capabilities provider. * Refactor desktop OAuth registration dialogs and integrate setup instructions * Refactor desktop OAuth registration dialogs for Linux and Windows using native-styled components and improved layouts. * Integrate step-by-step setup instructions with copyable fields and external links directly into the registration flow. * Refactor `RegistrationSetupController` and `RegistrationStaging` to improve state tracking, real-time input validation, and lifecycle management. * Add localization keys and translations for setup instructions and form fields across all supported languages. * Add unit tests for `RegistrationSetupController` and comprehensive widget and integration tests for the updated registration UI. * Update documentation for Google and Microsoft setup procedures to reflect the new workflow. * * Implement explicitly managed desktop OAuth registrations and refactor connection workflows: - Separate active production registrations from protected original clients reserved for existing accounts. - Introduce a connection method selection step in Settings, offering "Connect with BusyMax", Workspace, and custom registration paths on Linux and Windows. - Add `check_desktop_oauth_config.dart` to validate production prerequisites and configuration syntax before packaging. - Update Linux and Windows CI workflows, build scripts, and packaging tools to handle active registrations and verify unconfigured builds. - Update Google and Microsoft connection documentation and release checklists to detail explicit setups and organizational paths. - Add `wrapSelectedValue` to `BusyMaxComboRow` to support adaptive layout of long text values in compact dialogs. - Expand localization and integration test coverage for new connection hierarchies, keyboard navigation, and explicit registration handling. * * Consolidate live-provider testing documentation into `development.md` and remove obsolete validation reports and reference docs. * Remove the `busymax_android_platform` example application package and `create_source_handoff.py` script. * Remove obsolete documentation screenshots and update `README.md` and AppStream metainfo. * Update `.gitignore` to exclude `/docs/validation/` and `.kotlin/`. * Streamline and update documentation regarding OAuth setup, Android configuration, synchronization budget, and visual comparison tooling. * Clean up documentation, third-party vendored files, and native registration test scripts * Rename `oauth_next_release_checklist.md` to `desktop_oauth_release_checklist.md` and update references across documentation files. * Rename `test_oauth_corrective_native.py` to `test_registration_native.py` and update test fixture documentation. * Remove obsolete integration test `oauth_corrective_native_test.dart`. * Remove unused `untranslated.json` from `third_party/fluent_ui`. * Remove redundant documentation, configuration, and example files from vendored `xdg_status_notifier_item` package and update `third_party/README.md`. * Completed repairs * Changed Github workflow to add the explicit Python CI step * * Fix calendar range retrieval to skip disabled accounts and properly coordinate synchronization locks with baseline sync. * Refine cloud calendar series export and import logic to preserve time types, handle all-day exceptions, and correctly convert UTC original start dates to civil dates. * Isolate task attachment upload busy states per task and improve safe reload handling across task switches. * Ensure exception events correctly clear reminders during iCalendar round-trip imports when alarm components are absent. * Refactor Nextcloud account setup dialog, reorder account list in settings, and refine Google permission messaging * Update Nextcloud account setup dialog with a standardized title, header layout, and close action. * Reorder account addition options in settings to place Nextcloud at the top. * Restrict Google permissions consent notice to Google registration setup and clean up settings subtitle handling. * Fix typo in Google permissions localization string. * Fix Android and Windows release validation * Handle SDK 37 signing output and native test build time * Complete release CI configuration and portable shell checks * Include registration setup resources in Windows packages * Use the main Ubuntu mirror for Linux CI dependencies * Fix/release CI and OAuth (#25) * Fix/CI and managed Microsoft OAuth (#23) * Separate CI from production releases and fix managed Microsoft OAuth * Use notification timezone in daily summary test fixtures (cherry picked from commit 385c56d66ebd77dd695c50bd569cbcd38f35b145) * Release/v0.2.4 (#24) (cherry picked from commit 0f963d660041e379ac33217f50058074600c6f01) * Enforce matching release pull request source branches * Complete native contacts setup and fence authorization, sources and attendees * Retain the notification drawable required for Android startup * Exclude IDE state and escaping links from source archives * Serialize parent removal ownership while draining contacts enrollment safely * fix(contacts): recover bindings after removal preparation failure --- .github/workflows/flutter-android.yml | 9 + .github/workflows/flutter-linux.yml | 63 +- .github/workflows/flutter-windows.yml | 12 +- .gitignore | 1 + .../res/raw/busymax_notification_keep.xml | 4 + config/windows_store.example.json | 3 +- docs/beta_snap_release.md | 31 +- docs/desktop_oauth_release_checklist.md | 6 +- docs/development.md | 31 +- docs/windows_release_checklist.md | 10 +- lib/l10n/app_ar.arb | 17 + lib/l10n/app_de.arb | 17 + lib/l10n/app_en.arb | 19 +- lib/l10n/app_es.arb | 17 + lib/l10n/app_et.arb | 17 + lib/l10n/app_fa.arb | 17 + lib/l10n/app_fi.arb | 17 + lib/l10n/app_fr.arb | 17 + lib/l10n/app_hi.arb | 17 + lib/l10n/app_it.arb | 17 + lib/l10n/app_ja.arb | 17 + lib/l10n/app_ko.arb | 17 + lib/l10n/app_pl.arb | 17 + lib/l10n/app_pt_PT.arb | 17 + lib/l10n/app_ru.arb | 17 + lib/l10n/app_vi.arb | 17 + lib/l10n/app_zh.arb | 17 + lib/l10n/app_zh_Hans.arb | 17 + lib/l10n/app_zh_Hant.arb | 17 + lib/l10n/generated/app_localizations.dart | 102 + lib/l10n/generated/app_localizations_ar.dart | 56 + lib/l10n/generated/app_localizations_de.dart | 57 + lib/l10n/generated/app_localizations_en.dart | 56 + lib/l10n/generated/app_localizations_es.dart | 57 + lib/l10n/generated/app_localizations_et.dart | 56 + lib/l10n/generated/app_localizations_fa.dart | 56 + lib/l10n/generated/app_localizations_fi.dart | 57 + lib/l10n/generated/app_localizations_fr.dart | 59 + lib/l10n/generated/app_localizations_hi.dart | 57 + lib/l10n/generated/app_localizations_it.dart | 58 + lib/l10n/generated/app_localizations_ja.dart | 53 + lib/l10n/generated/app_localizations_ko.dart | 53 + lib/l10n/generated/app_localizations_pl.dart | 56 + lib/l10n/generated/app_localizations_pt.dart | 112 ++ lib/l10n/generated/app_localizations_ru.dart | 58 + lib/l10n/generated/app_localizations_vi.dart | 56 + lib/l10n/generated/app_localizations_zh.dart | 154 ++ lib/src/android/android_authorization.dart | 277 ++- .../android_contacts_settings.dart | 376 ++++ .../android_event_attachments_dialog.dart | 2 + .../presentation/android_schedule_screen.dart | 221 ++- .../presentation/android_settings_screen.dart | 25 +- lib/src/app/app_bootstrap.dart | 219 ++- lib/src/config/build_config.dart | 6 - .../config/desktop_oauth_configuration.dart | 18 +- .../contacts/busymax_contacts_controller.dart | 1253 ++++++++++++ lib/src/contacts/busymax_contacts_store.dart | 630 ++++++ lib/src/core/auth/account_token_broker.dart | 100 +- .../core/auth/authorization_persistence.dart | 60 +- lib/src/core/auth/registration_staging.dart | 16 +- lib/src/core/http/shared_graph_transport.dart | 167 ++ lib/src/dav/dav_errors.dart | 307 +-- lib/src/dav/dav_href.dart | 40 +- lib/src/dav/dav_provider_profile.dart | 20 + .../dav/discovery/dav_discovery_service.dart | 2 + lib/src/dav/http/dav_http_transport.dart | 733 +------ ..._calendar_collection_mutation_service.dart | 1 + .../dav_conditional_mutation_service.dart | 7 +- .../dav_task_list_mutation_service.dart | 5 +- .../nextcloud_collection_service.dart | 6 +- .../nextcloud/nextcloud_sharing_service.dart | 2 + .../nextcloud/nextcloud_trash_service.dart | 6 +- lib/src/dav/xml/dav_xml.dart | 445 +---- lib/src/db/app_database.g.dart | 1732 ++++++++--------- lib/src/db/migrations.dart | 97 +- .../accounts/data/accounts_repository.dart | 9 +- .../features/auth/data/auth_repository.dart | 128 +- .../calendar/presentation/event_editor.dart | 150 +- .../linux_event_attachments_dialog.dart | 2 + .../presentation/settings_screen.dart | 569 +++++- lib/src/google_tasks/oauth/oauth_service.dart | 132 +- .../microsoft_calendar_api_client.dart | 158 +- .../api/microsoft_todo_api_client.dart | 111 +- .../oauth/microsoft_oauth_service.dart | 160 +- .../ui/windows/windows_contacts_settings.dart | 362 ++++ .../windows_event_attachments_dialog.dart | 4 +- .../windows/windows_event_editor_dialog.dart | 172 +- lib/src/ui/windows/windows_settings_page.dart | 16 +- .../BusymaxAndroidPlatformPlugin.kt | 9 +- .../lib/busymax_android_platform.dart | 2 + pubspec.lock | 47 +- pubspec.yaml | 14 +- test/config/build_config_test.dart | 82 +- .../busymax_contacts_controller_test.dart | 132 ++ .../contacts/busymax_contacts_store_test.dart | 246 +++ test/contacts/contacts_enrollment_test.dart | 302 +++ .../parent_removal_lifecycle_test.dart | 1058 ++++++++++ .../auth/authorization_transition_test.dart | 561 +++--- .../registration_setup_controller_test.dart | 90 +- .../dav/fake_dav_server_integration_test.dart | 260 ++- test/dav/http/dav_http_transport_test.dart | 127 +- ...ndar_collection_mutation_service_test.dart | 16 +- test/db/app_database_test.dart | 225 ++- .../presentation/settings_screen_test.dart | 144 +- .../presentation/task_details_pane_test.dart | 243 ++- .../oauth/token_exchange_test.dart | 161 +- .../microsoft_calendar_api_client_test.dart | 179 +- .../api/microsoft_todo_api_client_test.dart | 731 +++---- .../oauth/microsoft_oauth_service_test.dart | 250 ++- .../android/android_adaptive_app_test.dart | 93 +- ...roid_notification_reconciliation_test.dart | 12 +- .../attendee_contacts_context_test.dart | 269 +++ .../contacts_settings_routes_test.dart | 477 +++++ .../native_event_attachments_test.dart | 314 +-- ...ws_cloud_calendar_sharing_dialog_test.dart | 84 +- .../windows_packaging_contract_test.dart | 28 +- test/support/contacts_fixture.dart | 198 ++ test/support/dav_contacts_fixture.dart | 124 ++ test/support/desktop_registration_config.dart | 44 +- test/tool/flutter_linux_workflow_test.dart | 206 +- test/tool/release_pull_request_test.dart | 70 + .../windows_event_preservation_test.dart | 49 +- .../windows_task_editor_safety_test.dart | 53 +- tool/android/inspect_artifacts.sh | 11 + tool/check_desktop_oauth_config.dart | 6 +- tool/check_release_pull_request.py | 25 + tool/checkout_shared_dependencies.py | 44 + tool/package_source.py | 87 + tool/shared_dependencies.json | 24 + tool/windows/build_release.ps1 | 3 +- tool/windows/common.ps1 | 7 +- tool/windows/tests/store_config.Tests.ps1 | 25 +- 132 files changed, 13283 insertions(+), 4303 deletions(-) create mode 100644 android/app/src/main/res/raw/busymax_notification_keep.xml create mode 100644 lib/src/android/presentation/android_contacts_settings.dart create mode 100644 lib/src/contacts/busymax_contacts_controller.dart create mode 100644 lib/src/contacts/busymax_contacts_store.dart create mode 100644 lib/src/core/http/shared_graph_transport.dart create mode 100644 lib/src/ui/windows/windows_contacts_settings.dart create mode 100644 test/contacts/busymax_contacts_controller_test.dart create mode 100644 test/contacts/busymax_contacts_store_test.dart create mode 100644 test/contacts/contacts_enrollment_test.dart create mode 100644 test/contacts/parent_removal_lifecycle_test.dart create mode 100644 test/platform/attendee_contacts_context_test.dart create mode 100644 test/platform/contacts_settings_routes_test.dart create mode 100644 test/support/contacts_fixture.dart create mode 100644 test/support/dav_contacts_fixture.dart create mode 100644 test/tool/release_pull_request_test.dart create mode 100644 tool/check_release_pull_request.py create mode 100644 tool/checkout_shared_dependencies.py create mode 100644 tool/package_source.py create mode 100644 tool/shared_dependencies.json diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml index 3ea8e4dc..20c04103 100644 --- a/.github/workflows/flutter-android.yml +++ b/.github/workflows/flutter-android.yml @@ -19,6 +19,15 @@ jobs: - name: Check out repository uses: actions/checkout@v7 + - name: Validate release PR source branch + if: github.event_name == 'pull_request' + env: + 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 7b99003d..673b1326 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -1,6 +1,13 @@ name: Flutter Linux on: + workflow_dispatch: + inputs: + production_release: + description: 'Build an official production Snap (main only)' + type: boolean + required: true + default: false pull_request: branches: [main] push: @@ -15,9 +22,24 @@ jobs: runs-on: ubuntu-24.04 steps: + - name: Require main for manual runs + if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' + run: | + echo '::error::Manual Linux builds must run from main.' + exit 1 + - name: Check out repository uses: actions/checkout@v7 + - name: Validate release PR source branch + if: github.event_name == 'pull_request' + env: + 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' | \ @@ -180,7 +202,7 @@ jobs: test -f build/linux/x64/release/bundle/data/flutter_assets/docs/microsoft_setup.md - name: Validate production registrations and protected originals - if: github.event_name == 'push' + if: github.event_name == 'workflow_dispatch' && inputs.production_release && github.ref == 'refs/heads/main' env: GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} @@ -191,12 +213,11 @@ jobs: BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET }} BUSYMAX_GOOGLE_OAUTH_PROJECT_ID: ${{ vars.BUSYMAX_GOOGLE_OAUTH_PROJECT_ID }} BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID: ${{ vars.BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID }} - BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT: ${{ vars.BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT }} BUSYMAX_PRIVACY_POLICY_URL: https://busystack.org/privacy-busymax run: '"$BUSYMAX_DART_EXECUTABLE" run tool/check_desktop_oauth_config.dart' - name: Build official Linux release with explicit active registrations - if: github.event_name == 'push' + if: github.event_name == 'workflow_dispatch' && inputs.production_release && github.ref == 'refs/heads/main' env: GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} @@ -207,7 +228,6 @@ jobs: BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET }} BUSYMAX_GOOGLE_OAUTH_PROJECT_ID: ${{ vars.BUSYMAX_GOOGLE_OAUTH_PROJECT_ID }} BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID: ${{ vars.BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID }} - BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT: ${{ vars.BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT }} BUSYMAX_PRIVACY_POLICY_URL: https://busystack.org/privacy-busymax run: | registration_file="$(mktemp "$RUNNER_TEMP/busymax-oauth.XXXXXX.json")" @@ -220,7 +240,7 @@ jobs: 'MICROSOFT_OAUTH_AUTHORITY_TENANT', 'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID', 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET', 'BUSYMAX_GOOGLE_OAUTH_PROJECT_ID', 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID', - 'BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT', 'BUSYMAX_PRIVACY_POLICY_URL', + 'BUSYMAX_PRIVACY_POLICY_URL', ] with open(sys.argv[1], 'w') as output: json.dump({key: os.environ[key] for key in keys if os.environ.get(key)}, output) @@ -241,12 +261,15 @@ jobs: id: snapcraft uses: snapcore/action-build@v1 - - name: Upload Snap artifact + - 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-snap - path: ${{ steps.snapcraft.outputs.snap }} + name: busymax-source + path: dist/source/ if-no-files-found: error retention-days: 7 @@ -261,3 +284,27 @@ jobs: grep -Eq '^[[:space:]]*confinement:[[:space:]]+strict$' # shellcheck disable=SC2016 snap run --shell busymax -c 'test -x "$SNAP/busymax"' + + - name: Label non-production Snap artifact + if: github.event_name != 'workflow_dispatch' || !inputs.production_release + env: + SNAP_ARTIFACT: ${{ steps.snapcraft.outputs.snap }} + run: cp -- "$SNAP_ARTIFACT" build/busymax-snap-ci-non-production.snap + + - name: Upload non-production Snap artifact + if: github.event_name != 'workflow_dispatch' || !inputs.production_release + uses: actions/upload-artifact@v7 + with: + name: busymax-snap-ci-non-production + path: build/busymax-snap-ci-non-production.snap + if-no-files-found: error + retention-days: 7 + + - name: Upload Snap artifact + if: github.event_name == 'workflow_dispatch' && inputs.production_release && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v7 + with: + name: busymax-snap + path: ${{ steps.snapcraft.outputs.snap }} + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 5c4a0b55..9f3905cd 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -19,6 +19,17 @@ jobs: - name: Check out repository uses: actions/checkout@v7 + - name: Validate release PR source branch + if: github.event_name == 'pull_request' + shell: pwsh + env: + 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: @@ -59,7 +70,6 @@ jobs: busyMaxGoogleOAuthClientSecret = 'synthetic-ci-public-secret' busyMaxGoogleOAuthProjectId = 'busymax-ci-managed' busyMaxMicrosoftOAuthClientId = '44444444-4444-4444-4444-444444444444' - busyMaxMicrosoftOAuthAuthorityTenant = 'common' fakeData = $false developmentBackend = $false } | ConvertTo-Json | Set-Content ` 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/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/config/windows_store.example.json b/config/windows_store.example.json index bff5aa31..ba60b36e 100644 --- a/config/windows_store.example.json +++ b/config/windows_store.example.json @@ -18,6 +18,5 @@ "busyMaxGoogleOAuthClientId": "REPLACE_WITH_PRODUCTION_GOOGLE_DESKTOP_CLIENT_ID", "busyMaxGoogleOAuthClientSecret": "REPLACE_WITH_PRODUCTION_GOOGLE_DESKTOP_CLIENT_SECRET", "busyMaxGoogleOAuthProjectId": "REPLACE_WITH_PRODUCTION_GOOGLE_PROJECT_ID", - "busyMaxMicrosoftOAuthClientId": "REPLACE_WITH_PRODUCTION_MICROSOFT_PUBLIC_CLIENT_ID", - "busyMaxMicrosoftOAuthAuthorityTenant": "REPLACE_WITH_EXPLICIT_PRODUCTION_AUDIENCE_OR_TENANT" + "busyMaxMicrosoftOAuthClientId": "REPLACE_WITH_PRODUCTION_MICROSOFT_PUBLIC_CLIENT_ID" } diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 7c67b00e..269b35b1 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -34,7 +34,6 @@ For a configured release, create the ignored file "BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET": "PRODUCTION_GOOGLE_DESKTOP_CLIENT_SECRET", "BUSYMAX_GOOGLE_OAUTH_PROJECT_ID": "ACTUAL_PRODUCTION_GOOGLE_PROJECT_ID", "BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID": "PRODUCTION_MICROSOFT_PUBLIC_CLIENT_ID", - "BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT": "EXPLICIT_PRODUCTION_AUDIENCE_OR_TENANT", "BUSYMAX_PRIVACY_POLICY_URL": "https://busystack.org/privacy-busymax" } ``` @@ -73,11 +72,31 @@ reports that a provider is not configured, the compiled bundle is missing its Dart defines and must be rebuilt. The Flutter Linux workflow performs source validation, builds -`lib/main_linux.dart`, packages a strict Snap, and installs it for package -validation. Pull requests use an unconfigured package and do not upload it. -Pushes to `main` require the Google and Microsoft release settings, verify -that they reached the binary, and retain the `busymax-snap` artifact for seven -days. +`lib/main_linux.dart`, packages a strict Snap, and installs and verifies it. +Pull requests and pushes to `main` need no production OAuth credentials. +They upload the verified `busymax-snap-ci-non-production` +artifact, containing `busymax-snap-ci-non-production.snap`, for +seven days. This unconfigured package keeps custom registration available and +managed connections unavailable. It is not an official release and must not +be published. + +For an official production artifact, configure the actual managed Google and +Microsoft registrations plus the protected originals in GitHub Actions, then +explicitly request the production path from `main`: + +```bash +gh workflow run flutter-linux.yml --ref main -f production_release=true +``` + +Manual Linux runs from other branches fail. The production path runs the CI +checks, strictly validates the release configuration, verifies that the +configured registrations reached the binary, and builds, installs, and +verifies the strict Snap before uploading the established `busymax-snap` artifact. +Missing, invalid, or synthetic production registrations fail the run. The +artifact is retained for seven days; the workflow does not publish to the +Snap Store. A manual run without `production_release` selected remains +non-production CI. Managed Microsoft connections always use `common`, and +the app registration must support both personal and organizational accounts. ### Local scaffold helper diff --git a/docs/desktop_oauth_release_checklist.md b/docs/desktop_oauth_release_checklist.md index edbf52bc..700ebc63 100644 --- a/docs/desktop_oauth_release_checklist.md +++ b/docs/desktop_oauth_release_checklist.md @@ -10,7 +10,7 @@ Official desktop releases support explicitly active BusyMax-managed registration ## Microsoft external prerequisites -- The owner must supply a real public app ID and explicit supported-account authority (`common`, `organizations`, `consumers`, or a tenant UUID) through `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID` and `BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT`. +- The owner must supply a real public app ID through `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID`. The managed registration must support personal and organizational Microsoft accounts; its authority is always `common` and is not configurable. Custom registrations retain their own audience and tenant selections. - Configure Mobile and desktop applications with `http://localhost`, existing mandatory delegated permissions, and applicable administrator consent. Optional shared-calendar/category consent stays optional. No client secret is used. ## Preserve existing registrations and data @@ -24,7 +24,9 @@ Official desktop releases support explicitly active BusyMax-managed registration ## Build and acceptance gates - `dart run tool/check_desktop_oauth_config.dart` validates environment values; `--config ` validates a define file. Missing production registrations fail official release validation and are reported as separate external prerequisites. Syntactic validity does not mean provider approval. -- Linux push packaging validates and injects active and original values separately. Unconfigured validation builds display the recommended action as unavailable and retain custom setup. Windows official configuration validates the same prerequisites; synthetic CI values are restricted to explicitly nonproduction CI packages. `-Ci -Unconfigured -Stage WindowsCompile` exercises the unavailable state only. +- Routine Linux pull-request and main-push CI requires no production registrations. It builds, installs, and verifies an unconfigured strict Snap and labels its artifact as non-production; it must not be published. Unconfigured validation builds display the recommended action as unavailable and retain custom setup. +- Official Linux packaging requires a manual `Flutter Linux` dispatch on `main` with `production_release` selected. It validates and injects active and original values separately, rejects missing/invalid or synthetic registrations, and uploads the production Snap only after installation and verification. CI success does not prove production readiness or provider approval. +- Windows official configuration validates the same prerequisites; synthetic CI values are restricted to explicitly nonproduction CI packages. `-Ci -Unconfigured -Stage WindowsCompile` exercises the unavailable state only. - Run locked dependency resolution, localization generation, formatting, analysis, full tests, platform boundaries, Linux release build and diff checks. Run Windows release/native/MSIX validation on Windows and affected Android regressions. - Inspect actual production Settings dialogs, native menus, all instruction pages and errors in light/dark, narrow, enlarged-text, long-label and keyboard cases. Use isolated synthetic configurations; owner-authorized accounts are required for real provider authorization. - Review cancellation, invalid/cancelled replacement, expiry, disposal/stale work, duplicate submits, wrong account, missing scopes, secure-storage failure, restart/reconnect and optional-consent evidence before publication. diff --git a/docs/development.md b/docs/development.md index 2ffacfb1..738ecab2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,8 +29,9 @@ registrations in [`BuildConfig`](../lib/src/config/build_config.dart): - Google: `BUSYMAX_GOOGLE_OAUTH_CLIENT_ID`, `BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET`, and `BUSYMAX_GOOGLE_OAUTH_PROJECT_ID`. -- Microsoft: `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID` and an explicit - `BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT`. +- Microsoft: `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID`. Managed connections always use + `common` for personal and organizational Microsoft accounts. Custom + registrations retain their own audience and tenant selections. Protected `GOOGLE_OAUTH_*` and `MICROSOFT_OAUTH_*` originals serve eligible existing account bindings. Keep them separate from active registrations; they @@ -128,8 +129,7 @@ flutter run -d windows -t lib/main_windows.dart ` --dart-define=BUSYMAX_GOOGLE_OAUTH_CLIENT_ID= ` --dart-define=BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET= ` --dart-define=BUSYMAX_GOOGLE_OAUTH_PROJECT_ID= ` - --dart-define=BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID= ` - --dart-define=BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT= + --dart-define=BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID= ``` Omit the defines for providers you are not testing. An unpackaged development @@ -157,12 +157,27 @@ Normal `flutter test` skips credential-gated provider tests. See [Live-provider tests](#live-provider-tests) for their setup and safety requirements. The Linux and Windows workflows run for pull requests targeting `main` and -pushes to `main`; Windows also supports a manual dispatch. A newer run for the -same workflow and ref cancels the superseded run. Windows test reports are -retained for every run, while the unsigned CI MSIX and package evidence are -retained for seven days only after a successful `main` push or manual run. +pushes to `main`; both also support manual dispatch. Routine Linux CI requires +no production OAuth configuration: it builds, installs, and verifies an +unconfigured strict Snap with custom registration available and managed +connections unavailable. Its verified artifact is explicitly labeled +`busymax-snap-ci-non-production` and must not be published. +Manual Linux runs are restricted to `main`; selecting `production_release` +validates production registrations and builds a verified production Snap, +uploaded under the established `busymax-snap` artifact name. +Without that selection, a manual run remains non-production CI. +A newer run for the same workflow and ref cancels the superseded run. Windows +test reports are retained for every run, while the unsigned CI MSIX and package +evidence are retained for seven days only after a successful `main` push or manual run. The workflows do not deploy Windows packages. +Release PRs originate from the existing release branch for that version. +For v0.2.4, squash feature and fix PRs into `Release/v0.2.4`, then open +`Release/v0.2.4` into `main` with the title `Release/v0.2.4` and squash it. +Verify both the PR title and its source branch when checking release CI. +The workflows reject release titles that do not match the source release +branch; comparison permits intentionally lowercase release branch names. + For release artifacts, follow [Snap beta release](beta_snap_release.md) or [Windows packaging](windows_packaging.md) and the [Windows release checklist](windows_release_checklist.md). diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md index 7901042f..d60b613d 100644 --- a/docs/windows_release_checklist.md +++ b/docs/windows_release_checklist.md @@ -159,10 +159,12 @@ mark unexecuted cases as passed. Official packages require separate `busyMaxGoogleOAuthClientId`, `busyMaxGoogleOAuthClientSecret`, `busyMaxGoogleOAuthProjectId`, -`busyMaxMicrosoftOAuthClientId`, and `busyMaxMicrosoftOAuthAuthorityTenant` -values in the owner-controlled Store configuration. Preserve the protected -original `googleOAuth*`/`microsoftOAuth*` values for existing bindings; never -replace them with a newly designated client. Use +and `busyMaxMicrosoftOAuthClientId` values in the owner-controlled Store +configuration. Managed Microsoft connections always use `common`; the approved +registration must support personal and organizational Microsoft accounts. +Custom registrations retain their own audience and tenant selections. +Preserve the protected original `googleOAuth*`/`microsoftOAuth*` values for +existing bindings; never replace them with a newly designated client. Use `https://busystack.org/privacy-busymax` as the product privacy URL. The validator reports missing Google and Microsoft configuration separately diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 69bc2c49..8613090d 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "جهات الاتصال", + "contactsLinkedDescription": "استخدم جهات الاتصال المخزنة مؤقتًا من هذا الحساب عند إضافة مدعوين إلى الأحداث.", + "contactsEnableSuggestions": "تفعيل اقتراحات جهات الاتصال", + "contactsReadPermissionDescription": "يطلب إذن قراءة جهات الاتصال فقط", + "contactsEnableEditing": "تفعيل تعديل جهات الاتصال", + "contactsWritePermissionDescription": "يطلب إذن قراءة جهات الاتصال وكتابتها", + "contactsSuggestionsEnabled": "تم تفعيل اقتراحات جهات الاتصال", + "contactsNeedsAttention": "تحتاج جهات الاتصال إلى الانتباه", + "contactsReadWriteAccess": "إذن القراءة والكتابة", + "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..8acda8ea 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Kontakte", + "contactsLinkedDescription": "Beim Hinzufügen von Veranstaltungsgästen zwischengespeicherte Kontakte dieses Kontos verwenden.", + "contactsEnableSuggestions": "Kontaktvorschläge aktivieren", + "contactsReadPermissionDescription": "Fordert schreibgeschützten Zugriff auf Kontakte an", + "contactsEnableEditing": "Kontaktbearbeitung aktivieren", + "contactsWritePermissionDescription": "Fordert Lese- und Schreibzugriff auf Kontakte an", + "contactsSuggestionsEnabled": "Kontaktvorschläge aktiviert", + "contactsNeedsAttention": "Kontakte erfordern Aufmerksamkeit", + "contactsReadWriteAccess": "Lese- und Schreibzugriff", + "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..06e3766f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1962,5 +1962,22 @@ "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", + "contactsReadPermissionDescription": "Requests read-only contact access", + "contactsEnableEditing": "Enable contact editing", + "contactsWritePermissionDescription": "Requests contact read and write access", + "contactsSuggestionsEnabled": "Contact suggestions enabled", + "contactsNeedsAttention": "Contacts need attention", + "contactsReadWriteAccess": "Read and write access", + "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..ed584526 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Contactos", + "contactsLinkedDescription": "Usa los contactos almacenados de esta cuenta al añadir invitados a eventos.", + "contactsEnableSuggestions": "Activar sugerencias de contactos", + "contactsReadPermissionDescription": "Solicita acceso de solo lectura a los contactos", + "contactsEnableEditing": "Activar edición de contactos", + "contactsWritePermissionDescription": "Solicita acceso de lectura y escritura a los contactos", + "contactsSuggestionsEnabled": "Sugerencias de contactos activadas", + "contactsNeedsAttention": "Los contactos requieren atención", + "contactsReadWriteAccess": "Acceso de lectura y escritura", + "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..01c6ed13 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Kontaktid", + "contactsLinkedDescription": "Kasuta sündmuse külaliste lisamisel selle konto vahemällu salvestatud kontakte.", + "contactsEnableSuggestions": "Luba kontaktisoovitused", + "contactsReadPermissionDescription": "Taotleb kontaktidele kirjutuskaitstud juurdepääsu", + "contactsEnableEditing": "Luba kontaktide muutmine", + "contactsWritePermissionDescription": "Taotleb kontaktidele lugemis- ja kirjutusõigust", + "contactsSuggestionsEnabled": "Kontaktisoovitused on lubatud", + "contactsNeedsAttention": "Kontaktid vajavad tähelepanu", + "contactsReadWriteAccess": "Lugemis- ja kirjutusõigus", + "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..3d3e2464 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "مخاطبان", + "contactsLinkedDescription": "هنگام افزودن مهمانان رویداد از مخاطبان ذخیره‌شدهٔ این حساب استفاده شود.", + "contactsEnableSuggestions": "فعال‌کردن پیشنهادهای مخاطبان", + "contactsReadPermissionDescription": "درخواست دسترسی فقط‌خواندنی به مخاطبان", + "contactsEnableEditing": "فعال‌کردن ویرایش مخاطبان", + "contactsWritePermissionDescription": "درخواست دسترسی خواندن و نوشتن مخاطبان", + "contactsSuggestionsEnabled": "پیشنهادهای مخاطبان فعال است", + "contactsNeedsAttention": "مخاطبان نیاز به توجه دارند", + "contactsReadWriteAccess": "دسترسی خواندن و نوشتن", + "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..d1027734 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Yhteystiedot", + "contactsLinkedDescription": "Käytä tämän tilin välimuistissa olevia yhteystietoja tapahtuman vieraita lisättäessä.", + "contactsEnableSuggestions": "Ota yhteystietoehdotukset käyttöön", + "contactsReadPermissionDescription": "Pyytää yhteystietoihin vain luku -oikeuden", + "contactsEnableEditing": "Ota yhteystietojen muokkaus käyttöön", + "contactsWritePermissionDescription": "Pyytää yhteystietoihin luku- ja kirjoitusoikeuden", + "contactsSuggestionsEnabled": "Yhteystietoehdotukset käytössä", + "contactsNeedsAttention": "Yhteystiedot vaativat huomiota", + "contactsReadWriteAccess": "Luku- ja kirjoitusoikeus", + "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..b3df7305 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,21 @@ { + "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", + "contactsReadPermissionDescription": "Demande un accès en lecture seule aux contacts", + "contactsEnableEditing": "Activer la modification des contacts", + "contactsWritePermissionDescription": "Demande un accès en lecture et en écriture aux contacts", + "contactsSuggestionsEnabled": "Suggestions de contacts activées", + "contactsNeedsAttention": "Les contacts nécessitent votre attention", + "contactsReadWriteAccess": "Accès en lecture et en écriture", + "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..41320cf0 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "संपर्क", + "contactsLinkedDescription": "इवेंट में अतिथि जोड़ते समय इस खाते के कैश किए गए संपर्कों का उपयोग करें।", + "contactsEnableSuggestions": "संपर्क सुझाव चालू करें", + "contactsReadPermissionDescription": "संपर्कों के लिए केवल पढ़ने की अनुमति माँगता है", + "contactsEnableEditing": "संपर्क संपादन चालू करें", + "contactsWritePermissionDescription": "संपर्कों को पढ़ने और लिखने की अनुमति माँगता है", + "contactsSuggestionsEnabled": "संपर्क सुझाव चालू हैं", + "contactsNeedsAttention": "संपर्कों पर ध्यान देना आवश्यक है", + "contactsReadWriteAccess": "पढ़ने और लिखने की पहुँच", + "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..e831ef03 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Contatti", + "contactsLinkedDescription": "Usa i contatti memorizzati di questo account quando aggiungi invitati agli eventi.", + "contactsEnableSuggestions": "Attiva i suggerimenti dei contatti", + "contactsReadPermissionDescription": "Richiede l’accesso ai contatti in sola lettura", + "contactsEnableEditing": "Attiva la modifica dei contatti", + "contactsWritePermissionDescription": "Richiede l’accesso ai contatti in lettura e scrittura", + "contactsSuggestionsEnabled": "Suggerimenti dei contatti attivati", + "contactsNeedsAttention": "I contatti richiedono attenzione", + "contactsReadWriteAccess": "Accesso in lettura e scrittura", + "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..c853e74f 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "連絡先", + "contactsLinkedDescription": "予定のゲストを追加するときに、このアカウントのキャッシュ済み連絡先を使用します。", + "contactsEnableSuggestions": "連絡先の候補を有効にする", + "contactsReadPermissionDescription": "連絡先への読み取り専用アクセスを要求します", + "contactsEnableEditing": "連絡先の編集を有効にする", + "contactsWritePermissionDescription": "連絡先への読み取りおよび書き込みアクセスを要求します", + "contactsSuggestionsEnabled": "連絡先の候補が有効です", + "contactsNeedsAttention": "連絡先の確認が必要です", + "contactsReadWriteAccess": "読み取りおよび書き込みアクセス", + "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..168db9f3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "연락처", + "contactsLinkedDescription": "이벤트 참석자를 추가할 때 이 계정에 캐시된 연락처를 사용합니다.", + "contactsEnableSuggestions": "연락처 추천 사용", + "contactsReadPermissionDescription": "연락처 읽기 전용 권한을 요청합니다", + "contactsEnableEditing": "연락처 편집 사용", + "contactsWritePermissionDescription": "연락처 읽기 및 쓰기 권한을 요청합니다", + "contactsSuggestionsEnabled": "연락처 추천을 사용 중입니다", + "contactsNeedsAttention": "연락처를 확인해야 합니다", + "contactsReadWriteAccess": "읽기 및 쓰기 권한", + "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..6bb3616b 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,21 @@ { + "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", + "contactsReadPermissionDescription": "Prosi o dostęp do kontaktów tylko do odczytu", + "contactsEnableEditing": "Włącz edycję kontaktów", + "contactsWritePermissionDescription": "Prosi o dostęp do odczytu i zapisu kontaktów", + "contactsSuggestionsEnabled": "Sugestie kontaktów są włączone", + "contactsNeedsAttention": "Kontakty wymagają uwagi", + "contactsReadWriteAccess": "Dostęp do odczytu i zapisu", + "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..3a97b436 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Contactos", + "contactsLinkedDescription": "Utilize os contactos em cache desta conta ao adicionar convidados a eventos.", + "contactsEnableSuggestions": "Ativar sugestões de contactos", + "contactsReadPermissionDescription": "Pede acesso só de leitura aos contactos", + "contactsEnableEditing": "Ativar edição de contactos", + "contactsWritePermissionDescription": "Pede acesso de leitura e escrita aos contactos", + "contactsSuggestionsEnabled": "Sugestões de contactos ativadas", + "contactsNeedsAttention": "Os contactos precisam de atenção", + "contactsReadWriteAccess": "Acesso de leitura e escrita", + "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..4583834f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "Контакты", + "contactsLinkedDescription": "Использовать кэшированные контакты этой учётной записи при добавлении гостей события.", + "contactsEnableSuggestions": "Включить предложения контактов", + "contactsReadPermissionDescription": "Запрашивает доступ к контактам только для чтения", + "contactsEnableEditing": "Включить изменение контактов", + "contactsWritePermissionDescription": "Запрашивает доступ к контактам для чтения и записи", + "contactsSuggestionsEnabled": "Предложения контактов включены", + "contactsNeedsAttention": "Контакты требуют внимания", + "contactsReadWriteAccess": "Доступ для чтения и записи", + "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..bfeb93a9 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,21 @@ { + "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ệ", + "contactsReadPermissionDescription": "Yêu cầu quyền chỉ đọc danh bạ", + "contactsEnableEditing": "Bật chỉnh sửa liên hệ", + "contactsWritePermissionDescription": "Yêu cầu quyền đọc và ghi danh bạ", + "contactsSuggestionsEnabled": "Đã bật gợi ý liên hệ", + "contactsNeedsAttention": "Danh bạ cần được chú ý", + "contactsReadWriteAccess": "Quyền đọc và ghi", + "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..b5d3db5c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "联系人", + "contactsLinkedDescription": "添加日程参与者时使用此账号缓存的联系人。", + "contactsEnableSuggestions": "启用联系人建议", + "contactsReadPermissionDescription": "请求联系人只读权限", + "contactsEnableEditing": "启用联系人编辑", + "contactsWritePermissionDescription": "请求联系人读写权限", + "contactsSuggestionsEnabled": "联系人建议已启用", + "contactsNeedsAttention": "联系人需要处理", + "contactsReadWriteAccess": "读写权限", + "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..d0414ac4 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "联系人", + "contactsLinkedDescription": "添加日程参与者时使用此账号缓存的联系人。", + "contactsEnableSuggestions": "启用联系人建议", + "contactsReadPermissionDescription": "请求联系人只读权限", + "contactsEnableEditing": "启用联系人编辑", + "contactsWritePermissionDescription": "请求联系人读写权限", + "contactsSuggestionsEnabled": "联系人建议已启用", + "contactsNeedsAttention": "联系人需要处理", + "contactsReadWriteAccess": "读写权限", + "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..c535a207 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,21 @@ { + "contactsTitle": "聯絡人", + "contactsLinkedDescription": "新增活動來賓時使用此帳號快取的聯絡人。", + "contactsEnableSuggestions": "啟用聯絡人建議", + "contactsReadPermissionDescription": "要求聯絡人的唯讀權限", + "contactsEnableEditing": "啟用聯絡人編輯", + "contactsWritePermissionDescription": "要求聯絡人的讀寫權限", + "contactsSuggestionsEnabled": "已啟用聯絡人建議", + "contactsNeedsAttention": "聯絡人需要處理", + "contactsReadWriteAccess": "讀寫權限", + "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..f6753020 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5903,6 +5903,108 @@ 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 @contactsReadPermissionDescription. + /// + /// In en, this message translates to: + /// **'Requests read-only contact access'** + String get contactsReadPermissionDescription; + + /// No description provided for @contactsEnableEditing. + /// + /// In en, this message translates to: + /// **'Enable contact editing'** + String get contactsEnableEditing; + + /// No description provided for @contactsWritePermissionDescription. + /// + /// In en, this message translates to: + /// **'Requests contact read and write access'** + String get contactsWritePermissionDescription; + + /// 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 @contactsReadWriteAccess. + /// + /// In en, this message translates to: + /// **'Read and write access'** + String get contactsReadWriteAccess; + + /// 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..7ab4d147 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,59 @@ 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 contactsReadPermissionDescription => + 'يطلب إذن قراءة جهات الاتصال فقط'; + + @override + String get contactsEnableEditing => 'تفعيل تعديل جهات الاتصال'; + + @override + String get contactsWritePermissionDescription => + 'يطلب إذن قراءة جهات الاتصال وكتابتها'; + + @override + String get contactsSuggestionsEnabled => 'تم تفعيل اقتراحات جهات الاتصال'; + + @override + String get contactsNeedsAttention => 'تحتاج جهات الاتصال إلى الانتباه'; + + @override + String get contactsReadWriteAccess => 'إذن القراءة والكتابة'; + + @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..54a5ab85 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,60 @@ 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 contactsReadPermissionDescription => + 'Fordert schreibgeschützten Zugriff auf Kontakte an'; + + @override + String get contactsEnableEditing => 'Kontaktbearbeitung aktivieren'; + + @override + String get contactsWritePermissionDescription => + 'Fordert Lese- und Schreibzugriff auf Kontakte an'; + + @override + String get contactsSuggestionsEnabled => 'Kontaktvorschläge aktiviert'; + + @override + String get contactsNeedsAttention => 'Kontakte erfordern Aufmerksamkeit'; + + @override + String get contactsReadWriteAccess => 'Lese- und Schreibzugriff'; + + @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..00e0b46e 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,59 @@ 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 contactsReadPermissionDescription => + 'Requests read-only contact access'; + + @override + String get contactsEnableEditing => 'Enable contact editing'; + + @override + String get contactsWritePermissionDescription => + 'Requests contact read and write access'; + + @override + String get contactsSuggestionsEnabled => 'Contact suggestions enabled'; + + @override + String get contactsNeedsAttention => 'Contacts need attention'; + + @override + String get contactsReadWriteAccess => 'Read and write access'; + + @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..889fc5ad 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,60 @@ 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 contactsReadPermissionDescription => + 'Solicita acceso de solo lectura a los contactos'; + + @override + String get contactsEnableEditing => 'Activar edición de contactos'; + + @override + String get contactsWritePermissionDescription => + 'Solicita acceso de lectura y escritura a los contactos'; + + @override + String get contactsSuggestionsEnabled => 'Sugerencias de contactos activadas'; + + @override + String get contactsNeedsAttention => 'Los contactos requieren atención'; + + @override + String get contactsReadWriteAccess => 'Acceso de lectura y escritura'; + + @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..35886b63 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,59 @@ 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 contactsReadPermissionDescription => + 'Taotleb kontaktidele kirjutuskaitstud juurdepääsu'; + + @override + String get contactsEnableEditing => 'Luba kontaktide muutmine'; + + @override + String get contactsWritePermissionDescription => + 'Taotleb kontaktidele lugemis- ja kirjutusõigust'; + + @override + String get contactsSuggestionsEnabled => 'Kontaktisoovitused on lubatud'; + + @override + String get contactsNeedsAttention => 'Kontaktid vajavad tähelepanu'; + + @override + String get contactsReadWriteAccess => 'Lugemis- ja kirjutusõigus'; + + @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..51e96a52 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,59 @@ 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 contactsReadPermissionDescription => + 'درخواست دسترسی فقط‌خواندنی به مخاطبان'; + + @override + String get contactsEnableEditing => 'فعال‌کردن ویرایش مخاطبان'; + + @override + String get contactsWritePermissionDescription => + 'درخواست دسترسی خواندن و نوشتن مخاطبان'; + + @override + String get contactsSuggestionsEnabled => 'پیشنهادهای مخاطبان فعال است'; + + @override + String get contactsNeedsAttention => 'مخاطبان نیاز به توجه دارند'; + + @override + String get contactsReadWriteAccess => 'دسترسی خواندن و نوشتن'; + + @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..3491ebbf 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,60 @@ 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 contactsReadPermissionDescription => + 'Pyytää yhteystietoihin vain luku -oikeuden'; + + @override + String get contactsEnableEditing => 'Ota yhteystietojen muokkaus käyttöön'; + + @override + String get contactsWritePermissionDescription => + 'Pyytää yhteystietoihin luku- ja kirjoitusoikeuden'; + + @override + String get contactsSuggestionsEnabled => 'Yhteystietoehdotukset käytössä'; + + @override + String get contactsNeedsAttention => 'Yhteystiedot vaativat huomiota'; + + @override + String get contactsReadWriteAccess => 'Luku- ja kirjoitusoikeus'; + + @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..632400f3 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,62 @@ 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 contactsReadPermissionDescription => + 'Demande un accès en lecture seule aux contacts'; + + @override + String get contactsEnableEditing => 'Activer la modification des contacts'; + + @override + String get contactsWritePermissionDescription => + 'Demande un accès en lecture et en écriture aux contacts'; + + @override + String get contactsSuggestionsEnabled => 'Suggestions de contacts activées'; + + @override + String get contactsNeedsAttention => + 'Les contacts nécessitent votre attention'; + + @override + String get contactsReadWriteAccess => 'Accès en lecture et en écriture'; + + @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..683de83f 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,60 @@ 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 contactsReadPermissionDescription => + 'संपर्कों के लिए केवल पढ़ने की अनुमति माँगता है'; + + @override + String get contactsEnableEditing => 'संपर्क संपादन चालू करें'; + + @override + String get contactsWritePermissionDescription => + 'संपर्कों को पढ़ने और लिखने की अनुमति माँगता है'; + + @override + String get contactsSuggestionsEnabled => 'संपर्क सुझाव चालू हैं'; + + @override + String get contactsNeedsAttention => 'संपर्कों पर ध्यान देना आवश्यक है'; + + @override + String get contactsReadWriteAccess => 'पढ़ने और लिखने की पहुँच'; + + @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..1bd6a47a 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,61 @@ 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 contactsReadPermissionDescription => + 'Richiede l’accesso ai contatti in sola lettura'; + + @override + String get contactsEnableEditing => 'Attiva la modifica dei contatti'; + + @override + String get contactsWritePermissionDescription => + 'Richiede l’accesso ai contatti in lettura e scrittura'; + + @override + String get contactsSuggestionsEnabled => 'Suggerimenti dei contatti attivati'; + + @override + String get contactsNeedsAttention => 'I contatti richiedono attenzione'; + + @override + String get contactsReadWriteAccess => 'Accesso in lettura e scrittura'; + + @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..0834125f 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,56 @@ 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 contactsReadPermissionDescription => '連絡先への読み取り専用アクセスを要求します'; + + @override + String get contactsEnableEditing => '連絡先の編集を有効にする'; + + @override + String get contactsWritePermissionDescription => '連絡先への読み取りおよび書き込みアクセスを要求します'; + + @override + String get contactsSuggestionsEnabled => '連絡先の候補が有効です'; + + @override + String get contactsNeedsAttention => '連絡先の確認が必要です'; + + @override + String get contactsReadWriteAccess => '読み取りおよび書き込みアクセス'; + + @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..b8cd34c6 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,56 @@ 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 contactsReadPermissionDescription => '연락처 읽기 전용 권한을 요청합니다'; + + @override + String get contactsEnableEditing => '연락처 편집 사용'; + + @override + String get contactsWritePermissionDescription => '연락처 읽기 및 쓰기 권한을 요청합니다'; + + @override + String get contactsSuggestionsEnabled => '연락처 추천을 사용 중입니다'; + + @override + String get contactsNeedsAttention => '연락처를 확인해야 합니다'; + + @override + String get contactsReadWriteAccess => '읽기 및 쓰기 권한'; + + @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..b29dd426 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,59 @@ 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 contactsReadPermissionDescription => + 'Prosi o dostęp do kontaktów tylko do odczytu'; + + @override + String get contactsEnableEditing => 'Włącz edycję kontaktów'; + + @override + String get contactsWritePermissionDescription => + 'Prosi o dostęp do odczytu i zapisu kontaktów'; + + @override + String get contactsSuggestionsEnabled => 'Sugestie kontaktów są włączone'; + + @override + String get contactsNeedsAttention => 'Kontakty wymagają uwagi'; + + @override + String get contactsReadWriteAccess => 'Dostęp do odczytu i zapisu'; + + @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..a97a235c 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,61 @@ 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 contactsReadPermissionDescription => + 'Requests read-only contact access'; + + @override + String get contactsEnableEditing => 'Enable contact editing'; + + @override + String get contactsWritePermissionDescription => + 'Requests contact read and write access'; + + @override + String get contactsSuggestionsEnabled => 'Contact suggestions enabled'; + + @override + String get contactsNeedsAttention => 'Contacts need attention'; + + @override + String get contactsReadWriteAccess => 'Read and write access'; + + @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 +7132,60 @@ 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 contactsReadPermissionDescription => + 'Pede acesso só de leitura aos contactos'; + + @override + String get contactsEnableEditing => 'Ativar edição de contactos'; + + @override + String get contactsWritePermissionDescription => + 'Pede acesso de leitura e escrita aos contactos'; + + @override + String get contactsSuggestionsEnabled => 'Sugestões de contactos ativadas'; + + @override + String get contactsNeedsAttention => 'Os contactos precisam de atenção'; + + @override + String get contactsReadWriteAccess => 'Acesso de leitura e escrita'; + + @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..0bfd396f 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,61 @@ 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 contactsReadPermissionDescription => + 'Запрашивает доступ к контактам только для чтения'; + + @override + String get contactsEnableEditing => 'Включить изменение контактов'; + + @override + String get contactsWritePermissionDescription => + 'Запрашивает доступ к контактам для чтения и записи'; + + @override + String get contactsSuggestionsEnabled => 'Предложения контактов включены'; + + @override + String get contactsNeedsAttention => 'Контакты требуют внимания'; + + @override + String get contactsReadWriteAccess => 'Доступ для чтения и записи'; + + @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..e0be4905 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,59 @@ 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 contactsReadPermissionDescription => + 'Yêu cầu quyền chỉ đọc danh bạ'; + + @override + String get contactsEnableEditing => 'Bật chỉnh sửa liên hệ'; + + @override + String get contactsWritePermissionDescription => + 'Yêu cầu quyền đọc và ghi 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 contactsReadWriteAccess => 'Quyền đọc và ghi'; + + @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..8876b1e6 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,57 @@ 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 contactsReadPermissionDescription => '请求联系人只读权限'; + + @override + String get contactsEnableEditing => '启用联系人编辑'; + + @override + String get contactsWritePermissionDescription => '请求联系人读写权限'; + + @override + String get contactsSuggestionsEnabled => '联系人建议已启用'; + + @override + String get contactsNeedsAttention => '联系人需要处理'; + + @override + String get contactsReadWriteAccess => '读写权限'; + + @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 +6814,57 @@ 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 contactsReadPermissionDescription => '请求联系人只读权限'; + + @override + String get contactsEnableEditing => '启用联系人编辑'; + + @override + String get contactsWritePermissionDescription => '请求联系人读写权限'; + + @override + String get contactsSuggestionsEnabled => '联系人建议已启用'; + + @override + String get contactsNeedsAttention => '联系人需要处理'; + + @override + String get contactsReadWriteAccess => '读写权限'; + + @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 +10244,55 @@ 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 contactsReadPermissionDescription => '要求聯絡人的唯讀權限'; + + @override + String get contactsEnableEditing => '啟用聯絡人編輯'; + + @override + String get contactsWritePermissionDescription => '要求聯絡人的讀寫權限'; + + @override + String get contactsSuggestionsEnabled => '已啟用聯絡人建議'; + + @override + String get contactsNeedsAttention => '聯絡人需要處理'; + + @override + String get contactsReadWriteAccess => '讀寫權限'; + + @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/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart index eb42817b..0e5e95ac 100644 --- a/lib/src/android/android_authorization.dart +++ b/lib/src/android/android_authorization.dart @@ -5,7 +5,9 @@ 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'; @@ -39,6 +41,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 +60,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 +75,11 @@ final class AndroidAuthorizationBroker MicrosoftOAuthGateway, MicrosoftSharedCalendarAuthorization, MicrosoftCategoryAuthorization, - AccountTokenBroker { + MicrosoftContactsAuthorization, + GoogleContactsAuthorization, + AccountTokenBroker, + ContactsAuthorizationBroker, + MicrosoftGraphAuthorizationBroker { AndroidAuthorizationBroker({ required BusyMaxAndroidPlatform platform, required http.Client httpClient, @@ -124,7 +131,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 +144,7 @@ final class AndroidAuthorizationBroker Future _connectGoogle( AuthorizationRequest request, AuthorizationAttempt attempt, + List optional, ) async { if (request.registration != null || request.intent == AuthorizationIntent.replaceRegistration) { @@ -165,12 +173,18 @@ final class AndroidAuthorizationBroker final native = await _boundedNative( attempt, () => _platform.authorizeGoogleInteractively( - scopes: _googleScopes, + scopes: [..._googleScopes, ...optional], authorizationAttemptId: attempt.id, ), ); final tokens = _tokenSet(native); _requireSilentScopes(BusyProvider.google, tokens, candidate: true); + if (!optional.every(tokens.scopes.contains)) { + 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) { @@ -695,11 +709,80 @@ final class AndroidAuthorizationBroker await _optionalNativeConsent(id, _microsoftSharedNativeScope, cancellation); } + @override + Future authorizeGoogleContacts( + String id, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) async { + final attempt = _attempts.begin(_nowUtc, cancellation); + NativeOAuthCredential? current; + final scope = writable ? googleContactsWriteScope : googleContactsReadScope; + try { + current = await attempt.wait(nativeCredential(id, BusyProvider.google)); + 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, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => _optionalNativeConsent( + id, + writable + ? _microsoftContactsWriteNativeScope + : _microsoftContactsReadNativeScope, + cancellation, + persistContacts: persistContacts, + ); + + @override + Future authorizeContacts( + BusyProvider provider, + String accountId, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => switch (provider) { + BusyProvider.google => authorizeGoogleContacts( + accountId, + writable: writable, + cancellation: cancellation, + persistContacts: persistContacts, + ), + BusyProvider.microsoft => authorizeMicrosoftContacts( + accountId, + writable: writable, + 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 { @@ -711,7 +794,7 @@ final class AndroidAuthorizationBroker optional: [scope], capturedAttempt: attempt, ); - await result.commit!(() async {}); + await result.commit!(persistContacts ?? () async {}); } on OAuthException catch (error) { if (current == null) rethrow; throw AuthorizationScopedOAuthException( @@ -806,6 +889,180 @@ 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, { + required bool writable, + String? claims, + }) => switch (provider) { + BusyProvider.google => googleContactsAuthorizationHeader( + accountId, + writable: writable, + ), + BusyProvider.microsoft => microsoftContactsAuthorizationHeader( + accountId, + writable: writable, + claims: claims, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + + @override + Future googleContactsAuthorizationHeader( + String accountId, { + required bool writable, + }) async { + try { + final current = persistence == null + ? null + : await nativeCredential(accountId, BusyProvider.google); + final requiredScope = writable + ? googleContactsWriteScope + : 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) && + (writable || !tokenSet.scopes.contains(googleContactsWriteScope))) { + throw const OAuthException( + 'OAuthContactsConsentRequired', + 'Google Contacts permission must be granted for this account.', + ); + } + await _acceptNativeToken(accountId, current, native); + return 'Bearer ${native.accessToken}'; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.google); + } + } + + @override + Future microsoftContactsAuthorizationHeader( + String accountId, { + required bool writable, + String? claims, + }) async { + try { + final current = persistence == null + ? null + : await nativeCredential(accountId, BusyProvider.microsoft) + as MicrosoftAndroidCredential; + final requiredNativeScope = writable + ? _microsoftContactsWriteNativeScope + : _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); + final requiredScope = writable + ? microsoftContactsWriteScope + : microsoftContactsReadScope; + if (!tokenSet.scopes.contains(requiredScope) && + (writable || + !tokenSet.scopes.contains(microsoftContactsWriteScope))) { + throw const OAuthException( + 'MicrosoftOAuthContactsConsentRequired', + 'Microsoft Contacts permission must be granted for this account.', + ); + } + await _acceptNativeToken(accountId, current, native); + return 'Bearer ${native.accessToken}'; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.microsoft); + } + } + @override Future microsoftSharedCalendarAuthorizationHeader( String accountId, @@ -1055,6 +1312,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/presentation/android_contacts_settings.dart b/lib/src/android/presentation/android_contacts_settings.dart new file mode 100644 index 00000000..ca5ee52b --- /dev/null +++ b/lib/src/android/presentation/android_contacts_settings.dart @@ -0,0 +1,376 @@ +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); + } + } + + bool _writable(ContactAccount account) => account.grantedScopes.any( + (s) => [ + 'contacts.readwrite', + 'carddav:write', + 'https://www.googleapis.com/auth/contacts', + ].contains(s.toLowerCase()), + ); + 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), + readOnly: input.readOnly, + ); + } else { + await controller.addCardDavContactsOnly( + id: id, + label: input.label, + server: Uri.parse(input.server), + username: input.username, + password: input.password, + readOnly: input.readOnly, + ); + } + } + } + + 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), + ), + Text(context.l10n.contactsWritePermissionDescription), + _action( + context.l10n.contactsEnableEditing, + () => parent.provider == BusyProvider.nextcloud + ? controller.enableLinkedNextcloudContacts( + parent.id, + writable: true, + ) + : controller.enableLinkedContacts( + parent.id, + writable: true, + ), + ), + ], + Wrap( + spacing: 8, + children: [ + _action(context.l10n.contactsAddCardDav, () => _add(false)), + _action(context.l10n.contactsAddNextcloud, () => _add(true)), + ], + ), + for (final account in accounts) ...[ + Text( + '${account.displayName} · ${_writable(account) ? context.l10n.contactsReadWriteAccess : context.l10n.readOnlySharedCollection} · ${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), + ), + if (account.provider == ContactProviderKind.carddav && + !account.id.startsWith('contacts:') && + !_writable(account)) + _action( + context.l10n.contactsEnableEditing, + () => controller.reconnectDavContactsOnly( + account.id, + readOnly: false, + ), + ), + _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, + this.readOnly, + ); + final String label, server, username, password; + final bool readOnly; +} + +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(); + bool _readOnly = true; + 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, + _readOnly, + ), + ); + } + + @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, + ), + ), + SwitchListTile( + title: Text(context.l10n.readOnlySharedCollection), + value: _readOnly, + onChanged: (value) => setState(() => _readOnly = value), + ), + 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_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_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/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/config/build_config.dart b/lib/src/config/build_config.dart index b7c91142..8851b0d0 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -15,7 +15,6 @@ class BuildConfig { this.busyMaxGoogleOAuthClientSecret = '', this.busyMaxGoogleOAuthProjectId = '', this.busyMaxMicrosoftOAuthClientId = '', - this.busyMaxMicrosoftOAuthAuthorityTenant = '', this.microsoftGraphBaseUrl = 'https://graph.microsoft.com/v1.0', this.googleApiBaseUrl = 'https://www.googleapis.com', this.feedbackEndpoint = 'https://busystack.org/api/feedback', @@ -62,9 +61,6 @@ class BuildConfig { busyMaxMicrosoftOAuthClientId: const String.fromEnvironment( 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID', ), - busyMaxMicrosoftOAuthAuthorityTenant: const String.fromEnvironment( - 'BUSYMAX_MICROSOFT_OAUTH_AUTHORITY_TENANT', - ), microsoftOAuthClientId: const String.fromEnvironment( 'MICROSOFT_OAUTH_CLIENT_ID', ), @@ -156,7 +152,6 @@ class BuildConfig { final String busyMaxGoogleOAuthClientSecret; final String busyMaxGoogleOAuthProjectId; final String busyMaxMicrosoftOAuthClientId; - final String busyMaxMicrosoftOAuthAuthorityTenant; final String microsoftGraphBaseUrl; final String googleApiBaseUrl; final String feedbackEndpoint; @@ -191,7 +186,6 @@ class BuildConfig { !androidNativeAuthorization && validMicrosoftDesktopConfiguration( clientId: busyMaxMicrosoftOAuthClientId, - authorityTenant: busyMaxMicrosoftOAuthAuthorityTenant, ); bool get hasGoogleOAuthClientId => diff --git a/lib/src/config/desktop_oauth_configuration.dart b/lib/src/config/desktop_oauth_configuration.dart index e64d576e..5c3ba41e 100644 --- a/lib/src/config/desktop_oauth_configuration.dart +++ b/lib/src/config/desktop_oauth_configuration.dart @@ -5,22 +5,12 @@ 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; -bool validMicrosoftDesktopConfiguration({ - required String clientId, - required String authorityTenant, -}) => - _uuid.hasMatch(clientId.trim()) && - (const { - 'common', - 'organizations', - 'consumers', - }.contains(authorityTenant.trim()) || - _uuid.hasMatch(authorityTenant.trim())); +bool validMicrosoftDesktopConfiguration({required String clientId}) => + _uuid.hasMatch(clientId.trim()); final _uuid = RegExp(r'^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$'); diff --git a/lib/src/contacts/busymax_contacts_controller.dart b/lib/src/contacts/busymax_contacts_controller.dart new file mode 100644 index 00000000..4f73362b --- /dev/null +++ b/lib/src/contacts/busymax_contacts_controller.dart @@ -0,0 +1,1253 @@ +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'; + +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), + this.providerFactory, + this.readAuthorizationGeneration, + }) : 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 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, { + bool writable = false, + }) => _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 + ? { + writable ? googleContactsWriteScope : googleContactsReadScope, + } + : {writable ? 'Contacts.ReadWrite' : '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, + writable: writable, + 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) || + !writable && + 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, { + bool writable = false, + }) => _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: { + if (writable) 'carddav:write' else '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 readOnly = false, + bool nextcloud = false, + }) => _enroll( + id, + (attempt) => _addCardDav( + attempt, + id: id, + label: label, + server: server, + username: username, + password: password, + readOnly: readOnly, + nextcloud: nextcloud, + ), + ); + + Future _addCardDav( + _ContactsEnrollment attempt, { + required String id, + required String label, + required Uri server, + required String username, + required String password, + required bool readOnly, + 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: { + if (readOnly) 'carddav:read' else 'carddav:write', + }, + ); + final secret = jsonEncode({ + 'version': 1, + 'server': server.toString(), + 'username': credential.username, + 'password': password, + 'readOnly': readOnly, + '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, + bool readOnly = false, + }) => _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, + readOnly: readOnly, + 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); + await directory.resumeQueued(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(); + directory.bind( + 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(); + directory.bind( + GoogleContactsProvider( + accountId: account.id, + authorizedClient: _BusyMaxGoogleContactsClient( + authorization: authorization, + accountId: parentId, + writable: account.grantedScopes.contains( + googleContactsWriteScope, + ), + 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); + final writable = account.grantedScopes.any( + (scope) => microsoftGraphScopeName(scope) == 'contacts.readwrite', + ); + validateBinding?.call(); + directory.bind( + MicrosoftContactsProvider( + accountId: account.id, + graph: GraphClient( + httpClient: httpClient, + tokenProvider: ({claims}) async => GraphAuthorization( + (await authorization.contactsAuthorizationHeader( + BusyProvider.microsoft, + parentId, + writable: writable, + 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': !account.grantedScopes.contains('carddav:write'), + 'nextcloud': true, + } + : (jsonDecode(encoded) as Map).cast(); + if (config['version'] != 1) { + throw const FormatException('Unsupported CardDAV credential.'); + } + validateBinding?.call(); + directory.bind( + _davProvider(account, config), + accountGeneration: account.generation, + ); + } + _boundGenerations[account.id] = account.generation; + } + + 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: config['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); + final writable = account.grantedScopes.any( + (s) => + microsoftGraphScopeName(s) == 'contacts.readwrite' || + s == googleContactsWriteScope || + s == 'carddav:write', + ); + if (parent != null) { + if (account.provider == ContactProviderKind.carddav) { + await enableLinkedNextcloudContacts(parent, writable: writable); + } else { + await enableLinkedContacts(parent, writable: writable); + } + return; + } + await reconnectDavContactsOnly(id); + } + + Future reconnectDavContactsOnly( + String id, { + String? password, + bool? readOnly, + }) => _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 && readOnly == null) { + 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(); + } + } + if (readOnly != null) config['readOnly'] = readOnly; + final replacement = ContactAccount( + id: previous.id, + provider: previous.provider, + displayName: previous.displayName, + subject: previous.subject, + enabled: true, + generation: previous.generation + 1, + grantedScopes: { + config['readOnly'] == true ? 'carddav:read' : 'carddav:write', + }, + ); + 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.writable, + required this.inner, + }); + + final ContactsAuthorizationBroker authorization; + final String accountId; + final bool writable; + final http.Client inner; + + @override + Future send(http.BaseRequest request) async { + request.headers['authorization'] = await authorization + .contactsAuthorizationHeader( + BusyProvider.google, + accountId, + writable: writable, + ); + 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..51041d1d --- /dev/null +++ b/lib/src/contacts/busymax_contacts_store.dart @@ -0,0 +1,630 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:drift/drift.dart'; + +import '../db/app_database.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; + + @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', + ContactSource.fromJson, + accountId == null + ? const >[] + : >[Variable(accountId)], + ); + + @override + Future source(String key) => _one( + 'bm_contact_sources', + 'source_key', + key, + ContactSource.fromJson, + ); + + @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(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/core/auth/account_token_broker.dart b/lib/src/core/auth/account_token_broker.dart index bf2bb22d..5f0e160c 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,42 @@ 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, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }); + + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + required bool writable, + String? claims, + }); +} + +final class DesktopAccountTokenBroker + implements + AccountTokenBroker, + ContactsAuthorizationBroker, + MicrosoftGraphAuthorizationBroker { const DesktopAccountTokenBroker({ required this.google, required this.microsoft, @@ -22,6 +58,48 @@ final class DesktopAccountTokenBroker implements AccountTokenBroker { final OAuthService google; final MicrosoftOAuthService microsoft; + @override + Future authorizeContacts( + BusyProvider provider, + String accountId, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => switch (provider) { + BusyProvider.google => google.authorizeGoogleContacts( + accountId, + writable: writable, + cancellation: cancellation, + persistContacts: persistContacts, + ), + BusyProvider.microsoft => microsoft.authorizeMicrosoftContacts( + accountId, + writable: writable, + cancellation: cancellation, + persistContacts: persistContacts, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + + @override + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + required bool writable, + String? claims, + }) => switch (provider) { + BusyProvider.google => google.googleContactsAuthorizationHeader( + accountId, + writable: writable, + ), + BusyProvider.microsoft => microsoft.microsoftContactsAuthorizationHeader( + accountId, + writable: writable, + claims: claims, + ), + _ => throw StateError('$provider does not use OAuth contacts access.'), + }; + @override Future microsoftSharedCalendarAuthorizationHeader(String accountId) => microsoft.sharedCalendarAuthorizationHeaderForAccount(accountId); @@ -30,6 +108,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_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/registration_staging.dart b/lib/src/core/auth/registration_staging.dart index 53e4af0c..208b4e8c 100644 --- a/lib/src/core/auth/registration_staging.dart +++ b/lib/src/core/auth/registration_staging.dart @@ -91,19 +91,10 @@ final class RegistrationStaging extends ChangeNotifier { } if (provider == BusyProvider.microsoft && config.hasBusyMaxMicrosoftRegistration) { - final authority = config.busyMaxMicrosoftOAuthAuthorityTenant - .trim() - .toLowerCase(); return stage( MicrosoftPublicRegistration( clientId: config.busyMaxMicrosoftOAuthClientId, - audience: switch (authority) { - 'common' => MicrosoftAudience.personalAndOrganizations, - 'organizations' => MicrosoftAudience.organizations, - 'consumers' => MicrosoftAudience.personal, - _ => MicrosoftAudience.tenant, - }, - tenantId: isUuid(authority) ? authority : null, + audience: MicrosoftAudience.personalAndOrganizations, origin: RegistrationOrigin.busyMaxManaged, ), ); @@ -324,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/shared_graph_transport.dart b/lib/src/core/http/shared_graph_transport.dart new file mode 100644 index 00000000..30be87a9 --- /dev/null +++ b/lib/src/core/http/shared_graph_transport.dart @@ -0,0 +1,167 @@ +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, + }) : 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; + + 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': + '${error.retryAt!.difference(DateTime.now().toUtc()).inSeconds + 1}', + }, + ); + } + } + + 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/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/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_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_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_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/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/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/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/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/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/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 84e33b97..d26a4d58 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,19 @@ class _SettingsScreenState extends ConsumerState { removingAccountIds: _removingAccountIds, onRemoveAccount: (account) => unawaited(_removeAccount(context, ref, account)), + contactAccounts: contactAccounts, + contactSources: contactSources, + busyContactAccountIds: _busyContactAccountIds, + onEnableContacts: (account, writable) => + unawaited(_enableContacts(account, writable: writable)), + 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 +612,90 @@ class _SettingsScreenState extends ConsumerState { ); } + Future _enableContacts( + AccountEntity account, { + required bool writable, + }) => _runContactOperation(account.id, () async { + final controller = ref.read(busyMaxContactsControllerProvider); + if (account.provider == BusyProvider.nextcloud) { + await controller.enableLinkedNextcloudContacts( + account.id, + writable: writable, + ); + } else { + await controller.enableLinkedContacts(account.id, writable: writable); + } + }); + + 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, + readOnly: input.readOnly, + ); + }); + } + + 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 +922,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 +1262,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 +1523,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 +1566,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, bool writable) 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 +1669,51 @@ 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, false), + onEnableWrite: () => onEnableContacts(account, true), + 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 +1800,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 +1880,237 @@ 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.onEnableWrite, + required this.onDisable, + required this.onSourceEnabled, + }); + + final AccountEntity account; + final ContactAccount? contactAccount; + final List sources; + final bool busy; + final VoidCallback onEnableRead; + final VoidCallback onEnableWrite; + 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( + key: ValueKey('enable-contacts-write-${account.id}'), + title: l10n.contactsEnableEditing, + subtitle: l10n.contactsWritePermissionDescription, + leading: const Icon(Icons.edit_outlined), + onTap: busy ? null : onEnableWrite, + ), + ] + : [ + BusyMaxActionRow( + title: contact.errorCode == null + ? l10n.contactsSuggestionsEnabled + : l10n.contactsNeedsAttention, + subtitle: _contactsWritable(contact) + ? l10n.contactsReadWriteAccess + : l10n.readOnlySharedCollection, + leading: Icon( + contact.errorCode == null + ? YaruIcons.checkmark + : YaruIcons.warning, + ), + ), + if (!_contactsWritable(contact)) + BusyMaxActionRow( + key: ValueKey('upgrade-contacts-write-${account.id}'), + title: l10n.contactsEnableEditing, + leading: const Icon(Icons.edit_outlined), + onTap: busy ? null : onEnableWrite, + ), + 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: _contactsWritable(account) + ? l10n.contactsReadWriteAccess + : l10n.readOnlySharedCollection, + 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)), + ), + ); +} + +bool _contactsWritable(ContactAccount account) => + account.grantedScopes.any((scope) { + final normalized = scope.toLowerCase(); + return normalized == 'contacts.readwrite' || + normalized == 'carddav:write' || + normalized == 'https://www.googleapis.com/auth/contacts'; + }); + TextStyle? _settingsAccountSubsectionStyle(BuildContext context) { final theme = Theme.of(context); return theme.textTheme.bodySmall?.copyWith( @@ -2083,6 +2502,144 @@ 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, + required this.readOnly, + }); + + final String label; + final Uri server; + final String username; + final String password; + final bool readOnly; +} + +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(); + bool _readOnly = false; + + @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), + SwitchListTile.adaptive( + value: _readOnly, + title: Text(l10n.readOnlySharedCollection), + contentPadding: EdgeInsets.zero, + onChanged: (value) => setState(() => _readOnly = value), + ), + ], + 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, + readOnly: _readOnly, + ), + ), + child: Text(l10n.connectAccountAction), + ), + ], + ); + } +} + final class _WebCalAddInput { const _WebCalAddInput({ required this.url, diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index 5845822d..10d1f511 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,24 @@ 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, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }); + + Future googleContactsAuthorizationHeader( + String accountId, { + required bool writable, + }); +} + abstract interface class GoogleRemovalRevoker { Future revokeSelectedAuthorization( AuthorizationRemovalSnapshot snapshot, @@ -55,6 +77,7 @@ class OAuthService implements OAuthGateway, GoogleConnectionGateway, + GoogleContactsAuthorization, GoogleRemovalRevoker, RegistrationBindingResolver { OAuthService({ @@ -163,7 +186,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 +202,7 @@ class OAuthService Future _connectGoogle( AuthorizationRequest request, AuthorizationAttempt attempt, + Set optionalScopes, ) async { final persistence = _persistence; if (persistence == null) { @@ -224,6 +248,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) + scope, + }; final result = await _loopbackFlow.start( attempt: attempt, authorizationEndpoint: Uri.https( @@ -231,7 +264,7 @@ class OAuthService '/o/oauth2/v2/auth', ), clientId: registration.clientId, - scope: googleBusyMaxOAuthScope, + scope: requestedScopes.join(' '), extraAuthorizationParameters: const { 'access_type': 'offline', 'prompt': 'consent', @@ -254,6 +287,12 @@ class OAuthService 'Grant both Google Tasks and Google Calendar permissions.', ); } + if (!optionalScopes.every(tokens.scopes.contains)) { + throw const OAuthException( + 'OAuthMissingRequiredScope', + 'The requested Google Contacts permission was not granted.', + ); + } attempt.check(); final user = await fetchUserInfo( tokens, @@ -329,6 +368,95 @@ class OAuthService ); } + @override + Future authorizeGoogleContacts( + String accountId, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) async { + final attempt = _attempts.begin(_nowUtc, cancellation); + GoogleDesktopCredential? current; + final scope = writable ? googleContactsWriteScope : 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) || + !writable && 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, { + required bool writable, + }) async { + final tokenSet = await validTokenForAccount(accountId); + if (writable + ? !tokenSet.scopes.contains(googleContactsWriteScope) + : !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/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_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/oauth/microsoft_oauth_service.dart b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart index 086589ab..12d1e229 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,21 @@ abstract interface class MicrosoftSharedCalendarAuthorization { }); } +abstract interface class MicrosoftContactsAuthorization { + Future authorizeMicrosoftContacts( + String accountId, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }); + + Future microsoftContactsAuthorizationHeader( + String accountId, { + required bool writable, + 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 +95,8 @@ class MicrosoftOAuthService MicrosoftConnectionGateway, RegistrationBindingResolver, MicrosoftSharedCalendarAuthorization, - MicrosoftCategoryAuthorization { + MicrosoftCategoryAuthorization, + MicrosoftContactsAuthorization { MicrosoftOAuthService({ required BuildConfig config, required http.Client httpClient, @@ -197,6 +221,18 @@ class MicrosoftOAuthService microsoftCategoryScope, )) microsoftCategoryScope, + if (previous != null && + hasMicrosoftGraphScope( + previous.tokenSet.scopes, + microsoftContactsWriteScope, + )) + microsoftContactsWriteScope + else if (previous != null && + hasMicrosoftGraphScope( + previous.tokenSet.scopes, + microsoftContactsReadScope, + )) + microsoftContactsReadScope, ].join(' '); attempt.check(); final result = await _loopbackFlow.start( @@ -460,15 +496,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 +525,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 +551,75 @@ class MicrosoftOAuthService String accountId, { AuthorizationCancellation? cancellation, }) => _optionalConsent(accountId, microsoftSharedCalendarScope, cancellation); + @override + Future authorizeMicrosoftContacts( + String accountId, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => _optionalConsent( + accountId, + writable ? microsoftContactsWriteScope : microsoftContactsReadScope, + cancellation, + persistContacts: persistContacts, + ); + + @override + Future microsoftContactsAuthorizationHeader( + String accountId, { + required bool writable, + 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 (writable ? !hasWrite : !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 (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 +628,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 +731,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 +743,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 +761,7 @@ class MicrosoftOAuthService Future refreshToken( OAuthTokenSet current, { MicrosoftPublicRegistration? registration, + String? claims, }) async { final clientId = registration?.clientId ?? _config.microsoftOAuthClientId.trim(); @@ -681,7 +799,18 @@ class MicrosoftOAuthService microsoftSharedCalendarScope, if (hasMicrosoftGraphScope(current.scopes, microsoftCategoryScope)) microsoftCategoryScope, + if (hasMicrosoftGraphScope( + current.scopes, + microsoftContactsWriteScope, + )) + microsoftContactsWriteScope + else if (hasMicrosoftGraphScope( + current.scopes, + microsoftContactsReadScope, + )) + microsoftContactsReadScope, ].join(' '), + if (claims != null && claims.isNotEmpty) 'claims': claims, }, ); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -807,9 +936,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/ui/windows/windows_contacts_settings.dart b/lib/src/ui/windows/windows_contacts_settings.dart new file mode 100644 index 00000000..db4ed394 --- /dev/null +++ b/lib/src/ui/windows/windows_contacts_settings.dart @@ -0,0 +1,362 @@ +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); + } + } + + bool _writable(ContactAccount account) => account.grantedScopes.any( + (s) => [ + 'contacts.readwrite', + 'carddav:write', + 'https://www.googleapis.com/auth/contacts', + ].contains(s.toLowerCase()), + ); + 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), + readOnly: input.readOnly, + ); + } else { + await controller.addCardDavContactsOnly( + id: id, + label: input.label, + server: Uri.parse(input.server), + username: input.username, + password: input.password, + readOnly: input.readOnly, + ); + } + } + } + + 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), + ), + Text(context.l10n.contactsWritePermissionDescription), + _action( + context.l10n.contactsEnableEditing, + () => parent.provider == BusyProvider.nextcloud + ? controller.enableLinkedNextcloudContacts( + parent.id, + writable: true, + ) + : controller.enableLinkedContacts( + parent.id, + writable: true, + ), + ), + ], + Wrap( + spacing: 8, + children: [ + _action(context.l10n.contactsAddCardDav, () => _add(false)), + _action(context.l10n.contactsAddNextcloud, () => _add(true)), + ], + ), + for (final account in accounts) ...[ + Text( + '${account.displayName} · ${_writable(account) ? context.l10n.contactsReadWriteAccess : context.l10n.readOnlySharedCollection} · ${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), + ), + if (account.provider == ContactProviderKind.carddav && + !account.id.startsWith('contacts:') && + !_writable(account)) + _action( + context.l10n.contactsEnableEditing, + () => controller.reconnectDavContactsOnly( + account.id, + readOnly: false, + ), + ), + _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, + this.readOnly, + ); + final String label, server, username, password; + final bool readOnly; +} + +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(); + bool _readOnly = true; + 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, + _readOnly, + ), + ); + } + + @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), + ), + ToggleSwitch( + checked: _readOnly, + content: Text(context.l10n.readOnlySharedCollection), + onChanged: (value) => setState(() => _readOnly = value), + ), + 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_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_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/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..03389259 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,11 +4,17 @@ publish_to: 'none' version: 0.2.4 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/test/config/build_config_test.dart b/test/config/build_config_test.dart index fe4d00f9..8fe03f68 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -1,42 +1,58 @@ 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, - authorityTenant: '', - ), - 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( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + busyMaxMicrosoftOAuthClientId: '33333333-3333-3333-3333-333333333333', + microsoftOAuthAuthorityTenant: 'organizations', + oauthAuthorizationEndpoint: 'https://accounts.example/authorize', + oauthTokenEndpoint: 'https://accounts.example/token', + oauthRevocationEndpoint: 'https://accounts.example/revoke', + ); + expect(config.hasBusyMaxMicrosoftRegistration, isTrue); + expect(config.hasBusyMaxGoogleRegistration, isFalse); + expect(config.microsoftOAuthClientId, isEmpty); + expect(config.microsoftOAuthAuthorityTenant, 'organizations'); + }); test('fromEnvironment uses Google endpoint defaults', () { final config = BuildConfig.fromEnvironment(); diff --git a/test/contacts/busymax_contacts_controller_test.dart b/test/contacts/busymax_contacts_controller_test.dart new file mode 100644 index 00000000..8e7c15c8 --- /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, { + required bool writable, + AuthorizationCancellation? cancellation, + Future Function()? persistContacts, + }) => throw StateError('authorization is not used by cached lookup'); + + @override + Future contactsAuthorizationHeader( + BusyProvider provider, + String accountId, { + required bool writable, + 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..d65694d4 --- /dev/null +++ b/test/contacts/contacts_enrollment_test.dart @@ -0,0 +1,302 @@ +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 upgrade 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 (final writable in [false, true, true]) { + await fixture.controller.enableLinkedContacts( + provider.name, + writable: writable, + ); + 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' + : 'Contacts.ReadWrite', + ), + ); + final before = account.toJson(); + fixture.authorizationFailure = StateError( + 'cancelled or invalid consent', + ); + await expectLater( + fixture.controller.enableLinkedContacts( + provider.name, + writable: true, + ), + 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 upgrade 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 = parentBefore; + 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, writable: true), + 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 upgraded 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, writable: true); + 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/parent_removal_lifecycle_test.dart b/test/contacts/parent_removal_lifecycle_test.dart new file mode 100644 index 00000000..46a1955a --- /dev/null +++ b/test/contacts/parent_removal_lifecycle_test.dart @@ -0,0 +1,1058 @@ +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, + writable: false, + ); + } + 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]) { + 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/core/auth/authorization_transition_test.dart b/test/core/auth/authorization_transition_test.dart index c00080cb..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'; @@ -94,6 +97,79 @@ void main() { h.staging.dispose(); await h.db.close(); }); + for (final origin in [ + RegistrationOrigin.busyMaxManaged, + RegistrationOrigin.userProvided, + ]) { + for (final audience in [ + MicrosoftAudience.organizations, + MicrosoftAudience.tenant, + ]) { + test( + '$origin $audience binding survives a changed managed build', + () async { + h.staging.dispose(); + await h.db.close(); + h = Harness( + buildConfig: syntheticDesktopConfig( + later: true, + originalMicrosoftAuthority: 'consumers', + ), + ); + final issuingRegistration = MicrosoftPublicRegistration( + clientId: origin == RegistrationOrigin.busyMaxManaged + ? syntheticDesktopConfig().busyMaxMicrosoftOAuthClientId + : msOwned, + audience: audience, + tenantId: audience == MicrosoftAudience.tenant ? tenant : null, + origin: origin, + ); + await h.seedMicrosoft( + shared: false, + registration: issuingRegistration, + ); + const id = 'microsoft:ms-user'; + final saved = (await h.secrets.readCredential(id))!; + await h.secrets.saveCredential( + id, + SecretRecord.fromJson(jsonDecode(jsonEncode(saved.toJson()))), + ); + + await h.microsoft.refreshTokenForAccount(id); + await h.microsoft.connectMicrosoft( + AuthorizationRequest.reconnect(id), + ); + expect(h.requests.where((r) => r.method == 'POST'), isNotEmpty); + expect( + h.requests + .where((r) => r.method == 'POST') + .every( + (r) => + r.url.path.startsWith( + '/${issuingRegistration.authorityTenant}/', + ) && + Uri.splitQueryString(r.body)['client_id'] == + issuingRegistration.clientId, + ), + isTrue, + ); + expect(h.flow.clients, [issuingRegistration.clientId]); + final finalRecord = + await h.secrets.readCredential(id) as BoundOAuthSecretRecord; + final registration = + finalRecord.registration as MicrosoftPublicRegistration; + expect(registration.clientId, issuingRegistration.clientId); + expect(registration.audience, audience); + expect(registration.tenantId, issuingRegistration.tenantId); + expect(registration.origin, origin); + expect( + h.staging.stageBusyMax(BusyProvider.microsoft).summary.authority, + 'common', + ); + }, + ); + } + } for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { test( '$provider managed credentials remain account-bound after restart and a changed build', @@ -159,7 +235,7 @@ void main() { expect( h.requests .where((r) => r.method == 'POST') - .every((r) => r.url.path.startsWith('/organizations/')), + .every((r) => r.url.path.startsWith('/common/')), isTrue, ); } @@ -272,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( @@ -419,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', @@ -447,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), ); }, @@ -462,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 { @@ -510,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'); @@ -578,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 { @@ -750,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)), ); }, @@ -805,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 { @@ -866,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( @@ -877,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', @@ -954,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', ); }, @@ -981,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); @@ -1005,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(); @@ -1052,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, ); }, @@ -1075,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( @@ -1153,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, ); }, @@ -1173,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); @@ -1206,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); }, @@ -1253,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', ); }, @@ -1341,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); @@ -1414,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; @@ -1448,7 +1513,10 @@ class Harness { requireExisting: true, persistAccount: () async {}, ); - Future seedMicrosoft({required bool shared}) async { + Future seedMicrosoft({ + required bool shared, + MicrosoftPublicRegistration? registration, + }) async { final id = 'microsoft:ms-user'; await accounts.upsertSignedInAccount( id: id, @@ -1466,13 +1534,15 @@ class Harness { accountId: id, expectedGeneration: 0, candidate: MicrosoftDesktopCredential( - registration: MicrosoftPublicRegistration( - clientId: shared ? config.microsoftOAuthClientId : msOwned, - audience: MicrosoftAudience.personalAndOrganizations, - origin: shared - ? RegistrationOrigin.retiringShared - : RegistrationOrigin.userProvided, - ), + registration: + registration ?? + MicrosoftPublicRegistration( + clientId: shared ? config.microsoftOAuthClientId : msOwned, + audience: MicrosoftAudience.personalAndOrganizations, + origin: shared + ? RegistrationOrigin.retiringShared + : RegistrationOrigin.userProvided, + ), subject: 'ms-user', tenantId: tenant, generation: 1, @@ -1538,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/registration_setup_controller_test.dart b/test/core/auth/registration_setup_controller_test.dart index e46506ef..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), @@ -56,6 +63,25 @@ void main() { }); } + test('managed Microsoft always uses common independently of originals', () { + final config = syntheticDesktopConfig( + originalMicrosoftAuthority: 'organizations', + ); + final staging = RegistrationStaging(config); + addTearDown(staging.dispose); + final registration = staging.consume( + staging.stageBusyMax(BusyProvider.microsoft), + ) as MicrosoftPublicRegistration; + + expect(registration.clientId, config.busyMaxMicrosoftOAuthClientId); + expect(registration.audience, MicrosoftAudience.personalAndOrganizations); + expect(registration.authorityTenant, 'common'); + expect(registration.tenantId, isNull); + expect(registration.origin, RegistrationOrigin.busyMaxManaged); + expect(config.microsoftOAuthAuthorityTenant, 'organizations'); + expect(config.microsoftOAuthClientId, isNot(registration.clientId)); + }); + group('Google configuration replacement', () { late RegistrationStaging staging; late RegistrationSetupController setup; @@ -250,6 +276,28 @@ void main() { expect(setup.canConnect, isFalse); }); + for (final audience in MicrosoftAudience.values) { + test( + 'custom Microsoft preserves the selected $audience registration', + () { + setup.updateClientId(clientId); + setup.updateAudience(audience); + if (audience == MicrosoftAudience.tenant) { + setup.updateTenantId(tenantId); + } + final registration = + staging.consume(setup.accept()!) as MicrosoftPublicRegistration; + expect(registration.clientId, clientId); + expect(registration.audience, audience); + expect( + registration.tenantId, + audience == MicrosoftAudience.tenant ? tenantId : null, + ); + expect(registration.origin, RegistrationOrigin.userProvided); + }, + ); + } + test( 'audience changes clear tenant and never submit hidden stale data', () { 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/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/db/app_database_test.dart b/test/db/app_database_test.dart index b32249e7..2609906b 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -102,95 +102,148 @@ void main() { }); }); - 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)); + test('schema 19 migration adds contacts without changing app data', () async { + await database.close(); + final directory = await createPersistenceTestDirectory( + 'busymax-contacts-migration-', + ); + addTearDown(() => 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/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index ce752aa3..e4906e7b 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,102 @@ 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; + 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 +249,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 +702,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 +924,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 +2213,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 +2688,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 +2772,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/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/oauth/token_exchange_test.dart b/test/google_tasks/oauth/token_exchange_test.dart index f9adcb58..9b65d70f 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,137 @@ 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', + writable: true, + persistContacts: () async { + contactsCommits++; + }, + ); + + expect(contactsCommits, 1); + expect( + await service.googleContactsAuthorizationHeader( + 'google:subject', + writable: true, + ), + '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', + writable: false, + ), + 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/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_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/oauth/microsoft_oauth_service_test.dart b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart index 2ad03d7a..f6d3539d 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'; @@ -32,6 +34,7 @@ void main() { 'Calendars.ReadWrite', 'Calendars.ReadWrite.Shared', 'MailboxSettings.Read', + 'Contacts.ReadWrite', }, ), ); @@ -57,8 +60,19 @@ void main() { await service.categoryAuthorizationHeaderForAccount('microsoft:user'), 'Bearer short-access', ); + expect( + await service.microsoftContactsAuthorizationHeader( + 'microsoft:user', + writable: true, + ), + 'Bearer short-access', + ); await service.authorizeSharedCalendarAccess('microsoft:user'); await service.authorizeCategoryAccess('microsoft:user'); + await service.authorizeMicrosoftContacts( + 'microsoft:user', + writable: true, + ); expect( (await store.readOAuthTokenSet( 'microsoft:user', @@ -97,89 +111,85 @@ 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(requested, contains(microsoftContactsWriteScope)); 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)); + 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 +207,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 +230,28 @@ void main() { service.categoryAuthorizationHeaderForAccount('microsoft:user'), throwsA(isA()), ); + await expectLater( + service.microsoftContactsAuthorizationHeader( + 'microsoft:user', + writable: false, + ), + throwsA(isA()), + ); + await expectLater( + service.microsoftContactsAuthorizationHeader( + 'microsoft:user', + writable: true, + ), + throwsA(isA()), + ); }, ); for (final optional in [ microsoftSharedCalendarScope, microsoftCategoryScope, + microsoftContactsReadScope, + microsoftContactsWriteScope, ]) { for (final denial in [ 'cancelled', @@ -283,9 +311,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( @@ -457,40 +483,37 @@ 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)['scope'], + contains(microsoftCategoryScope), + ); + expect(token.scopes, contains(microsoftCategoryScope)); + }); test('refresh failure preserves the token endpoint status', () async { final service = _service((request) async { @@ -601,6 +624,25 @@ 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', + writable: false, + ), + microsoftContactsWriteScope => service.authorizeMicrosoftContacts( + 'microsoft:user', + writable: true, + ), + _ => 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_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart index 40c2f5e8..a0598183 100644 --- a/test/platform/android/android_notification_reconciliation_test.dart +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -306,10 +306,12 @@ void main() { test( 'overdue pending summary is replaced after privacy and precision change', () async { + final notifications = service(exact: true); + await notifications.initialize(timeZoneId: 'America/Vancouver'); await _insertDueTodayTask(database); final dueTask = await database.select(database.tasks).getSingle(); expect(taskDueAsLocal(dueTask), isNotNull); - expect(taskDueAsLocal(dueTask)!.day, DateTime.now().day); + expect(taskDueAsLocal(dueTask)!.day, tz.TZDateTime.now(tz.local).day); final previousSettings = settings; final localDate = _today(); await database @@ -338,9 +340,6 @@ void main() { notifyDueToday: true, notificationDetailLevel: NotificationDetailLevel.private, ); - final notifications = service(exact: true); - await notifications.initialize(timeZoneId: 'America/Vancouver'); - await notifications.reconcile(); expect(plugin.cancelledIds, contains(0x425903)); @@ -452,7 +451,8 @@ Future _insertDueTodayTask(AppDatabase database) async { updatedLocalAtUtc: '', ), ); - final today = DateTime.now(); + // Match the service's configured date, not the host runner's timezone. + final today = tz.TZDateTime.now(tz.local); await database .into(database.tasks) .insert( @@ -472,7 +472,7 @@ Future _insertDueTodayTask(AppDatabase database) async { } String _today() { - final now = DateTime.now(); + final now = tz.TZDateTime.now(tz.local); return '${now.year.toString().padLeft(4, '0')}-' '${now.month.toString().padLeft(2, '0')}-' '${now.day.toString().padLeft(2, '0')}'; 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/contacts_settings_routes_test.dart b/test/platform/contacts_settings_routes_test.dart new file mode 100644 index 00000000..b77bf0ce --- /dev/null +++ b/test/platform/contacts_settings_routes_test.dart @@ -0,0 +1,477 @@ +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'} setup, editing mode 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, + ); + await tester.ensureVisible(find.text('Enable contact editing').first); + await tester.pumpAndSettle(); + final upgraded = f.store.changes + .asyncMap((_) => f.store.read((tx) => tx.account(account.id))) + .firstWhere( + (a) => a?.grantedScopes.contains('carddav:write') == true, + ); + await tester.tap(find.text('Enable contact editing').first); + await complete(tester, () => upgraded); + await contactActionsReady(tester, windows); + await complete( + tester, + () => f.controller.synchronizeAccount(account.id), + ); + await settle(tester); + expect( + (await complete( + tester, + () => f.controller.store.read((tx) => tx.account(account.id)), + ))!.grantedScopes, + contains('carddav:write'), + ); + 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, upgrades, 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); + await tester.ensureVisible(find.text('Enable contact editing').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Enable contact editing').first); + await settle(tester); + expect(find.textContaining('Read and write access'), findsWidgets); + 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/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_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