diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml
index e99df16a..2b6ba5db 100644
--- a/.github/workflows/flutter-android.yml
+++ b/.github/workflows/flutter-android.yml
@@ -18,6 +18,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
- name: Validate release PR source branch
if: github.event_name == 'pull_request'
@@ -25,6 +27,9 @@ jobs:
RELEASE_PR_TITLE: ${{ github.event.pull_request.title }}
run: python3 tool/check_release_pull_request.py
+ - name: Materialize pinned shared dependencies
+ run: python3 tool/checkout_shared_dependencies.py
+
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml
index acfafedc..87628cb2 100644
--- a/.github/workflows/flutter-linux.yml
+++ b/.github/workflows/flutter-linux.yml
@@ -30,6 +30,8 @@ jobs:
- name: Check out repository
uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
- name: Validate release PR source branch
if: github.event_name == 'pull_request'
@@ -37,6 +39,9 @@ jobs:
RELEASE_PR_TITLE: ${{ github.event.pull_request.title }}
run: python3 tool/check_release_pull_request.py
+ - name: Materialize pinned shared dependencies
+ run: python3 tool/checkout_shared_dependencies.py
+
- name: Install Linux build dependencies
run: |
printf 'https://archive.ubuntu.com/ubuntu/\n' | \
@@ -258,6 +263,18 @@ jobs:
id: snapcraft
uses: snapcore/action-build@v1
+ - name: Build deterministic sibling-layout source archive
+ run: python3 tool/package_source.py
+
+ - name: Upload pinned source artifact
+ if: github.event_name == 'push'
+ uses: actions/upload-artifact@v7
+ with:
+ name: busymax-source
+ path: dist/source/
+ if-no-files-found: error
+ retention-days: 7
+
- name: Install strict Snap
run: >-
sudo snap install --dangerous
diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml
index 2561bce6..cc2302aa 100644
--- a/.github/workflows/flutter-windows.yml
+++ b/.github/workflows/flutter-windows.yml
@@ -18,6 +18,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
- name: Validate release PR source branch
if: github.event_name == 'pull_request'
@@ -26,6 +28,10 @@ jobs:
RELEASE_PR_TITLE: ${{ github.event.pull_request.title }}
run: python tool/check_release_pull_request.py
+ - name: Materialize pinned shared dependencies
+ shell: pwsh
+ run: python tool/checkout_shared_dependencies.py
+
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
diff --git a/.gitignore b/.gitignore
index b0f6c4ea..9f124303 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,7 @@ migrate_working_dir/
.pub/
/build/
/coverage/
+/dist/
# Per-run evidence belongs in build/ or a temporary directory.
/docs/validation/
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2cf49892..6e6e0303 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -59,12 +59,13 @@ android {
minSdk = 24
targetSdk = 37
// Keep Android's required monotonic package code separate from the
- // public 0.2.4 version declared in pubspec.yaml. When using split APKs,
+ // public version declared in pubspec.yaml. The owner confirmed 4 is
+ // the highest distributed base code; v0.3.0 advances to 5. For split APKs,
// 1000 * ABI_VERSION is added automatically by Flutter.
// (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
- versionCode = 4
+ versionCode = 5
versionName = flutter.versionName
manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!!
manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash
diff --git a/android/app/src/main/res/raw/busymax_notification_keep.xml b/android/app/src/main/res/raw/busymax_notification_keep.xml
new file mode 100644
index 00000000..f7e80f1a
--- /dev/null
+++ b/android/app/src/main/res/raw/busymax_notification_keep.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/docs/android_release.md b/docs/android_release.md
index b732f5cf..323625fd 100644
--- a/docs/android_release.md
+++ b/docs/android_release.md
@@ -5,6 +5,12 @@ Production key material is never tracked. Copy
`storeFile` at the owner-controlled keystore, and fill its passwords and alias
outside logs and shell history. Do not replace an established upload identity.
+For v0.3.0 the public version is `0.3.0` and the package code is `5`, advancing
+the v0.2.4 source and retained test-signed artifact code `4`. During preparation
+on 2026-10-11, the owner confirmed `4` is the highest distributed base code.
+For subsequent releases, verify the distribution ledger and account for ABI
+offsets if split APKs were distributed before choosing the next code.
+
With real public provider configuration and owner signing present, build both
formats with:
diff --git a/docs/android_setup.md b/docs/android_setup.md
index 7668c33e..77a52f32 100644
--- a/docs/android_setup.md
+++ b/docs/android_setup.md
@@ -1,7 +1,7 @@
# Android setup and provider registration
BusyMax Android is a beta application with package ID
-`io.busystack.busymax`, version `0.2.4`, minimum API 24, and compile/target
+`io.busystack.busymax`, version `0.3.0`, minimum API 24, and compile/target
API 37. The supported toolchain is Flutter 3.47.5, its bundled Dart 3.13.4,
JDK 17, Android platform 37, and Build Tools 37.x.
diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md
index 269b35b1..21322beb 100644
--- a/docs/beta_snap_release.md
+++ b/docs/beta_snap_release.md
@@ -20,6 +20,8 @@ grep -nE '^version:|/head` and target branch;
+the synthetic PR merge checkout does not prove release-branch ancestry.
+The owner reviews and manually merges the open release PR.
+Historically, v0.2.4 feature/fix PRs were squashed into `Release/v0.2.4`,
+followed by a squash of that release PR into `main`.
For release artifacts, follow [Snap beta release](beta_snap_release.md) or
[Windows packaging](windows_packaging.md) and the
diff --git a/docs/development/contacts-read-only.md b/docs/development/contacts-read-only.md
new file mode 100644
index 00000000..6bf97cbe
--- /dev/null
+++ b/docs/development/contacts-read-only.md
@@ -0,0 +1,66 @@
+# BusyMax contacts are read-only attendee lookup
+
+Implemented the owner's approved product policy. BusyMax synchronizes and caches contacts for attendee suggestions and offline lookup. It does not create, update or delete remote contacts, photos, Google groups, Microsoft folders or DAV address books. Contact account enrollment, consent, refresh, local disconnection/removal, source preferences and cache/cursor writes remain supported. Calendar/task writes and invitations retain their existing authorization and providers.
+
+The live starting point was `fix/contacts-read-only` at `42dd355454cfc5e86bd10f0c58ea786267f20b5f`, with no staged, unstaged or untracked work. No owner instruction files were found in BusyMax or its ancestor directories. [Baseline evidence](evidence/contacts-read-only/baseline.json) records sibling heads and pre-existing BusyCards IDE/cache work. The supplied archive identifies `788e8a8e4be563eecddc45f8f783e02e315ae0e2`, SHA-256 `e4830832d85d210df33fa459a8ec9623fd9eed1cbbb1c5ab6d06fc7da9b4dd86`; its static report was not treated as an executed test run of this correction.
+
+## Integration boundary
+
+`ReadOnlyContactsProvider` implements the existing public `ContactsProvider` contract. It keeps its concrete delegate private, delegates discovery, paging, retrieval, photo reads, create reconciliation reads, action decoding and disposal, and rejects `create`, `update`, `delete`, `setPhoto` and every provider action before calling the delegate. It preserves native contact records, identities, revisions, cancellation and pagination. Effective source capabilities disable create/update/delete/photo-write, rename and remove; read/photo-read and all other metadata retain their original values, including server-unreadable sources.
+
+`BusyMaxContactsController._bindProvider` applies this boundary to every `_bind` route, including the injectable factory, startup, enrollment, reconnect and removal recovery. The factory is private after construction. `_davProvider` also constructs CardDAV with `readOnly: true`. Stored OAuth grants remain actual provider grants rather than fabricated read-only sets. Old DAV configuration/passwords and `carddav:write` markers remain readable; only new DAV enrollment records the constant read-only configuration. Existing mode selection arguments and the Google contacts client's writable field were removed.
+
+`BusyMaxContactsStore` uses the existing Drift transaction to normalize cached source capabilities at startup before directory restoration. Reads and future source writes apply the same effective policy, including before online discovery and for disabled sources. IDs, records, source selection, cursors, preferences, account generations, credentials and schema 20 remain intact. No Drift regeneration, schema bump or data reset is needed.
+
+## Requested and granted authorization
+
+Contacts remain optional. First calendar/task sign-in requests no contacts scope. Explicit Google contacts enrollment requests only `https://www.googleapis.com/auth/contacts.readonly`; desktop Microsoft requests Graph-qualified `Contacts.Read`, and native MSAL uses the short representation. Already granted full Google contacts or Microsoft `Contacts.ReadWrite` permission satisfies read access without write consent or a new browser. Recognition of historical write grants is separate from outgoing scope assembly.
+
+Desktop reconnect/replacement requests carry previous contact access forward as read-only and retain base Calendar/Tasks, identity/offline access and enabled Microsoft shared-calendar/category scopes. Google keeps the installed-app system-browser PKCE full-required-set flow; it does not use web incremental authorization. Google refresh remains scope-free. Microsoft refresh now omits its optional scope parameter, including claims recovery, while retaining the issuing registration, rotation, returned-grant parsing, cooldown and generation fencing. Omitted returned scopes retain the existing grant under the renewal contract; explicit partial/revoked grants are honored. No local policy change revokes an existing provider grant or clears a working refresh token.
+
+The real Android broker sends read-only contacts scopes for interactive and silent requests. Existing sufficient native authorization uses silent acquisition, checks the actual result and preserves subject/tenant/registration/generation fencing. First optional enrollment persists accepted actual native grants before validating the contacts link, inside the existing native credential commit. Persistence failure rolls the grant/link back together. Requested scopes are never concatenated into a fictitious token grant. Existing optional native calendar/category access is carried into explicit reconnect/replacement. The Kotlin bridge already accepts supplied scope lists and returns provider-reported scopes; no new allowlist or bridge change was necessary.
+
+The resolved native artifacts were inspected: Google `play-services-auth:21.5.0` exposes `getGrantedScopes(): List` and `setRequestedScopes(List)`; MSAL `8.4.1` exposes `getScope(): String[]`. Artifact hashes and `javap` output are retained with delivery evidence. No SDK, runtime, dependency or registration versions changed.
+
+## Journals and lifecycle
+
+BusyMax no longer calls `directory.resumeQueued` during synchronization. Queued, unknown and conflicting entries retain their IDs, payloads and uncertainty evidence. Existing executing-to-unknown crash/removal handling remains in the shared directory, with unresolved recovery still actionable through the existing reconnect/removal state. Startup, timer, manual sync, reconnect and removal rollback do not dispatch historic contact writes. Explicit contact mutation attempts fail at the integration boundary; the shared directory may record a newly attempted provider action as failed, while existing journals are preserved.
+
+AuthRepository, AuthorizationPersistence, C1 lock ordering and C2 removal receipts were preserved. The actual LifecycleFixture still includes its production persistence/lock chain. Tests retain original enabled/disabled/absent state, cancellation/drain ordering, conditional restoration, detached-binding recovery, newer-generation fencing, unknown writes and shutdown assertions.
+
+## Settings and localization
+
+Linux/Yaru, Windows/Fluent and Android Settings retain account/source controls and platform routes. Editing/upgrade actions, writable badges and DAV mode switches were removed. Every supported locale contains a contacts-specific read-only attendee description, and the three unused editing keys were removed through normal `flutter gen-l10n`. The generic Portuguese alias remains empty; the existing European Portuguese locale carries its translation. Shared calendar/task editing strings remain. Manual attendee input, source-qualified cached suggestions, exclusions, limits, draft selections and stale-result fencing use the existing implementation. The embedded provider setup guides label contacts as optional and make no claim that prior grants were revoked or verification approved.
+
+## Verification and delivery
+
+Use the unchanged Flutter 3.47.5 and bundled Dart 3.13.4 pins verified by `tool/verify_flutter_sdk.dart`; `tool/shared_dependencies.json` retains contacts `cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2`, DAV `18f636f3227e3df8c1406bafd85b45e7f12cf8bd` and Graph `6572ee14ae992d4cd4f8c2218babaff5051bc132`. Sibling repositories are read-only dependencies; their checkout helper was inspected and not run over owner working trees.
+
+Commands and full logs are recorded under `build/contacts-read-only` and included in the delivery evidence archive. Command receipts retain their actual exit status, duration, head/tree and source fingerprint. The delivery manifest records the final commit/tree, archive and per-file checksums. Source uses the existing `python3 tool/package_source.py` sibling layout and contains the unchanged pinned dependency inputs. Linux release packaging preserves its native helper/assets. Extraction receipts verify both manifests, the relative dependency layout and locked offline resolution. The pre-existing source archive was preserved under `build/contacts-read-only/preserved-source-input` before replacing the packaging output; its revision is unverified and it is not presented as this correction.
+
+Targeted regressions include the existing contacts/store/enrollment/removal, authorization/consent/credential persistence, actual platform Settings routes and attendee fencing tests; additions inspect actual desktop authorization URLs, real Android broker method-channel scopes/native commits, shared-provider transports under broad grants, cached schema-20 state and journal preservation across startup/timer/reconnect/removal rollback. The final evidence also records normal localization generation, touched-file formatting, `git diff --check`, `flutter analyze`, `dart run tool/check_platform_boundaries.dart`, the full BusyMax Flutter suite, Linux debug/release builds, available native workflow checks and an isolated native Linux Settings session with a synthetic cache and screenshot.
+
+The final application and test inputs are commit `4a4bd5528725fe1eca067099a9671ced9e4a745a`, tree `e0e1c52405d73f26a259ecd59cff8f6ee48863f2`, source fingerprint `8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6`. Subsequent changes contain development evidence only; the delivery manifest identifies that final documentation commit and proves identical tested inputs. [Verification record](evidence/contacts-read-only/verification.json) includes exact commands, exit statuses, durations, log hashes and skip names.
+
+| Gate | Measured result |
+| --- | --- |
+| Locked resolution, localization generation, touched-file formatting, diff check, analysis, platform boundaries | Passed |
+| Complete BusyMax Flutter suite | 3,619 passed, 10 live-provider skips, 0 failures |
+| Affected cases within that final complete run | 435 passed, 0 skips |
+| Corrective locale and linked-DAV focused run | 42 passed |
+| Native Linux workflow checks and debug/release builds | Passed on the recorded Linux host |
+| Real isolated Linux Settings session | 1 passed; synthetic offline cache, source selection, dialog and screenshot |
+| Android bridge native unit tests and app lint | 11 tests passed; lint passed; unchanged native inputs checked across the correction |
+| Independent Android test-signed APK/AAB builds and inspection | Passed; signatures, notification resources, 16 KB ELF segments and APK zip alignment checked |
+
+The first complete run caught the generic Portuguese alias regression (3,617 passes, 10 skips, 1 failure). It was corrected without changing supported locales, and the complete suite was rerun. An initial Android release build in the shared build layout failed because the generated registrant referenced the dev-only integration plugin without its release dependency. The independent temporary source/sibling layout resolved that build failure without changing pins or generated source manually. Both diagnostic logs remain in the evidence archive. Newly built Android files are labeled test-signed and were never installed.
+
+Native Windows builds/device acceptance require a Windows host; Fluent route tests are not native acceptance. Android on-device acceptance and live-provider tests are unverified: no device installation, disposable accounts or registrations were authorized. AAB bundletool alignment metadata remains unverified because no local bundletool jar is available; the other recorded artifact checks passed. Historical Windows/Android/Snap/offline artifacts are not relabeled as builds of this correction. Unchanged Snap/engine/driver qualification was not restarted.
+
+## References checked 11 October 2026
+
+- [Google People connections authorization](https://developers.google.com/people/api/rest/v1/people.connections/list): accepts readonly or full contacts authorization.
+- [Microsoft Graph permission meanings](https://learn.microsoft.com/en-us/graph/permissions-reference): `Contacts.Read` reads; `Contacts.ReadWrite` also includes mutations.
+- [Google installed-app authorization](https://developers.google.com/identity/protocols/oauth2/native-app): PKCE and full-set reauthorization; installed-app incremental authorization is unsupported.
+- [Microsoft refresh parameters](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token) and [RFC 6749 section 6](https://www.rfc-editor.org/rfc/rfc6749.html#section-6): optional refresh scope omission renews existing authorization.
+- [Google sensitive scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification): scope access must match actual user-facing functionality.
diff --git a/docs/development/evidence/contacts-read-only/baseline.json b/docs/development/evidence/contacts-read-only/baseline.json
new file mode 100644
index 00000000..54e20ff5
--- /dev/null
+++ b/docs/development/evidence/contacts-read-only/baseline.json
@@ -0,0 +1,52 @@
+{
+ "busymax": {
+ "branch": "fix/contacts-read-only",
+ "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f",
+ "status": "",
+ "staged": "",
+ "unstaged": "",
+ "schema": 20
+ },
+ "busystack_contacts": {
+ "branch": "main",
+ "head": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busystack_dav": {
+ "branch": "main",
+ "head": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busystack_graph": {
+ "branch": "main",
+ "head": "6572ee14ae992d4cd4f8c2218babaff5051bc132",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busycards": {
+ "branch": "main",
+ "head": "8cfb67e50e2d058228edc16dfcb053baf4abef56",
+ "status": "M .idea/busycards.iml\n M .idea/libraries/Dart_Packages.xml\n?? .idea/libraries/Flutter_Plugins.xml\n?? native/microsoft_auth_helper/__pycache__/",
+ "staged": "",
+ "unstaged": ".idea/busycards.iml | 3574 +++++++++++++++++++++++++++++++++++++\n .idea/libraries/Dart_Packages.xml | 558 +++++-\n 2 files changed, 4130 insertions(+), 2 deletions(-)"
+ },
+ "busybox": {
+ "branch": "Release/v0.0.1",
+ "head": "5aeba272bde4f6798fadaa394c037e8b7aaf74e6",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busyfiles": {
+ "branch": "Release/v0.0.1",
+ "head": "14c2853cc089345d2b1ed053fc0d34b1a7477110",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ }
+}
diff --git a/docs/development/evidence/contacts-read-only/verification.json b/docs/development/evidence/contacts-read-only/verification.json
new file mode 100644
index 00000000..5662281e
--- /dev/null
+++ b/docs/development/evidence/contacts-read-only/verification.json
@@ -0,0 +1,505 @@
+{
+ "schema": 1,
+ "recordedUtc": "2026-10-11T13:30:21.822384+00:00",
+ "verifiedCommit": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "verifiedTree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "implementationCommits": [
+ "1c7a433950ebcd6ce691b7c3ae4deddb572acf2e",
+ "4a4bd5528725fe1eca067099a9671ced9e4a745a"
+ ],
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "fingerprintContract": "SHA-256 of json.dumps(sorted path->SHA-256 map, sort_keys=True); application, test, platform, native, tool and embedded provider guides. Only subsequent development evidence is excluded. Full per-file map is in the delivery evidence archive.",
+ "toolchain": {
+ "host": "Linux-7.0.0-38-generic-x86_64-with-glibc2.43",
+ "flutter": {
+ "frameworkVersion": "3.47.5",
+ "channel": "stable",
+ "repositoryUrl": "https://github.com/flutter/flutter.git",
+ "frameworkRevision": "6a19cca56475dbfba1478ee68d7bd0c2ef891da1",
+ "frameworkCommitDate": "2026-09-17 14:13:22 -0400",
+ "engineRevision": "af7e796e161ae0bb1ff0758c71a7105418bd9ded",
+ "engineCommitDate": "2026-09-16 18:35:09.000Z",
+ "engineContentHash": "ab598368592da0064197e2bc15c7f5b0a2c6bb1f",
+ "engineBuildDate": "2026-09-16 12:35:21.428",
+ "dartSdkVersion": "3.13.4",
+ "devToolsVersion": "2.60.0",
+ "flutterVersion": "3.47.5"
+ },
+ "pubspecLockSha256": "d7ad8317a97382b3aa52fa7434fd6d3a3ef2c8cb2a13d431581894f0d8bef849",
+ "dependencyPins": {
+ "schema": 1,
+ "layout": "sibling-working-trees",
+ "dependencies": [
+ {
+ "name": "busystack_contacts",
+ "repository": "https://github.com/ChenZhenIO/busystack_contacts.git",
+ "revision": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2",
+ "path": "../busystack_contacts"
+ },
+ {
+ "name": "busystack_dav",
+ "repository": "https://github.com/ChenZhenIO/busystack_dav.git",
+ "revision": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd",
+ "path": "../busystack_dav"
+ },
+ {
+ "name": "busystack_graph",
+ "repository": "https://github.com/ChenZhenIO/busystack_graph.git",
+ "revision": "6572ee14ae992d4cd4f8c2218babaff5051bc132",
+ "path": "../busystack_graph"
+ }
+ ]
+ },
+ "instructions": "No applicable AGENTS.md files found in repository or ancestor directories.",
+ "java": {
+ "exit": 0,
+ "output": "openjdk version \"25.0.1\" 2025-10-21 LTS\nOpenJDK Runtime Environment Temurin-25.0.1+8 (build 25.0.1+8-LTS)\nOpenJDK 64-Bit Server VM Temurin-25.0.1+8 (build 25.0.1+8-LTS, mixed mode, sharing)\n"
+ },
+ "clang": {
+ "exit": 0,
+ "output": "Ubuntu clang version 21.1.8 (6ubuntu1)\nTarget: x86_64-pc-linux-gnu\nThread model: posix\nInstalledDir: /usr/lib/llvm-21/bin\n"
+ },
+ "cmake": {
+ "exit": 0,
+ "output": "cmake version 4.2.3\n\nCMake suite maintained and supported by Kitware (kitware.com/cmake).\n"
+ },
+ "g++": {
+ "exit": 0,
+ "output": "g++ (Ubuntu 15.2.0-16ubuntu1) 15.2.0\nCopyright (C) 2025 Free Software Foundation, Inc.\nThis is free software; see the source for copying conditions. There is NO\nwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\n\n"
+ }
+ },
+ "fullFlutterSuite": {
+ "passed": 3619,
+ "skipped": 10,
+ "failed": 0
+ },
+ "affectedCasesInFinalFullSuite": {
+ "passed": 435,
+ "skipped": 0,
+ "failed": 0
+ },
+ "priorFocusedCommand": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "test",
+ "test/contacts",
+ "test/core/auth/contacts_authorization_requests_test.dart",
+ "test/google_tasks/oauth/auth_url_test.dart",
+ "test/google_tasks/oauth/token_exchange_test.dart",
+ "test/microsoft_todo/oauth/microsoft_oauth_service_test.dart",
+ "test/core/auth/authorization_transition_test.dart",
+ "test/features/auth/data/auth_repository_test.dart",
+ "test/features/settings/presentation/settings_screen_test.dart",
+ "test/platform/contacts_settings_routes_test.dart",
+ "test/platform/attendee_contacts_context_test.dart",
+ "test/platform/android/android_authorization_broker_test.dart",
+ "test/platform/android/android_contacts_enrollment_test.dart",
+ "test/platform/android/android_registration_transition_test.dart",
+ "test/platform/android/android_platform_channel_test.dart",
+ "--machine"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f",
+ "tree": "bba83dd5c534ab05ed1c28a24d85de22942821a2",
+ "sourceFingerprint": "50fdbb2ef28c2a32ac592ce18abf4ca5a755de013c45bb6020116ea797e3fd38",
+ "startedUtc": "2026-10-11T12:52:28.288945+00:00",
+ "exit": 0,
+ "elapsedSeconds": 153.117,
+ "log": "focused.log",
+ "logSha256": "6232d43a982166bad84df8b7febbf9b0e6d26e8961bf6291008a59c7a89f5170"
+ },
+ "correctiveFocusedCases": 42,
+ "skips": [
+ {
+ "name": "real browser Login Flow returns and revokes a canonical app password",
+ "path": "test/dav/nextcloud_login_flow_live_test.dart",
+ "reason": "Set BUSYMAX_NEXTCLOUD_LOGIN_LIVE=1 and its URL, username, temporary app password, and TLS certificate variables to run this test."
+ },
+ {
+ "name": "real Login Flow start can be cancelled while browser authorization opens",
+ "path": "test/dav/nextcloud_login_flow_live_test.dart",
+ "reason": "Live Nextcloud Login Flow is not enabled."
+ },
+ {
+ "name": "real pending polling remains 404 until the operation expires",
+ "path": "test/dav/nextcloud_login_flow_live_test.dart",
+ "reason": "Live Nextcloud Login Flow is not enabled."
+ },
+ {
+ "name": "real Apple iCloud Calendar preserves discovery and event semantics",
+ "path": "test/dav/apple_icloud_live_integration_test.dart",
+ "reason": "Set BUSYMAX_ICLOUD_LIVE=1 and the BUSYMAX_ICLOUD_LIVE_* credential variables to run the Apple iCloud integration test."
+ },
+ {
+ "name": "real iCloud onboarding, credential replacement, and local removal are atomic",
+ "path": "test/dav/apple_icloud_live_integration_test.dart",
+ "reason": "Set BUSYMAX_ICLOUD_LIVE=1 and the BUSYMAX_ICLOUD_LIVE_* credential variables to run the Apple iCloud integration test."
+ },
+ {
+ "name": "exact Nextcloud server preserves DAV calendar and task semantics",
+ "path": "test/dav/nextcloud_live_integration_test.dart",
+ "reason": "Set BUSYMAX_NEXTCLOUD_LIVE=1 and the BUSYMAX_NEXTCLOUD_LIVE_* credential variables to run the isolated live-server tests."
+ },
+ {
+ "name": "large Nextcloud collection synchronizes and deletes without truncation",
+ "path": "test/dav/nextcloud_live_integration_test.dart",
+ "reason": "Set BUSYMAX_NEXTCLOUD_LIVE_LARGE=1 with the live-server variables to run the 128-member collection test."
+ },
+ {
+ "name": "prepare a durable Nextcloud object for server restart",
+ "path": "test/dav/nextcloud_live_integration_test.dart",
+ "reason": "Set the live restart stage to prepare to create the durable probe."
+ },
+ {
+ "name": "rediscover and mutate the durable object after server restart",
+ "path": "test/dav/nextcloud_live_integration_test.dart",
+ "reason": "Set the live restart stage to verify after restarting the server."
+ },
+ {
+ "name": "Nextcloud user and group shares enforce live ACL changes",
+ "path": "test/dav/nextcloud_sharing_live_test.dart",
+ "reason": "Set BUSYMAX_NEXTCLOUD_SHARING_LIVE=1 and disposable user/group environment variables to run the live Nextcloud sharing test."
+ }
+ ],
+ "androidNativeUnitTests": {
+ "tests": 11,
+ "skipped": 0,
+ "failures": 0,
+ "errors": 0
+ },
+ "androidNativeInputReuse": "Unit/lint receipt at 1c7a433; git diff --exit-code confirms android/, packages/busymax_android_platform/ and lib/src/android/ unchanged through verifiedCommit. The independent release build uses verifiedCommit.",
+ "commands": {
+ "locked-pub-final": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "pub",
+ "get",
+ "--enforce-lockfile"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:12:06.693602+00:00",
+ "exit": 0,
+ "elapsedSeconds": 5.444,
+ "log": "locked-pub-final.log",
+ "logSha256": "81571a671d2c0bb8c6f76bb7b2225fe07ad8bfd2abfa126840513cf856701ae6"
+ },
+ "localization-verified": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "gen-l10n"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:17:38.991692+00:00",
+ "exit": 0,
+ "elapsedSeconds": 8.163,
+ "log": "localization-verified.log",
+ "logSha256": "97362974d3ec503ef18e205dbcc7d8e1ca57e5426c80a4966c6608571fa88609"
+ },
+ "format-final": {
+ "command": [
+ "python3",
+ "-c",
+ "import subprocess; p=subprocess.check_output([\"git\",\"diff\",\"--name-only\",\"42dd355454cfc5e86bd10f0c58ea786267f20b5f\",\"HEAD\",\"--\",\"*.dart\"],text=True).splitlines(); subprocess.run([\"/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart\",\"format\",\"--output=none\",\"--set-exit-if-changed\",*p],check=True)"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:12:47.625274+00:00",
+ "exit": 0,
+ "elapsedSeconds": 2.032,
+ "log": "format-final.log",
+ "logSha256": "d7b931aeddd13dbfaf708346f8224047501416a8e8e4592df36bd16897905d6c"
+ },
+ "diff-check-final": {
+ "command": [
+ "git",
+ "diff",
+ "--check",
+ "42dd355454cfc5e86bd10f0c58ea786267f20b5f",
+ "HEAD"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:17:36.740548+00:00",
+ "exit": 0,
+ "elapsedSeconds": 0.477,
+ "log": "diff-check-final.log",
+ "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ "analyze-final": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "analyze"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:12:12.372728+00:00",
+ "exit": 0,
+ "elapsedSeconds": 32.2,
+ "log": "analyze-final.log",
+ "logSha256": "7fd056ddcba04d39591b14b27d7cb3f3a91ba0f18d01c23a4748fdb519566169"
+ },
+ "boundaries-final": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart",
+ "run",
+ "tool/check_platform_boundaries.dart"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:12:45.429983+00:00",
+ "exit": 0,
+ "elapsedSeconds": 1.996,
+ "log": "boundaries-final.log",
+ "logSha256": "3f00b10245d2e2189cf2f1a007fb552555a01bc024a4a7618cce5a011ca7680e"
+ },
+ "locale-and-linked-verified": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "test",
+ "test/app/localization_audit_test.dart",
+ "test/contacts/contacts_read_only_state_test.dart",
+ "--machine"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "1c7a433950ebcd6ce691b7c3ae4deddb572acf2e",
+ "tree": "7ac2af1e93321a972376ed23e67c5e6d697abf0d",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:11:09.973830+00:00",
+ "exit": 0,
+ "elapsedSeconds": 7.584,
+ "log": "locale-and-linked-verified.log",
+ "logSha256": "83ea98596ed15665b2efce7f585c26e73dd751165d972c90984aee38dfe722d6"
+ },
+ "full-suite-final": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "test",
+ "--machine"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:11:48.529349+00:00",
+ "exit": 0,
+ "elapsedSeconds": 870.015,
+ "log": "full-suite-final.log",
+ "logSha256": "508eeff6de15de7cae41ec84281a52115088c9faef8640e4c89726f063821b00"
+ },
+ "native-linux-final": {
+ "command": [
+ "bash",
+ "/tmp/busymax-native-checks.sh"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:12:49.868408+00:00",
+ "exit": 0,
+ "elapsedSeconds": 20.436,
+ "log": "native-linux-final.log",
+ "logSha256": "fffdb4efc17e2e656a8ed419a250e486b9a87908c9c72729758b62df327b5795"
+ },
+ "linux-session-final": {
+ "command": [
+ "env",
+ "BUSYMAX_ACCEPTANCE_ROOT=/home/albert/busystack/busymax/build/contacts-read-only/native-session",
+ "XDG_DATA_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/data",
+ "XDG_CONFIG_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/config",
+ "XDG_CACHE_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/cache",
+ "dbus-run-session",
+ "--",
+ "xvfb-run",
+ "-a",
+ "/home/albert/flutter-sdk/bin/flutter",
+ "test",
+ "integration_test/contacts_read_only_linux_test.dart",
+ "-d",
+ "linux",
+ "--reporter",
+ "expanded"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:13:10.513412+00:00",
+ "exit": 0,
+ "elapsedSeconds": 158.794,
+ "log": "linux-session-final.log",
+ "logSha256": "b3b714acab8e4bde0f5fbc88c25835242f564988a097b6189717ce236ae8a264"
+ },
+ "linux-debug-final": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "build",
+ "linux",
+ "--debug",
+ "-t",
+ "lib/main_linux.dart"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:15:51.053606+00:00",
+ "exit": 0,
+ "elapsedSeconds": 58.835,
+ "log": "linux-debug-final.log",
+ "logSha256": "f2bdcc31bd06c32b9e3216c398f0c7567578f0b3737e80421e13a34ebfef5798"
+ },
+ "linux-release-final": {
+ "command": [
+ "/home/albert/flutter-sdk/bin/flutter",
+ "build",
+ "linux",
+ "--release",
+ "-t",
+ "lib/main_linux.dart"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:16:51.521909+00:00",
+ "exit": 0,
+ "elapsedSeconds": 228.576,
+ "log": "linux-release-final.log",
+ "logSha256": "cba4d7abad7028d9f049a4469a4c08d8eeb7823d35107f8b4ff5bb18a270a5a9"
+ },
+ "android-native": {
+ "command": [
+ "timeout",
+ "600",
+ "android/gradlew",
+ "--project-dir",
+ "android",
+ ":busymax_android_platform:testDebugUnitTest",
+ ":app:lintDebug",
+ "--console=plain"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f",
+ "tree": "bba83dd5c534ab05ed1c28a24d85de22942821a2",
+ "sourceFingerprint": "50fdbb2ef28c2a32ac592ce18abf4ca5a755de013c45bb6020116ea797e3fd38",
+ "startedUtc": "2026-10-11T12:51:09.823115+00:00",
+ "exit": 0,
+ "elapsedSeconds": 318.333,
+ "log": "android-native.log",
+ "logSha256": "cce1d7c344ddb8f37aa1c26ea3fe8946305cfb20d75eecb9d5751bcf3ba1ec9d"
+ },
+ "android-release-isolated": {
+ "command": [
+ "timeout",
+ "900",
+ "python3",
+ "/tmp/busymax-android-isolated.py"
+ ],
+ "cwd": "/home/albert/busystack/busymax",
+ "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2",
+ "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6",
+ "startedUtc": "2026-10-11T13:11:48.024453+00:00",
+ "exit": 0,
+ "elapsedSeconds": 575.814,
+ "log": "android-release-isolated.log",
+ "logSha256": "c6ad717468194d88b1e8a5512021c7cf0f33085f873cf7901cc1a0d42efa7427"
+ }
+ },
+ "androidArtifacts": {
+ "commit": "4a4bd5528725fe1eca067099a9671ced9e4a745a",
+ "label": "TEST-SIGNED; not production distribution; never installed",
+ "artifacts": [
+ {
+ "file": "build/contacts-read-only/android-test-signed/busymax-contacts-read-only-test-signed.apk",
+ "sha256": "dbe4a866878b437a0e8e99c00c4289199fabb2644fa545de110403ade4a9d7f5",
+ "bytes": 93277935
+ },
+ {
+ "file": "build/contacts-read-only/android-test-signed/busymax-contacts-read-only-test-signed.aab",
+ "sha256": "58a52279a6bc60ac835bfe63c85a90d6265dcf25bde139b06c842a1528fd5025",
+ "bytes": 88155491
+ }
+ ],
+ "remainingGate": "AAB bundletool alignment metadata unverified: local bundletool jar unavailable",
+ "sourceLayout": "/tmp/busymax-contacts-android-check-etq4atas"
+ },
+ "remainingGates": [
+ "Windows native build/device acceptance: no Windows host. Real Fluent route tests passed on Linux; they are not native Windows acceptance.",
+ "Android device acceptance: no emulator/device installation performed or authorized.",
+ "AAB bundletool alignment metadata: no local bundletool jar. APK/AAB builds, signature/resource checks and 16 KB ELF/zip alignment inspection passed.",
+ "Live-provider authorization/sync/mutations: no disposable accounts or registrations authorized; 10 live DAV cases skipped. No live-provider success claimed.",
+ "Snap/engine/driver qualification: unchanged inputs; not restarted or relabeled."
+ ],
+ "diagnosticRuns": {
+ "full-suite": "3617 passed, 10 skipped, 1 Portuguese alias audit failure at 1c7a433; corrected and complete suite rerun.",
+ "android-release": "Shared-layout release exit 1: GeneratedPluginRegistrant referenced integration_test while its release dependency was absent. Independent committed source/sibling layout build and inspection exit 0.",
+ "locale-and-linked-regression": "Initial new fixture compilation/authority setup failures are retained in logs; corrected before final verification.",
+ "sdk-missing-arguments": "Verifier first invoked without required flags, exit 1. Correct pinned-SDK command exit 0."
+ },
+ "siblingFinalState": {
+ "busystack_contacts": {
+ "branch": "main",
+ "head": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busystack_dav": {
+ "branch": "main",
+ "head": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busystack_graph": {
+ "branch": "main",
+ "head": "6572ee14ae992d4cd4f8c2218babaff5051bc132",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busycards": {
+ "branch": "main",
+ "head": "8cfb67e50e2d058228edc16dfcb053baf4abef56",
+ "status": "M .idea/busycards.iml\n M .idea/libraries/Dart_Packages.xml\n?? .idea/libraries/Flutter_Plugins.xml\n?? native/microsoft_auth_helper/__pycache__/",
+ "staged": "",
+ "unstaged": ".idea/busycards.iml | 3574 +++++++++++++++++++++++++++++++++++++\n .idea/libraries/Dart_Packages.xml | 558 +++++-\n 2 files changed, 4130 insertions(+), 2 deletions(-)"
+ },
+ "busybox": {
+ "branch": "Release/v0.0.1",
+ "head": "5aeba272bde4f6798fadaa394c037e8b7aaf74e6",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ },
+ "busyfiles": {
+ "branch": "Release/v0.0.1",
+ "head": "14c2853cc089345d2b1ed053fc0d34b1a7477110",
+ "status": "",
+ "staged": "",
+ "unstaged": ""
+ }
+ },
+ "preservedSourceInput": {
+ "originalPath": "dist/source/busymax-0.2.4-source.tar.gz",
+ "preservedPath": "build/contacts-read-only/preserved-source-input/busymax-0.2.4-source.tar.gz",
+ "sha256": "c6431035581860ab05e38cc115d81fcea1d8737354ff3ad423ce4902b194f94a",
+ "revision": "unverified pre-existing artifact; not a build of this correction"
+ }
+}
diff --git a/docs/google_setup.md b/docs/google_setup.md
index 90e652c7..6c3f405e 100644
--- a/docs/google_setup.md
+++ b/docs/google_setup.md
@@ -39,3 +39,15 @@ https://www.googleapis.com/auth/calendar
These are BusyMax's existing desktop scopes. Imported endpoints, redirects, and scope declarations do not replace BusyMax's OAuth settings. Cancellation or an invalid replacement preserves the previous valid selection; staged configuration expires after ten minutes and is single-use. Re-import after expiry. Ordinary Reconnect uses the account's saved issuing registration. Explicit Replace registration chooses a method and preserves account identity and local data transactionally.
Android uses native authentication and is outside these desktop instructions. Production Google registration values and provider approvals are owner-controlled prerequisites, not results of local validation or tests.
+
+## Optional read-only contacts
+
+Contacts are optional and are not requested during first Calendar/Tasks sign-in. To enable cached attendee suggestions, enable **People API** in the same Google Cloud project and allow this separate scope:
+
+```text
+https://www.googleapis.com/auth/contacts.readonly
+```
+
+BusyMax uses synchronized contacts read-only, including offline attendee lookup. It does not edit contacts or groups. A previously granted full contacts permission remains accurately recorded and can satisfy lookup authorization; BusyMax's policy does not revoke that grant or clear working refresh tokens. Explicit reconnect carries contact access forward as read-only in its requested scopes using the installed-app full-required-set PKCE flow.
+
+Review [Google's sensitive-scope verification guidance](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification): requested access must match the user-facing functionality. These instructions do not claim verification approval.
diff --git a/docs/microsoft_setup.md b/docs/microsoft_setup.md
index 9752ad1a..f643af55 100644
--- a/docs/microsoft_setup.md
+++ b/docs/microsoft_setup.md
@@ -36,3 +36,9 @@ These match the existing `microsoftTodoOAuthScopes`. Shared-calendar (`Calendars
Instructions use the same setup modal with a fixed header and one scrolling body. Back preserves the form; Close cancels the whole setup. Audience changes clear the tenant both visibly and in submitted state. Valid staged configuration expires after ten minutes and is single-use. Re-enter/edit after expiry. Ordinary Reconnect, refresh, and optional consent use the registration saved with that account after restart. Explicit Replace registration preserves identity checks, rollback, and local data.
Android's native MSAL flow remains separate. The production app ID, audience, redirect, delegated permissions, and applicable consent are owner-controlled release prerequisites.
+
+## Optional read-only contacts
+
+Contacts are optional and are not requested during first Calendar/Tasks sign-in. For cached attendee suggestions, add the Microsoft Graph **delegated** permission `Contacts.Read` separately from the required scopes above. Desktop requests use `https://graph.microsoft.com/Contacts.Read`; native Android MSAL requests use `Contacts.Read`.
+
+BusyMax synchronizes contacts for read-only attendee lookup, including offline suggestions. It does not edit contacts or folders. Existing `Contacts.ReadWrite` grants can satisfy read access and remain accurately recorded. BusyMax's policy does not revoke prior server-side grants. Explicit reconnect and optional consent request only contacts read access while retaining enabled calendar/category permissions. Refresh omits the optional scope parameter to renew the existing authorization; it does not claim a reduced grant. See [Microsoft's refresh contract](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token).
diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md
index 683afa53..d0375cdf 100644
--- a/docs/windows_packaging.md
+++ b/docs/windows_packaging.md
@@ -14,8 +14,10 @@ owner's registered values:
- package identity name, Publisher CN, and publisher display name;
- four-part Store package version and optional previous version;
- privacy-policy, support, and homepage HTTPS URLs;
-- Google desktop OAuth client ID and client secret;
-- Microsoft public client ID and authority tenant; and
+- Explicitly active BusyMax-managed Google desktop client ID, secret, and
+ project ID, and Microsoft public client ID (managed authority is `common`);
+- protected original Google/Microsoft registrations and original authority,
+ separately from the active values; and
- `production=true`, `fakeData=false`, and `developmentBackend=false`.
Never commit the local file. Identity and desktop client values are embedded
diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md
index d60b613d..e77cc0c3 100644
--- a/docs/windows_release_checklist.md
+++ b/docs/windows_release_checklist.md
@@ -10,6 +10,12 @@ person/date responsible for each result.
## Automated gates
+- [ ] The release PR matches its source version, has consistent application,
+ Snap, AppStream and Windows resource metadata, and incorporates current
+ `main`; follow [Development](development.md).
+- [ ] v0.3.0 application resources report `0.3.0`. The owner supplies a separate
+ four-part MSIX version greater than the previous distributed version;
+ the CI-only `1.0.0.0` identity is not a production Store version.
- [ ] A clean checkout uses Flutter 3.47.5 and its bundled Dart 3.13.4.
- [ ] Dependency resolution, localization generation, and Drift generation
follow [Development](development.md) and leave committed outputs clean.
diff --git a/integration_test/contacts_read_only_linux_test.dart b/integration_test/contacts_read_only_linux_test.dart
new file mode 100644
index 00000000..073ad553
--- /dev/null
+++ b/integration_test/contacts_read_only_linux_test.dart
@@ -0,0 +1,159 @@
+import 'dart:io';
+import 'dart:ui' as ui;
+
+import 'package:busystack_contacts/busystack_contacts.dart';
+import 'package:busymax/main_linux.dart' as app;
+import 'package:busymax/src/app/app_bootstrap.dart';
+import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart';
+import 'package:busymax/src/features/settings/presentation/settings_screen.dart';
+import 'package:yaru/yaru.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ final output = Platform.environment['BUSYMAX_ACCEPTANCE_ROOT'];
+ testWidgets(
+ 'isolated native Linux Settings exposes cached read-only contacts and source selection',
+ (tester) async {
+ expect(Platform.environment['XDG_DATA_HOME'], '$output/data');
+ await tester.runAsync(() => app.main(const []));
+ await tester.pumpAndSettle();
+ final container = ProviderScope.containerOf(
+ tester.element(find.byType(ScheduleWorkspace)),
+ );
+ final controller = container.read(busyMaxContactsControllerProvider);
+ await tester.runAsync(() async {
+ await controller.start();
+ // Synthetic local cache only. No account tokens, provider registrations,
+ // external requests or owner's application data are used in this session.
+ const source = ContactSource(
+ accountId: 'isolated-cache',
+ id: 'book',
+ provider: ContactProviderKind.carddav,
+ kind: ContactSourceKind.carddavAddressBook,
+ name: 'Isolated cached source',
+ remotePath: 'https://fixture.invalid/book/',
+ capabilities: ContactCapabilities(
+ create: true,
+ update: true,
+ delete: true,
+ photoWrite: true,
+ ),
+ canRename: true,
+ canRemove: true,
+ );
+ const identity = ContactIdentity(
+ accountId: 'isolated-cache',
+ provider: ContactProviderKind.carddav,
+ sourceId: 'book',
+ remoteId: 'ada.vcf',
+ );
+ final vcard = newVcard(
+ const ContactPatch(
+ name: FieldEdit.set(ContactName(display: 'Ada Isolated')),
+ emails: FieldEdit.set([ContactValue('ada@example.test')]),
+ ),
+ uid: 'fixture',
+ );
+ await controller.store.write((tx) async {
+ await tx.putAccount(
+ const ContactAccount(
+ id: 'isolated-cache',
+ provider: ContactProviderKind.carddav,
+ displayName: 'Isolated attendee cache',
+ subject: 'fixture',
+ grantedScopes: {'carddav:write'},
+ ),
+ );
+ await tx.putSource(source);
+ await tx.putContact(
+ CardDavContactRecord(
+ identity: identity,
+ vcard: vcard,
+ revision: '"cached"',
+ projection: projectVcard(identity, vcard),
+ ),
+ );
+ });
+ expect(
+ (await controller.sourceSettings()).single.source.capabilities.update,
+ isFalse,
+ );
+ expect(
+ (await controller.suggestAttendees('ada')).single.email,
+ 'ada@example.test',
+ );
+ });
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
+ await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
+ await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
+ await tester.pumpAndSettle();
+ expect(find.byType(SettingsScreen), findsOneWidget);
+ final accounts = find.text('Accounts');
+ if (accounts.evaluate().isNotEmpty) {
+ await tester.tap(accounts.first);
+ await tester.pumpAndSettle();
+ }
+ expect(find.text('Enable contact editing'), findsNothing);
+ expect(find.text('Read and write access'), findsNothing);
+ expect(
+ find.text('Read-only contacts for attendee suggestions'),
+ findsWidgets,
+ );
+ final setting = (await tester.runAsync(controller.sourceSettings))!
+ .single;
+ final toggle = find.byKey(
+ ValueKey('contact-source-${setting.source.key}'),
+ );
+ await tester.ensureVisible(toggle);
+ await tester.tap(toggle);
+ await tester.pumpAndSettle();
+ expect(tester.widget(toggle).value, isFalse);
+ await tester.runAsync(() async {
+ expect(await controller.suggestAttendees('ada'), isEmpty);
+ });
+ await tester.tap(toggle);
+ await tester.pumpAndSettle();
+ await tester.runAsync(() async {
+ expect(
+ (await controller.suggestAttendees('ada')).single.email,
+ 'ada@example.test',
+ );
+ });
+ await tester.tap(find.byKey(const ValueKey('add-carddav-contacts')));
+ await tester.pumpAndSettle();
+ expect(
+ find.byKey(const ValueKey('carddav-contacts-label')),
+ findsOneWidget,
+ );
+ expect(find.byType(SwitchListTile), findsNothing);
+ expect(
+ find.text('Read-only contacts for attendee suggestions'),
+ findsWidgets,
+ );
+ final boundary = tester.renderObject(
+ find.byType(RepaintBoundary).first,
+ );
+ final picture = await boundary.toImage();
+ try {
+ final bytes = await picture.toByteData(format: ui.ImageByteFormat.png);
+ await File('$output/contacts-read-only-settings.png')
+ .writeAsBytes(bytes!.buffer.asUint8List());
+ } finally {
+ picture.dispose();
+ }
+ await tester.tap(find.text('Cancel').last);
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ await tester.pumpWidget(const SizedBox.shrink());
+ },
+ skip: output == null,
+ );
+}
diff --git a/integration_test/desktop_registration_setup_test.dart b/integration_test/desktop_registration_setup_test.dart
index b28c948a..c242d7a4 100644
--- a/integration_test/desktop_registration_setup_test.dart
+++ b/integration_test/desktop_registration_setup_test.dart
@@ -1,6 +1,7 @@
import 'package:busymax/l10n/generated/app_localizations.dart';
import 'package:busymax/src/l10n/registration_setup_content.dart';
import 'package:busymax/src/providers/busy_provider.dart';
+
import 'dart:io';
import 'package:busymax/main_linux.dart' as app;
@@ -20,370 +21,388 @@ import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final output = Platform.environment['BUSYMAX_ACCEPTANCE_ROOT'];
- testWidgets('Settings owns desktop registration forms, guides and error states', (
- tester,
- ) async {
- expect(Platform.isLinux, isTrue);
- expect(Platform.environment['XDG_DATA_HOME'], '$output/data');
- await tester.runAsync(() => app.main(const []));
- await tester.pumpAndSettle();
- final container = ProviderScope.containerOf(
- tester.element(find.byType(ScheduleWorkspace)),
- );
- expect(await container.read(accountsStreamProvider.future), isEmpty);
- final settings = container.read(appSettingsControllerProvider.notifier);
- await tester.runAsync(
- () => settings.setThemeModePreference(BusyMaxThemeModePreference.light),
- );
- await tester.pumpAndSettle();
- await tester.tap(find.text('Add account'));
- await tester.pumpAndSettle();
- expect(find.byType(SettingsScreen), findsOneWidget);
- final staging = container.read(registrationStagingProvider);
- final picker = _Picker();
- final previousPicker = FileSelectorPlatform.instance;
- FileSelectorPlatform.instance = picker;
- addTearDown(() => FileSelectorPlatform.instance = previousPicker);
- final file = File('$output/desktop.json');
- await file.writeAsString(
- await File('test/fixtures/oauth/desktop_synthetic.json').readAsString(),
- );
- picker.selection = XFile(file.path);
- await tester.tap(find.text('Add Google account'));
- await tester.pumpAndSettle();
- await _capture(tester, '$output/google-methods.png');
- expect(tester.widget(_key('busymax')).onPressed, isNull);
- expect(_key('shared-unavailable'), findsOneWidget);
- await _back(tester);
- expect(_key('methods-dialog'), findsOneWidget);
- expect(find.byType(SettingsScreen), findsOneWidget);
- await tester.tap(_key('workspace'));
- await tester.pumpAndSettle();
- await _capture(tester, '$output/workspace-empty.png');
- await tester.tap(_key('import'));
- await tester.pumpAndSettle();
- await _capture(tester, '$output/workspace-imported.png');
- await tester.tap(_key('guide'));
- await tester.pumpAndSettle();
- await _inspectInstructions(
- tester,
- BusyProvider.google,
- DesktopConnectionMethod.googleWorkspace,
- '$output/workspace',
- );
- await _back(tester);
- expect(_key('summary'), findsOneWidget);
- await _back(tester);
- await tester.tap(_key('custom'));
- await tester.pumpAndSettle();
- // Both ownership pages share the same transactional importer and selection.
- expect(_key('summary'), findsOneWidget);
- await tester.tap(find.text('Cancel'));
- await tester.pumpAndSettle();
- await tester.tap(find.text('Add Google account'));
- await tester.pumpAndSettle();
- await tester.tap(_key('custom'));
- await tester.pumpAndSettle();
- await _capture(tester, '$output/google-empty.png');
- await tester.tap(_key('import'));
- await tester.pumpAndSettle();
- expect(_key('summary'), findsOneWidget);
- await _capture(tester, '$output/google-imported.png');
- picker.selection = null;
- await tester.tap(_key('import'));
- await tester.pumpAndSettle();
- expect(_key('summary'), findsOneWidget);
- picker.selection = XFile(file.path);
- await file.writeAsString('{"web":{}}');
- await tester.tap(_key('import'));
- await tester.pumpAndSettle();
- expect(_key('error'), findsOneWidget);
- expect(_key('summary'), findsOneWidget);
- await _capture(tester, '$output/google-invalid-replacement.png');
- await tester.tap(_key('guide'));
- await tester.pumpAndSettle();
- expect(_key('instructions-dialog'), findsOneWidget);
- expect(_key('back'), findsOneWidget);
- await _capture(tester, '$output/google-guide.png');
- await _inspectInstructions(
- tester,
- BusyProvider.google,
- DesktopConnectionMethod.custom,
- '$output/google',
- );
- expect(_key('authorize').hitTestable(), findsNothing);
- await tester.ensureVisible(_key('copy-all'));
- await tester.pumpAndSettle();
- await _capture(tester, '$output/google-guide-scrolled.png');
- await tester.ensureVisible(find.text('Open Google Cloud Console'));
- await tester.pumpAndSettle();
- // Native Linux launcher returns an error string; exercise its real exception path.
- const channel = BasicMessageChannel