diff --git a/.github/workflows/bridge-bindings.yml b/.github/workflows/bridge-bindings.yml
index 5359e72eb0..740aa64603 100644
--- a/.github/workflows/bridge-bindings.yml
+++ b/.github/workflows/bridge-bindings.yml
@@ -2,9 +2,20 @@ name: Rust bridge validation
on:
workflow_dispatch:
+ inputs:
+ allow_generated_drift:
+ description: Upload regenerated bindings without failing the manual run
+ required: false
+ default: false
+ type: boolean
push:
paths:
- ".github/workflows/bridge-bindings.yml"
+ - ".gitmodules"
+ - "rust/Cargo.toml"
+ - "rust/Cargo.lock"
+ - "rust/src/**"
+ - "rustpush"
- "rust/src/api/**"
- "flutter_rust_bridge.yaml"
- "lib/src/rust/**"
@@ -12,6 +23,11 @@ on:
pull_request:
paths:
- ".github/workflows/bridge-bindings.yml"
+ - ".gitmodules"
+ - "rust/Cargo.toml"
+ - "rust/Cargo.lock"
+ - "rust/src/**"
+ - "rustpush"
- "rust/src/api/**"
- "flutter_rust_bridge.yaml"
- "lib/src/rust/**"
@@ -19,15 +35,23 @@ on:
jobs:
bindings:
- runs-on: ubuntu-latest
+ # Generated bindings must be reproducible, so pin the image instead of
+ # letting ubuntu-latest drift the toolchain under the drift check.
+ runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
with:
submodules: recursive
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
+ - name: Set up Flutter for bridge generation
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: "3.44.8"
+ channel: stable
+
- name: Install native build tools
run: |
sudo apt-get update
@@ -54,5 +78,76 @@ jobs:
cp rustpush/certs/legacy-fairplay/fairplay.crt "$destination/$name.crt"
done
+ - name: Install pinned Flutter Rust Bridge generator
+ shell: bash
+ run: |
+ archive="${RUNNER_TEMP}/flutter_rust_bridge_codegen.tgz"
+ url="https://github.com/fzyzcjy/flutter_rust_bridge/releases/download/v2.3.0/flutter_rust_bridge_codegen-x86_64-unknown-linux-gnu-v2.3.0.tgz"
+ curl --fail --location --silent --show-error "$url" --output "$archive"
+ echo "bb3a5e3d1f17a543e73f8dfff886e883928b95e3a9d19ca5268da40952d80cee $archive" | sha256sum --check --strict
+ mkdir -p "${RUNNER_TEMP}/frb-codegen"
+ tar -xzf "$archive" -C "${RUNNER_TEMP}/frb-codegen"
+ echo "${RUNNER_TEMP}/frb-codegen" >> "$GITHUB_PATH"
+
+ - name: Regenerate committed bridge bindings
+ shell: bash
+ run: flutter_rust_bridge_codegen generate --config-file flutter_rust_bridge.yaml
+
+ - name: Normalize generated SSE implementations
+ shell: pwsh
+ run: |
+ ./tooling/frb/guard_generated_sse_impls.ps1 -Mode Deduplicate -ExpectedRemovalCount 6
+ ./tooling/frb/guard_generated_sse_impls.ps1 -Mode Verify
+
+ - name: Upload regenerated bridge bindings
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: regenerated-rust-bridge-bindings
+ if-no-files-found: error
+ retention-days: 7
+ path: |
+ lib/src/rust/api/api.dart
+ lib/src/rust/frb_generated.dart
+ lib/src/rust/frb_generated.io.dart
+ lib/src/rust/frb_generated.web.dart
+ rust/src/frb_generated.rs
+ rust/src/frb_generated.io.rs
+ rust/src/frb_generated.web.rs
+
+ - name: Verify committed bridge bindings are reproducible
+ if: github.event_name != 'workflow_dispatch' || inputs.allow_generated_drift != true
+ shell: bash
+ run: |
+ git diff --exit-code -- \
+ lib/src/rust/api/api.dart \
+ lib/src/rust/frb_generated.dart \
+ lib/src/rust/frb_generated.io.dart \
+ lib/src/rust/frb_generated.web.dart \
+ rust/src/frb_generated.rs \
+ rust/src/frb_generated.io.rs \
+ rust/src/frb_generated.web.rs
+
- name: Compile committed Rust bridge
run: cargo check --manifest-path rust/Cargo.toml --lib --message-format short
+
+ # cargo check --lib does not even compile #[cfg(test)] code, so until now
+ # no Rust test in this repository could fail a build. That covered the
+ # canonical converter, the DTO parsers, the protected store, and the
+ # keyed identity hasher. A suite that cannot fail a build is not a gate.
+ - name: Run Rust tests
+ run: cargo test --manifest-path rust/Cargo.toml --lib
+
+ # Tests inside the rustpush submodule are not executed when rustpush is
+ # compiled only as a dependency of the bridge crate. Keep its protocol,
+ # concurrency, and log-redaction tests as an independent gate. Match the
+ # production bridge's non-macOS Anisette provider feature so icloud_auth
+ # exposes the same provider types that the application compiles against.
+ - name: Run rustpush tests
+ run: cargo test --manifest-path rustpush/Cargo.toml --lib --features remote-anisette-v3
+
+ # The protector harness is a separate crate and is deliberately
+ # dependency-light so these security invariants can run on hosts where the
+ # full application crate cannot build.
+ - name: Run the Cloud Sync protector harness
+ run: cargo test --manifest-path rust/cloud_sync_protector_harness/Cargo.toml
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fad9677085..edaca8dbaa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -3,7 +3,10 @@ name: Build
jobs:
build:
name: OpenBubbles APK
- runs-on: ubuntu-latest
+ # Pinned rather than ubuntu-latest so the image cannot drift underneath the
+ # gates. ubuntu-latest moved to an image without PowerShell, which silently
+ # broke the protected-data logging scan below.
+ runs-on: ubuntu-24.04
steps:
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
@@ -15,23 +18,21 @@ jobs:
large-packages: true
swap-storage: true
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
with:
submodules: recursive
- name: Set up Rust
- uses: actions-rs/toolchain@v1
- with:
- toolchain: stable
+ uses: dtolnay/rust-toolchain@stable
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- flutter-version: 3.24.0
+ flutter-version: 3.44.8
- name: Set up Java
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'
@@ -64,28 +65,361 @@ jobs:
cp rustpush/certs/legacy-fairplay/fairplay.crt rustpush/certs/fairplay/$name.crt
done
- - name: Run focused message helper tests
- run: flutter test test/helpers/message_helper_test.dart
+ # The Dart test host loads the ObjectBox C library from the system loader
+ # path, and the runner image does not ship one. Without this the Cloud
+ # Sync suite fails 63 ObjectBox-backed tests with a dynamic-library error
+ # that names nothing about the real cause. The version must track the
+ # objectbox trio pinned in pubspec.yaml, and the archive is hash-checked
+ # because the upstream download script is unpinned.
+ - name: Install ObjectBox C library
+ env:
+ OBJECTBOX_VERSION: 5.3.2
+ OBJECTBOX_SHA256: 6dbb5450c36dd11ee9074f16ecc61e79b45ff43c2082934601f3166b39c8a613
+ run: |
+ set -euo pipefail
+ pinned="$(grep -oP '^\s{2}objectbox:\s*\K\d+\.\d+\.\d+' pubspec.yaml)"
+ if [ "$pinned" != "$OBJECTBOX_VERSION" ]; then
+ echo "pubspec pins objectbox $pinned but this step installs $OBJECTBOX_VERSION" >&2
+ exit 1
+ fi
+ archive="objectbox-linux-x64.tar.gz"
+ curl -fsSL -o "$archive" \
+ "https://github.com/objectbox/objectbox-c/releases/download/v${OBJECTBOX_VERSION}/${archive}"
+ echo "${OBJECTBOX_SHA256} ${archive}" | sha256sum --check --strict
+ mkdir -p "$RUNNER_TEMP/objectbox"
+ tar -xzf "$archive" -C "$RUNNER_TEMP/objectbox"
+ library="$(find "$RUNNER_TEMP/objectbox" -name libobjectbox.so -print -quit)"
+ if [ -z "$library" ]; then
+ echo "libobjectbox.so was not present in $archive" >&2
+ exit 1
+ fi
+ echo "LD_LIBRARY_PATH=$(dirname "$library")${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV"
+
+ # Deliberately the whole suite rather than an enumerated list. The list
+ # had drifted: eleven test files were never executed by any workflow,
+ # including the only coverage for the attachment-GUID parser and the
+ # reaction-type mapper. A test that cannot fail a build is not a gate.
+ - name: Run the Dart test suite
+ run: flutter test
- - name: Run bounded memory cache tests
+ # This gate is a .ps1 shared with local Windows runs, so install pwsh
+ # rather than forking the scan logic into a second implementation that
+ # could drift. Guarded so a runner image that already ships PowerShell
+ # costs nothing.
+ - name: Ensure PowerShell is available
+ run: |
+ set -euo pipefail
+ if command -v pwsh >/dev/null 2>&1; then
+ pwsh --version
+ exit 0
+ fi
+ sudo snap install powershell --classic
+ pwsh --version
+
+ - name: Scan Rust diagnostics for protected-data logging
+ shell: pwsh
run: >-
- flutter test
- test/helpers/memory/bounded_byte_cache_test.dart
- test/helpers/memory/bounded_lru_map_test.dart
+ ./tooling/cloud_sync/check_sensitive_rust_logs.ps1
+ -RepositoryRoot $env:GITHUB_WORKSPACE
+
+ # android/.gitignore excludes /gradlew and gradle-wrapper.jar, and upstream
+ # tracks neither, so ./gradlew does not exist in a fresh checkout and this
+ # step exited 127. Only gradle-wrapper.properties is tracked, so install
+ # exactly the distribution it pins and drive Gradle directly. The version
+ # is cross-checked against that file so the two cannot drift, and the
+ # archive is hash-verified against Gradle's published checksum.
+ - name: Install pinned Gradle
+ env:
+ GRADLE_VERSION: 8.9
+ GRADLE_SHA256: d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
+ run: |
+ set -euo pipefail
+ pinned="$(sed -n 's|^distributionUrl=.*/gradle-\([0-9.]*\)-bin\.zip$|\1|p' \
+ android/gradle/wrapper/gradle-wrapper.properties)"
+ if [ "$pinned" != "$GRADLE_VERSION" ]; then
+ echo "wrapper pins Gradle $pinned but this step installs $GRADLE_VERSION" >&2
+ exit 1
+ fi
+ archive="gradle-${GRADLE_VERSION}-bin.zip"
+ curl -fsSL -o "$archive" \
+ "https://services.gradle.org/distributions/${archive}"
+ echo "${GRADLE_SHA256} ${archive}" | sha256sum --check --strict
+ unzip -q "$archive" -d "$RUNNER_TEMP/gradle"
+ echo "$RUNNER_TEMP/gradle/gradle-${GRADLE_VERSION}/bin" >> "$GITHUB_PATH"
+
+ # Same vendored-CargoKit fix as the sampler job below. Without it the
+ # alpha APKs uploaded here install and hang on launch.
+ - name: Patch vendored CargoKit for the Flutter Kotlin plugin
+ run: |
+ set -euo pipefail
+ flutter pub get
+ bash ./tooling/android/patch_pub_cache_cargokit.sh
+
+ - name: Set up Node for FaceTime WebRTC replay tests
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Run FaceTime WebRTC diagnostic replay tests
+ run: node --test tooling/facetime/web_rtc_diagnostic_bootstrap.test.mjs
+
+ # Slower than it used to be: CargoKit now actually applies, so this
+ # compile also builds the Rust bridge and vendored OpenSSL. That cost is
+ # the price of the APKs below containing a native library at all, since
+ # before the fix they were uploaded without one.
+ - name: Run Android cold-start delivery tests
+ working-directory: android
+ run: gradle --no-daemon :app:testAlphaDebugUnitTest :app:compileAlphaDebugKotlin
- # First run is expected to fail until ffmpeg_kit_flutter_new is fixed.
- name: Build Alpha Profile APK
- run: flutter build apk --flavor alpha --profile --target-platform android-arm64
+ env:
+ REGISTRATION_RELAY_ACCESS_TOKEN: ${{ secrets.REGISTRATION_RELAY_ACCESS_TOKEN }}
+ run: >-
+ flutter build apk --flavor alpha --profile --target-platform android-arm64
+ "--dart-define=OPENBUBBLES_REGISTRATION_RELAY_ACCESS_TOKEN=$REGISTRATION_RELAY_ACCESS_TOKEN"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v7
with:
name: Alpha Profile APK
path: build/app/outputs/flutter-apk/app-alpha-profile.apk
- name: Build Alpha Debug APK
- run: flutter build apk --flavor alpha --debug --target-platform android-arm64
+ env:
+ REGISTRATION_RELAY_ACCESS_TOKEN: ${{ secrets.REGISTRATION_RELAY_ACCESS_TOKEN }}
+ run: >-
+ flutter build apk --flavor alpha --debug --target-platform android-arm64
+ "--dart-define=OPENBUBBLES_REGISTRATION_RELAY_ACCESS_TOKEN=$REGISTRATION_RELAY_ACCESS_TOKEN"
+
+ - name: Verify the alpha APKs carry all native libraries
+ run: |
+ set -euo pipefail
+ for apk in \
+ build/app/outputs/flutter-apk/app-alpha-profile.apk \
+ build/app/outputs/flutter-apk/app-alpha-debug.apk; do
+ for entry in \
+ lib/arm64-v8a/libflutter.so \
+ lib/arm64-v8a/librust_lib_bluebubbles.so \
+ lib/arm64-v8a/libirondash_engine_context_native.so \
+ lib/arm64-v8a/libsuper_native_extensions.so; do
+ if ! unzip -l "$apk" | grep -q "$entry"; then
+ echo "$apk is missing $entry" >&2
+ unzip -l "$apk" | grep 'lib/' >&2 || true
+ exit 1
+ fi
+ done
+ done
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v7
with:
name: Alpha Debug APK
path: build/app/outputs/flutter-apk/app-alpha-debug.apk
+
+ # Separate job so the developer sampler APK is not queued behind the full
+ # test suite and the two alpha APK builds, each of which now pays its own
+ # CargoKit Rust and vendored-OpenSSL compile. Setup mirrors the APK job above,
+ # including the Fairplay fixtures the Rust build needs.
+ sampler-apk:
+ name: Beta Sampler APK
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Free Disk Space (Ubuntu)
+ uses: jlumbroso/free-disk-space@main
+ with:
+ tool-cache: true
+ android: true
+ dotnet: true
+ haskell: true
+ large-packages: true
+ swap-storage: true
+
+ - uses: actions/checkout@v7
+ with:
+ submodules: recursive
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ flutter-version: 3.44.8
+
+ - name: Set up Java
+ uses: actions/setup-java@v5
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+
+ - name: Setup Android SDK
+ uses: amyu/setup-android@v5
+
+ - name: Install Protobuf compiler
+ run: sudo apt-get install -y protobuf-compiler
+
+ - name: Set up fake Fairplay keys
+ run: |
+ mkdir -p rustpush/certs/fairplay
+
+ cert_names=(
+ "4056631661436364584235346952193"
+ "4056631661436364584235346952194"
+ "4056631661436364584235346952195"
+ "4056631661436364584235346952196"
+ "4056631661436364584235346952197"
+ "4056631661436364584235346952198"
+ "4056631661436364584235346952199"
+ "4056631661436364584235346952200"
+ "4056631661436364584235346952201"
+ "4056631661436364584235346952208"
+ )
+
+ for name in "${cert_names[@]}"; do
+ cp rustpush/certs/legacy-fairplay/fairplay.pem rustpush/certs/fairplay/$name.pem
+ cp rustpush/certs/legacy-fairplay/fairplay.crt rustpush/certs/fairplay/$name.crt
+ done
+
+ # irondash_engine_context and super_native_extensions each vendor their
+ # own CargoKit, and neither can see Flutter 3.44.8's Kotlin plugin, so
+ # their Rust is never built. The resulting APK installs and then hangs on
+ # launch: the missing libirondash_engine_context_native.so throws
+ # UnsatisfiedLinkError from a static initializer inside
+ # GeneratedPluginRegistrant.registerWith, which aborts registration for
+ # every other plugin, so path_provider has no channel and the app dies
+ # during startup behind an error that names none of this. Those copies
+ # live in the pub cache and cannot be fixed by a committed edit.
+ - name: Patch vendored CargoKit for the Flutter Kotlin plugin
+ run: |
+ set -euo pipefail
+ flutter pub get
+ bash ./tooling/android/patch_pub_cache_cargokit.sh
+
+ # Developer-only build for the first live read-only Cloud Sync V2 run.
+ # The beta flavour is a separate applicationId, so it installs alongside
+ # alpha without touching its database. Debug rather than release because
+ # the beta release config needs a signing keystore, and because the shadow
+ # report has no in-app export and must be pulled with adb run-as, which a
+ # release build does not permit.
+ - name: Build Beta Debug APK with the Cloud Sync V2 sampler
+ env:
+ REGISTRATION_RELAY_ACCESS_TOKEN: ${{ secrets.REGISTRATION_RELAY_ACCESS_TOKEN }}
+ run: >-
+ flutter build apk --flavor beta --debug --target-platform android-arm64
+ --dart-define=OPENBUBBLES_CLOUD_SYNC_V2_SAMPLER=true
+ --dart-define=OPENBUBBLES_CLOUD_SYNC_V2_SEMANTIC_PULL=true
+ --dart-define=OPENBUBBLES_BUILD_COMMIT=${{ github.sha }}
+ "--dart-define=OPENBUBBLES_REGISTRATION_RELAY_ACCESS_TOKEN=$REGISTRATION_RELAY_ACCESS_TOKEN"
+
+ - name: Verify the beta APK carries the Rust bridge
+ run: |
+ set -euo pipefail
+ apk=build/app/outputs/flutter-apk/app-beta-debug.apk
+ # CargoKit silently skipped the Rust build on Flutter 3.44.8 until the
+ # plugin-detection fix, producing a package that installs and cannot
+ # work. Fail here rather than ship that again.
+ # This is the complete set of CargoKit-built libraries the app loads,
+ # derived from GeneratedPluginRegistrant plus the app's own bridge,
+ # not from whichever one happened to crash last. A missing entry
+ # fails no build: it yields an APK that installs cleanly and dies on
+ # launch, because the loadLibrary throw happens inside
+ # GeneratedPluginRegistrant.registerWith and takes every other plugin
+ # down with it. Only an explicit assertion catches that.
+ for entry in \
+ lib/arm64-v8a/libflutter.so \
+ lib/arm64-v8a/librust_lib_bluebubbles.so \
+ lib/arm64-v8a/libirondash_engine_context_native.so \
+ lib/arm64-v8a/libsuper_native_extensions.so; do
+ if ! unzip -l "$apk" | grep -q "$entry"; then
+ echo "beta APK is missing $entry" >&2
+ unzip -l "$apk" | grep 'lib/' >&2 || true
+ exit 1
+ fi
+ done
+ unzip -l "$apk" | grep 'lib/arm64-v8a/'
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: Beta Debug APK (Cloud Sync sampler)
+ path: build/app/outputs/flutter-apk/app-beta-debug.apk
+
+ # Separate applicationId for bounded, local-only protocol evidence. The
+ # audited outbound path remains compile-time absent until a remote-
+ # absence proof can prevent duplicate logical messages.
+ - name: Build developer-only CloudKit V2 Evidence Canary APK
+ env:
+ REGISTRATION_RELAY_ACCESS_TOKEN: ${{ secrets.REGISTRATION_RELAY_ACCESS_TOKEN }}
+ run: >-
+ flutter build apk --flavor canary --debug --target-platform android-arm64
+ --dart-define=OPENBUBBLES_CLOUD_SYNC_V2_SAMPLER=true
+ --dart-define=OPENBUBBLES_CLOUD_SYNC_V2_SEMANTIC_PULL=true
+ --dart-define=OPENBUBBLES_CLOUD_SYNC_V2_EVIDENCE=true
+ --dart-define=OPENBUBBLES_BUILD_COMMIT=${{ github.sha }}
+ "--dart-define=OPENBUBBLES_REGISTRATION_RELAY_ACCESS_TOKEN=$REGISTRATION_RELAY_ACCESS_TOKEN"
+
+ # Debug APKs otherwise use a runner-generated key that changes between
+ # jobs. A stable, Canary-only key permits safe in-place updates without
+ # sharing the production signing identity.
+ - name: Sign the evidence canary with its stable key
+ env:
+ CANARY_KEYSTORE_BASE64: ${{ secrets.CANARY_KEYSTORE_BASE64 }}
+ CANARY_KEYSTORE_PASSWORD: ${{ secrets.CANARY_KEYSTORE_PASSWORD }}
+ CANARY_KEY_PASSWORD: ${{ secrets.CANARY_KEY_PASSWORD }}
+ CANARY_KEY_ALIAS: ${{ secrets.CANARY_KEY_ALIAS }}
+ run: |
+ set -euo pipefail
+ apk=build/app/outputs/flutter-apk/app-canary-debug.apk
+ keystore="$RUNNER_TEMP/cloudkit-canary.p12"
+ signed_apk="$RUNNER_TEMP/app-canary-debug-signed.apk"
+ sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
+ apksigner="$(
+ find "$sdk_root/build-tools" -type f -name apksigner -print \
+ | sort -V \
+ | tail -n 1
+ )"
+
+ test -n "$CANARY_KEYSTORE_BASE64"
+ test -n "$CANARY_KEYSTORE_PASSWORD"
+ test -n "$CANARY_KEY_PASSWORD"
+ test -n "$CANARY_KEY_ALIAS"
+ test -x "$apksigner"
+ printf '%s' "$CANARY_KEYSTORE_BASE64" | base64 --decode > "$keystore"
+
+ "$apksigner" sign \
+ --ks "$keystore" \
+ --ks-key-alias "$CANARY_KEY_ALIAS" \
+ --ks-pass env:CANARY_KEYSTORE_PASSWORD \
+ --key-pass env:CANARY_KEY_PASSWORD \
+ --out "$signed_apk" \
+ "$apk"
+ mv "$signed_apk" "$apk"
+
+ actual_fingerprint="$(
+ "$apksigner" verify --print-certs "$apk" \
+ | sed -n 's/^Signer #1 certificate SHA-256 digest: //p'
+ )"
+ expected_fingerprint='0ea17c1b67581ca79660d33db45af0a36b71ea36a4cbafec5293d3ae80570d79'
+ if [[ "$actual_fingerprint" != "$expected_fingerprint" ]]; then
+ echo "Unexpected Canary signing certificate: $actual_fingerprint" >&2
+ exit 1
+ fi
+
+ - name: Verify the evidence canary APK carries all native libraries
+ run: |
+ set -euo pipefail
+ apk=build/app/outputs/flutter-apk/app-canary-debug.apk
+ for entry in \
+ lib/arm64-v8a/libflutter.so \
+ lib/arm64-v8a/librust_lib_bluebubbles.so \
+ lib/arm64-v8a/libirondash_engine_context_native.so \
+ lib/arm64-v8a/libsuper_native_extensions.so; do
+ if ! unzip -l "$apk" | grep -q "$entry"; then
+ echo "evidence canary APK is missing $entry" >&2
+ unzip -l "$apk" | grep 'lib/' >&2 || true
+ exit 1
+ fi
+ done
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: CloudKit V2 Evidence Canary APK
+ path: build/app/outputs/flutter-apk/app-canary-debug.apk
diff --git a/.github/workflows/windows-arm64-native-media.yml b/.github/workflows/windows-arm64-native-media.yml
new file mode 100644
index 0000000000..9c08ed10d4
--- /dev/null
+++ b/.github/workflows/windows-arm64-native-media.yml
@@ -0,0 +1,359 @@
+name: Windows native media provenance
+
+on:
+ pull_request:
+ paths:
+ - "packages/media_kit_libs_windows_video/**"
+ - "docs/WINDOWS_ARM64_NATIVE_MEDIA.md"
+ - "pubspec.yaml"
+ - "pubspec.lock"
+ - "tooling/windows/*.ps1"
+ - "windows/CMakeLists.txt"
+ - ".github/workflows/windows-arm64-native-media.yml"
+ push:
+ branches:
+ - rustpush
+ paths:
+ - "packages/media_kit_libs_windows_video/**"
+ - "docs/WINDOWS_ARM64_NATIVE_MEDIA.md"
+ - "pubspec.yaml"
+ - "pubspec.lock"
+ - "tooling/windows/*.ps1"
+ - "windows/CMakeLists.txt"
+ - ".github/workflows/windows-arm64-native-media.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: windows-native-media-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ static-verification:
+ name: Fail-closed scaffold tests
+ runs-on: windows-2025
+ timeout-minutes: 10
+ steps:
+ - name: Check out reviewed source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Test manifests, hashes, PE guards, and rejection paths
+ shell: pwsh
+ run: >-
+ ./packages/media_kit_libs_windows_video/tool/test_native_media_scaffold.ps1
+
+ - name: Verify application dependency and runner packaging contract
+ shell: pwsh
+ run: >-
+ ./tooling/windows/verify_native_media_integration.ps1
+
+ native-source-build:
+ name: Official ANGLE, runner, and runtime (${{ matrix.arch }})
+ if: github.event_name == 'workflow_dispatch'
+ needs: static-verification
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ runner: windows-2025
+ rust-target: x86_64-pc-windows-msvc
+ - arch: arm64
+ runner: windows-11-arm
+ rust-target: aarch64-pc-windows-msvc
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 420
+ steps:
+ - name: Check out reviewed source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ submodules: recursive
+
+ - name: Record native runner architecture
+ id: runner
+ shell: pwsh
+ run: |
+ $native = $env:PROCESSOR_ARCHITEW6432
+ if (-not $native) {
+ $native = $env:PROCESSOR_ARCHITECTURE
+ }
+ "native_architecture=$native" >> $env:GITHUB_OUTPUT
+ Get-ComputerInfo |
+ Select-Object WindowsProductName, WindowsVersion, OsArchitecture
+
+ - name: Build ANGLE from pinned official Google source
+ shell: pwsh
+ run: |
+ $package = Join-Path $env:GITHUB_WORKSPACE `
+ "packages/media_kit_libs_windows_video"
+ & (Join-Path $package "tool/build_official_angle.ps1") `
+ -Architecture "${{ matrix.arch }}" `
+ -WorkRoot (Join-Path $env:RUNNER_TEMP "official-angle-work") `
+ -OutputRoot (
+ Join-Path $package "windows/native/${{ matrix.arch }}/angle"
+ )
+
+ - name: Resolve pinned libmpv and verify all native inputs
+ shell: pwsh
+ run: |
+ $package = Join-Path $env:GITHUB_WORKSPACE `
+ "packages/media_kit_libs_windows_video"
+ $cache = Join-Path $env:RUNNER_TEMP "native-media"
+ & (Join-Path $package "tool/prepare_native_media.ps1") `
+ -Architecture "${{ matrix.arch }}" `
+ -AngleBundleRoot (
+ Join-Path $package "windows/native/${{ matrix.arch }}/angle"
+ ) `
+ -CacheRoot $cache `
+ -GeneratedCmakePath (Join-Path $cache "native-media.cmake") `
+ -ResolutionPath (Join-Path $cache "native-media-resolution.json")
+
+ - name: Load DLLs and verify required exports natively
+ shell: pwsh
+ run: |
+ $package = Join-Path $env:GITHUB_WORKSPACE `
+ "packages/media_kit_libs_windows_video"
+ & (Join-Path $package "tool/runtime_smoke.ps1") `
+ -ResolutionPath (
+ Join-Path $env:RUNNER_TEMP `
+ "native-media/native-media-resolution.json"
+ )
+
+ - name: Configure and compile the package registration target
+ shell: pwsh
+ run: |
+ $package = Join-Path $env:GITHUB_WORKSPACE `
+ "packages/media_kit_libs_windows_video"
+ $resolution = Get-Content -LiteralPath (
+ Join-Path $env:RUNNER_TEMP `
+ "native-media/native-media-resolution.json"
+ ) -Raw | ConvertFrom-Json
+ if ("${{ matrix.arch }}" -eq "arm64") {
+ & (Join-Path $package "tool/test_native_media_scaffold.ps1") `
+ -Arm64LibmpvArchive $resolution.libmpv.archive_path
+ }
+ else {
+ & (Join-Path $package "tool/test_native_media_scaffold.ps1") `
+ -X64LibmpvArchive $resolution.libmpv.archive_path
+ }
+
+ - name: Set up Flutter 3.44.8
+ id: flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
+ with:
+ channel: stable
+ flutter-version: 3.44.8
+ architecture: x64
+ cache: true
+
+ - name: Bootstrap native ARM64 Dart and Flutter engine
+ if: matrix.arch == 'arm64'
+ shell: pwsh
+ run: |
+ $flutterRoot = "${{ steps.flutter.outputs['cache-path'] }}"
+ Remove-Item -Force `
+ "$flutterRoot\bin\cache\engine-dart-sdk.stamp" `
+ -ErrorAction SilentlyContinue
+ & "$flutterRoot\bin\internal\update_dart_sdk.ps1"
+ $dartVersion = & "$flutterRoot\bin\dart.bat" --version 2>&1 |
+ Out-String
+ if ($dartVersion -notmatch "windows_arm64") {
+ throw "Expected native ARM64 Dart; got: $dartVersion"
+ }
+ & "$flutterRoot\bin\flutter.bat" precache --windows
+ if (-not (Test-Path (
+ Join-Path $flutterRoot `
+ "bin/cache/artifacts/engine/windows-arm64-release"
+ ))) {
+ throw "Flutter did not install its Windows ARM64 release engine."
+ }
+
+ - name: Set up Rust target
+ uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
+ with:
+ targets: ${{ matrix.rust-target }}
+
+ - name: Set up pinned Protocol Buffers compiler
+ shell: pwsh
+ run: |
+ $version = "29.5"
+ $expectedHash = "633d3e555fc97f0a1f55b4adb03256cd94b8059e51e7abbae98ff39e58a9dfa5"
+ $archive = Join-Path $env:RUNNER_TEMP "protoc-$version-win64.zip"
+ $destination = Join-Path $env:RUNNER_TOOL_CACHE "protoc\$version\x64"
+ Invoke-WebRequest `
+ "https://github.com/protocolbuffers/protobuf/releases/download/v$version/protoc-$version-win64.zip" `
+ -OutFile $archive
+ $actualHash = (
+ Get-FileHash $archive -Algorithm SHA256
+ ).Hash.ToLowerInvariant()
+ if ($actualHash -ne $expectedHash) {
+ throw "protoc archive checksum mismatch."
+ }
+ Expand-Archive -Path $archive -DestinationPath $destination -Force
+ "$destination\bin" |
+ Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
+ & "$destination\bin\protoc.exe" --version
+
+ - name: Set up test FairPlay certificates
+ shell: pwsh
+ run: |
+ $destination = "rustpush\certs\fairplay"
+ New-Item -ItemType Directory -Force -Path $destination | Out-Null
+ $certificateNames = @(
+ "4056631661436364584235346952193",
+ "4056631661436364584235346952194",
+ "4056631661436364584235346952195",
+ "4056631661436364584235346952196",
+ "4056631661436364584235346952197",
+ "4056631661436364584235346952198",
+ "4056631661436364584235346952199",
+ "4056631661436364584235346952200",
+ "4056631661436364584235346952201",
+ "4056631661436364584235346952208"
+ )
+ foreach ($name in $certificateNames) {
+ Copy-Item `
+ "rustpush\certs\legacy-fairplay\fairplay.pem" `
+ "$destination\$name.pem"
+ Copy-Item `
+ "rustpush\certs\legacy-fairplay\fairplay.crt" `
+ "$destination\$name.crt"
+ }
+
+ - name: Build and verify complete native Windows runner
+ shell: pwsh
+ env:
+ CARGO_BUILD_JOBS: "2"
+ CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "4"
+ CARGO_TERM_COLOR: never
+ run: |
+ $shortWorkspace = "D:\s"
+ New-Item -ItemType Directory -Force -Path $shortWorkspace |
+ Out-Null
+ robocopy "$env:GITHUB_WORKSPACE" $shortWorkspace /E `
+ /XD `
+ "$env:GITHUB_WORKSPACE\.git" `
+ "$env:GITHUB_WORKSPACE\.dart_tool" `
+ "$env:GITHUB_WORKSPACE\build" `
+ "$env:GITHUB_WORKSPACE\windows\flutter\ephemeral" `
+ /NFL /NDL /NJH /NJS /NP
+ if ($LASTEXITCODE -ge 8) {
+ throw "Failed to stage the short Windows build workspace."
+ }
+
+ $architecture = "${{ matrix.arch }}"
+ $package = Join-Path $env:GITHUB_WORKSPACE `
+ "packages/media_kit_libs_windows_video"
+ $angle = Join-Path $package `
+ "windows/native/$architecture/angle"
+ $resolution = Get-Content -LiteralPath (
+ Join-Path $env:RUNNER_TEMP `
+ "native-media/native-media-resolution.json"
+ ) -Raw | ConvertFrom-Json
+ & (Join-Path $shortWorkspace `
+ "tooling/windows/build_verified_native_media_runner.ps1") `
+ -Architecture $architecture `
+ -AngleBundleRoot $angle `
+ -LibmpvArchivePath $resolution.libmpv.archive_path `
+ -FlutterRoot "${{ steps.flutter.outputs['cache-path'] }}" `
+ -RepoRoot $shortWorkspace `
+ -Configuration release
+ if ($LASTEXITCODE -ne 0) {
+ throw "Verified $architecture runner build failed."
+ }
+
+ - name: Package attested ANGLE engineering evidence
+ id: package
+ shell: pwsh
+ run: |
+ $architecture = "${{ matrix.arch }}"
+ $package = Join-Path $env:GITHUB_WORKSPACE `
+ "packages/media_kit_libs_windows_video"
+ $angle = Join-Path $package "windows/native/$architecture/angle"
+ $resolutionPath = Join-Path $env:RUNNER_TEMP `
+ "native-media/native-media-resolution.json"
+ $resolution = Get-Content -LiteralPath $resolutionPath -Raw |
+ ConvertFrom-Json
+ $stage = Join-Path $env:RUNNER_TEMP `
+ "openbubbles-native-media-$architecture"
+ New-Item -ItemType Directory -Path $stage -Force | Out-Null
+ Copy-Item -LiteralPath $angle -Destination (
+ Join-Path $stage "angle"
+ ) -Recurse
+ Copy-Item -LiteralPath (
+ Join-Path $package "provenance/native-dependencies.json"
+ ) -Destination $stage
+ Copy-Item -LiteralPath (
+ Join-Path $package "THIRD_PARTY_NOTICES.md"
+ ) -Destination $stage
+
+ $evidence = [ordered]@{
+ schema_version = 1
+ architecture = $architecture
+ runner_native_architecture = "${{ steps.runner.outputs.native_architecture }}"
+ source_commit = "${{ github.sha }}"
+ angle_manifest_sha256 = $resolution.angle.manifest_sha256
+ angle_source_commit = $resolution.angle.source_commit
+ depot_tools_commit = $resolution.angle.depot_tools_commit
+ libmpv_archive_sha256 = $resolution.libmpv.archive_sha256
+ libmpv_builder_commit = $resolution.libmpv.builder_commit
+ mpv_commit = $resolution.libmpv.mpv_commit
+ runtime_load_and_export_smoke = "passed"
+ libmpv_binary_in_artifact = $false
+ redistribution_status = $resolution.libmpv.redistribution_status
+ }
+ $evidence | ConvertTo-Json -Depth 8 |
+ Set-Content -LiteralPath (
+ Join-Path $stage "native-verification.json"
+ ) -Encoding utf8NoBOM
+
+ $fileInventory = @(
+ Get-ChildItem -LiteralPath $stage -File -Recurse |
+ Sort-Object FullName |
+ ForEach-Object {
+ [pscustomobject]@{
+ relative_path = $_.FullName.Substring(
+ $stage.Length + 1
+ ).Replace('\', '/')
+ sha256 = (
+ Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256
+ ).Hash.ToLowerInvariant()
+ }
+ }
+ )
+ $fileInventory | ConvertTo-Json -Depth 5 |
+ Set-Content -LiteralPath (
+ Join-Path $stage "artifact-file-inventory.json"
+ ) -Encoding utf8NoBOM
+
+ $archive = Join-Path $env:RUNNER_TEMP `
+ "openbubbles-official-angle-$architecture.zip"
+ Compress-Archive -Path (Join-Path $stage "*") -DestinationPath $archive
+ $archiveHash = (
+ Get-FileHash -LiteralPath $archive -Algorithm SHA256
+ ).Hash.ToLowerInvariant()
+ Set-Content -LiteralPath "$archive.sha256" `
+ -Value "$archiveHash $([IO.Path]::GetFileName($archive))" `
+ -Encoding ascii
+ "archive=$archive" >> $env:GITHUB_OUTPUT
+
+ - name: Attest official-source ANGLE artifact
+ uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be
+ with:
+ subject-path: ${{ steps.package.outputs.archive }}
+
+ - name: Upload short-lived engineering artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: official-angle-${{ matrix.arch }}-${{ github.sha }}
+ path: |
+ ${{ steps.package.outputs.archive }}
+ ${{ steps.package.outputs.archive }}.sha256
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 7396509840..dbdd2c78b0 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -1,4 +1,4 @@
-name: Windows builds
+name: Windows validation
on:
workflow_dispatch:
@@ -22,26 +22,23 @@ jobs:
matrix:
include:
- arch: x64
- # Flutter 3.24 does not recognize the Visual Studio 2026 image
- # currently behind windows-latest. Pin VS 2022 until the app's
- # Flutter toolchain is upgraded.
+ # Keep x64 and ARM64 on one Flutter/Dart release so generated
+ # bindings and package resolution are architecture-neutral. The
+ # stable VS 2022 image also avoids runner-image drift during the
+ # parity rollout.
runner: windows-2022
- flutter-version: 3.24.0
- rust-target: x86_64-pc-windows-msvc
- build-directory: x64
+ flutter-version: 3.44.8
experimental: false
- arch: arm64
runner: windows-11-arm
# Native Windows ARM64 Dart and engine artifacts are available
# starting with Flutter 3.44.
flutter-version: 3.44.8
- rust-target: aarch64-pc-windows-msvc
- build-directory: arm64
experimental: true
steps:
- name: Check out source
- uses: actions/checkout@v4
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
@@ -55,48 +52,43 @@ jobs:
throw "Expected a native ${{ matrix.arch }} runner, but received $osArchitecture."
}
- # Do not remove this guard merely to make CI green. A native ARM64
- # executable cannot load the x64 DLLs currently selected by these
- # packages. Re-audit all three dependencies before enabling the build.
- - name: Audit native ARM64 dependency compatibility
- if: matrix.arch == 'arm64'
- shell: pwsh
- run: |
- Write-Error @"
- Native Windows ARM64 packaging is blocked by the locked native dependencies:
-
- 1. objectbox_flutter_libs 4.0.3 selects ObjectBox C 4.0.2 using
- CMAKE_SYSTEM_PROCESSOR, but that release provides Windows x86 and
- x64 archives only. There is no objectbox-windows-ARM64.zip.
- 2. printing 5.13.4 hard-codes PDFIUM_ARCH=x64 and downloads an x64
- PDFium archive.
- 3. media_kit_libs_windows_video 1.0.10 hard-codes an x86_64 libmpv
- archive and an x64 ANGLE bundle.
-
- This job is experimental and allowed to fail. Keep it blocked until
- native ARM64 replacements exist and are runtime-tested.
- "@
- exit 1
-
- name: Set up Flutter
id: flutter
- uses: subosito/flutter-action@v2
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2
with:
channel: stable
flutter-version: ${{ matrix.flutter-version }}
- # Flutter's full Windows SDK archive is x64. On the ARM64 runner,
- # the next step replaces its Dart SDK and downloads ARM64 engines.
+ # Flutter publishes only an x64 Windows SDK archive; every entry in
+ # releases_windows.json reports dart_sdk_arch x64. On the ARM64 runner
+ # the next step swaps in the ARM64 Dart SDK and ARM64 engines.
architecture: x64
cache: true
+ # Uses $env:FLUTTER_ROOT rather than the action's cache-path output. The
+ # SDK lives one directory below cache-path, so the previous
+ # "$cachePath\bin\internal\update_dart_sdk.ps1" never resolved and this
+ # step failed with command-not-found before doing any work. The script
+ # itself does ship in 3.44.8.
- name: Bootstrap native ARM64 Dart and Flutter engine
if: matrix.arch == 'arm64'
shell: pwsh
run: |
- $flutterRoot = "${{ steps.flutter.outputs['cache-path'] }}"
+ $flutterRoot = "$env:FLUTTER_ROOT"
+ if (-not (Test-Path "$flutterRoot\bin\internal\update_dart_sdk.ps1")) {
+ throw "Flutter SDK layout changed: no bin\internal\update_dart_sdk.ps1 under $flutterRoot"
+ }
Remove-Item -Force "$flutterRoot\bin\cache\engine-dart-sdk.stamp" -ErrorAction SilentlyContinue
& "$flutterRoot\bin\internal\update_dart_sdk.ps1"
+ # flutter_tools.snapshot ships compiled for the archive's x64 Dart VM.
+ # Once the VM above is ARM64 that snapshot is rejected with "Snapshot
+ # not compatible with the current VM configuration", so every later
+ # flutter command fails and no ARM64 engine is ever fetched. Dropping
+ # the snapshot and its stamp makes the next flutter invocation
+ # recompile the tool against the ARM64 VM.
+ Remove-Item -Force "$flutterRoot\bin\cache\flutter_tools.snapshot" -ErrorAction SilentlyContinue
+ Remove-Item -Force "$flutterRoot\bin\cache\flutter_tools.stamp" -ErrorAction SilentlyContinue
+
$dartVersion = & "$flutterRoot\bin\dart.bat" --version 2>&1 | Out-String
Write-Host $dartVersion
if ($dartVersion -notmatch "windows_arm64") {
@@ -104,59 +96,20 @@ jobs:
}
& "$flutterRoot\bin\flutter.bat" precache --windows
- $engineDirectory = "$flutterRoot\bin\cache\artifacts\engine\windows-arm64-release"
- if (-not (Test-Path $engineDirectory)) {
- throw "Flutter did not install the native Windows ARM64 release engine."
- }
-
- - name: Set up Rust
- uses: dtolnay/rust-toolchain@stable
- with:
- targets: ${{ matrix.rust-target }}
-
- - name: Set up Protocol Buffers compiler
- shell: pwsh
- run: |
- # protobuf does not publish a native Windows ARM64 protoc archive.
- # The official win64 compiler is build-time tooling and runs under
- # Windows 11 ARM's x64 emulation without affecting app architecture.
- $version = "29.5"
- $expectedHash = "633d3e555fc97f0a1f55b4adb03256cd94b8059e51e7abbae98ff39e58a9dfa5"
- $archive = Join-Path $env:RUNNER_TEMP "protoc-$version-win64.zip"
- $destination = Join-Path $env:RUNNER_TOOL_CACHE "protoc\$version\x64"
- Invoke-WebRequest `
- "https://github.com/protocolbuffers/protobuf/releases/download/v$version/protoc-$version-win64.zip" `
- -OutFile $archive
-
- $actualHash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
- if ($actualHash -ne $expectedHash) {
- throw "protoc archive checksum mismatch: expected $expectedHash, got $actualHash"
- }
-
- Expand-Archive -Path $archive -DestinationPath $destination -Force
- "$destination\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- & "$destination\bin\protoc.exe" --version
- - name: Set up test FairPlay certificates
- shell: pwsh
- run: |
- $destination = "rustpush\certs\fairplay"
- New-Item -ItemType Directory -Force -Path $destination | Out-Null
- $certificateNames = @(
- "4056631661436364584235346952193",
- "4056631661436364584235346952194",
- "4056631661436364584235346952195",
- "4056631661436364584235346952196",
- "4056631661436364584235346952197",
- "4056631661436364584235346952198",
- "4056631661436364584235346952199",
- "4056631661436364584235346952200",
- "4056631661436364584235346952201",
- "4056631661436364584235346952208"
- )
- foreach ($name in $certificateNames) {
- Copy-Item "rustpush\certs\legacy-fairplay\fairplay.pem" "$destination\$name.pem"
- Copy-Item "rustpush\certs\legacy-fairplay\fairplay.crt" "$destination\$name.crt"
+ # precache fetches only the host debug engine, "windows-arm64".
+ # The profile and release engines download on demand during the build,
+ # so asserting windows-arm64-release here always fails. Verified by
+ # reproducing this exact sequence on an ARM64 host: precache produced
+ # windows-arm64 alongside the archive's x64 engines and no
+ # windows-arm64-release. What matters at this point is that Flutter
+ # resolved ARM64 artifacts at all rather than silently staying on x64.
+ $engineRoot = "$flutterRoot\bin\cache\artifacts\engine"
+ if (-not (Test-Path "$engineRoot\windows-arm64")) {
+ $present = (Get-ChildItem $engineRoot -Directory -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -like 'windows*' } |
+ Select-Object -ExpandProperty Name) -join ', '
+ throw "Flutter did not install a native Windows ARM64 engine. Present: $present"
}
- name: Resolve Flutter dependencies
@@ -166,58 +119,52 @@ jobs:
flutter doctor -v
flutter pub get
- - name: Build Windows release
+ - name: Verify architecture-aware native media integration
shell: pwsh
- env:
- # Keep the large Rust release link inside the standard hosted
- # runner's memory budget.
- CARGO_BUILD_JOBS: "2"
- CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "4"
- CARGO_TERM_COLOR: never
- CARGOKIT_VERBOSE: "1"
- run: |
- # OpenSSL's longest generated object path exceeds the legacy
- # 260-character compiler limit from GitHub's nested checkout path.
- # Build from a real short path and regenerate Flutter's absolute
- # plugin links there. A substituted drive breaks Cargokit's link
- # integrity checks because the links retain the original drive.
- $shortWorkspace = "D:\s"
- New-Item -ItemType Directory -Force -Path $shortWorkspace | Out-Null
- robocopy "$env:GITHUB_WORKSPACE" $shortWorkspace /E `
- /XD `
- "$env:GITHUB_WORKSPACE\.git" `
- "$env:GITHUB_WORKSPACE\.dart_tool" `
- "$env:GITHUB_WORKSPACE\build" `
- "$env:GITHUB_WORKSPACE\windows\flutter\ephemeral" `
- /NFL /NDL /NJH /NJS /NP
- if ($LASTEXITCODE -ge 8) {
- throw "Failed to stage the short Windows build workspace."
- }
-
- Set-Location $shortWorkspace
- flutter pub get
- flutter build windows --release
+ run: >-
+ ./tooling/windows/verify_native_media_integration.ps1
+ -RequireEphemeralSymlink
- - name: Package Windows release
+ - name: Run focused Windows quality gates
+ if: matrix.arch == 'x64'
shell: pwsh
run: |
- $bundle = "D:\s\build\windows\${{ matrix.build-directory }}\runner\Release"
- if (-not (Test-Path "$bundle\bluebubbles_app.exe")) {
- throw "Expected release executable was not produced at $bundle\bluebubbles_app.exe"
+ flutter test --no-pub `
+ test/helpers/memory/bounded_byte_cache_test.dart `
+ test/helpers/memory/bounded_lru_map_test.dart `
+ test/layouts/fullscreen_media/fullscreen_media_list_test.dart `
+ test/services/desktop_notification_actions_test.dart `
+ test/services/download_file_utils_test.dart
+
+ # Analyze the application surfaces exercised by this workflow rather
+ # than vendored Cargokit/example packages with independent pubspecs.
+ flutter analyze --no-pub --no-fatal-infos --no-fatal-warnings `
+ lib/app/layouts/fullscreen_media `
+ lib/app/layouts/settings/pages/desktop/desktop_panel.dart `
+ lib/services/backend/notifications `
+ lib/services/network/download_file_utils.dart `
+ lib/services/network/downloads_service.dart
+
+ - name: Enforce public native-binary release gate
+ shell: pwsh
+ run: |
+ $provenance = Get-Content -LiteralPath (
+ "packages/media_kit_libs_windows_video/" +
+ "provenance/native-dependencies.json"
+ ) -Raw | ConvertFrom-Json
+ $status = [string] (
+ $provenance.libmpv.license_mode_evidence.redistribution_status
+ )
+ if ($status -ne "blocked_pending_transitive_license_inventory") {
+ throw "Unreviewed native-media redistribution status: $status"
}
-
- New-Item -ItemType Directory -Force -Path dist | Out-Null
- $archive = "dist\OpenBubbles-windows-${{ matrix.arch }}.zip"
- Compress-Archive -Path "$bundle\*" -DestinationPath $archive -Force
- $hash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
- "$hash OpenBubbles-windows-${{ matrix.arch }}.zip" |
- Set-Content "$archive.sha256"
-
- - name: Upload Windows artifact
- uses: actions/upload-artifact@v4
- with:
- name: OpenBubbles-windows-${{ matrix.arch }}
- path: |
- dist/OpenBubbles-windows-${{ matrix.arch }}.zip
- dist/OpenBubbles-windows-${{ matrix.arch }}.zip.sha256
- if-no-files-found: error
+ if (-not (Test-Path -LiteralPath (
+ ".github/workflows/windows-arm64-native-media.yml"
+ ))) {
+ throw "The verified native Windows build workflow is missing."
+ }
+ Write-Host (
+ "Public Windows binary publishing remains blocked. Use the " +
+ "manual Windows native media provenance workflow for x64 and " +
+ "ARM64 engineering builds."
+ )
diff --git a/.gitignore b/.gitignore
index 7e848794a5..4348e746af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,7 @@
.pub-cache/
.pub/
/build/
+/build-*/
pubspeck.lock
# Web related
@@ -44,6 +45,8 @@ pubspeck.lock
.dart_tool/*
!.dart_tool/build
.dart_tool/build/entrypoint
+.dart_tool/build/asset_graph.json
+.dart_tool/build/lock/
.dart_tool/build/*/error_cache/**
.dart_tool/build/generated
!.dart_tool/build/generated/*/*.info
@@ -57,4 +60,7 @@ pubspeck.lock
native-lib/target/**
/rust/src/api/hw_testing.plist
-/rust/src/api/id_testing.plist
\ No newline at end of file
+/rust/src/api/id_testing.plist
+/rust/cloud_sync_protector_harness/target/
+/rust/target-cloudsync-check/
+/ios/Flutter/ephemeral/
diff --git a/.gitmodules b/.gitmodules
index 468942beac..2d7e8a0aec 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,6 +1,6 @@
[submodule "rustpush"]
path = rustpush
- url = https://github.com/OpenBubbles/rustpush.git
+ url = https://github.com/Xare123/rustpush.git
[submodule "telephony_plus"]
path = telephony_plus
url = https://github.com/OpenBubbles/telephony_plus.git
diff --git a/android/app/build.gradle b/android/app/build.gradle
index bec2bfc8ab..7dc90bc2cd 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -99,6 +99,13 @@ android {
resValue "string", "file_provider", "com.bluebubbles.messaging.beta.fileprovider"
applicationId "com.bluebubbles.messaging.beta"
}
+ canary {
+ dimension "app"
+ resValue "string", "app_name_en", "OpenBubbles (CK Canary)"
+ resValue "color", "ic_launcher_background", "#de8b49"
+ resValue "string", "file_provider", "com.bluebubbles.messaging.cloudkitcanary.fileprovider"
+ applicationId "com.bluebubbles.messaging.cloudkitcanary"
+ }
prod {
getIsDefault().set(true)
dimension "app"
@@ -120,6 +127,7 @@ android {
productFlavors.tanay.signingConfig signingConfigs.debug
productFlavors.alpha.signingConfig signingConfigs.debug
productFlavors.beta.signingConfig signingConfigs.release
+ productFlavors.canary.signingConfig signingConfigs.release
productFlavors.prod.signingConfig signingConfigs.release
productFlavors.alpha.signingConfig signingConfigs.release
minifyEnabled false
@@ -148,6 +156,8 @@ configurations.all {
}
dependencies {
+ testImplementation 'junit:junit:4.13.2'
+
// Android native functions
implementation "androidx.core:core-ktx:1.13.1"
implementation "androidx.sharetarget:sharetarget:1.2.0"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3f99ba3dd9..7b12b435d2 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,13 +9,6 @@
-
@@ -58,6 +51,7 @@
+
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/MainActivity.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/MainActivity.kt
index 00ff1915d8..ce03d6296e 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/MainActivity.kt
@@ -29,6 +29,8 @@ class MainActivity : FlutterFragmentActivity(), ComponentCallbacks2 {
var engine_ready = false
}
+ private var activityEngine: FlutterEngine? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -40,15 +42,22 @@ class MainActivity : FlutterFragmentActivity(), ComponentCallbacks2 {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
engine_ready = false
+ activityEngine = flutterEngine
engine = flutterEngine
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, Constants.methodChannel).setMethodCallHandler { call, result ->
if (call.method == "engine-done") {
Log.i("BBEngine", "Destroyed");
// this must be here in case another engine has been spawned in the meantime
- flutterEngine.destroy()
- if (engine == flutterEngine)
+ if (engine === flutterEngine) {
+ engine_ready = false
engine = null
+ APNService.onMainEngineUnavailable()
+ }
+ if (activityEngine === flutterEngine) {
+ activityEngine = null
+ }
+ flutterEngine.destroy()
}
MethodCallHandler().methodCallHandler(call, result, this)
}
@@ -58,7 +67,12 @@ class MainActivity : FlutterFragmentActivity(), ComponentCallbacks2 {
override fun onDestroy() {
Log.d(Constants.logTag, "BlueBubbles MainActivity is being destroyed")
- engine = null
+ if (engine === activityEngine) {
+ engine_ready = false
+ engine = null
+ APNService.onMainEngineUnavailable()
+ }
+ activityEngine = null
// If we are finishing "gracefully", the dart code would have started the foreground service.
// If we are finishing because the system is destroying the activity, we need to start the foreground service
@@ -121,4 +135,4 @@ class MainActivity : FlutterFragmentActivity(), ComponentCallbacks2 {
result.success(true)
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/DartWorker.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/DartWorker.kt
index e8130b58e7..009b35d3c1 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/DartWorker.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/DartWorker.kt
@@ -17,12 +17,11 @@ import com.google.common.util.concurrent.ListenableFuture
import com.google.gson.GsonBuilder
import com.google.gson.ToNumberPolicy
import com.google.gson.reflect.TypeToken
+import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
-import io.flutter.embedding.engine.loader.ApplicationInfoLoader
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
-import io.flutter.view.FlutterMain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@@ -49,12 +48,12 @@ class DartWorker(context: Context, workerParams: WorkerParameters): ListenableWo
/// Code idea taken from https://github.com/flutter/flutter/wiki/Experimental:-Reuse-FlutterEngine-across-screens
private suspend fun initNewEngine(applicationContext: Context) {
Log.d(Constants.logTag, "Ensuring Flutter is initialized before creating engine")
- // We use the deprecated class here anyways, the new one doesn't work correctly using the same code
- FlutterMain.startInitialization(applicationContext)
- FlutterMain.ensureInitializationComplete(applicationContext, null)
+ val flutterLoader = FlutterInjector.instance().flutterLoader()
+ flutterLoader.startInitialization(applicationContext)
+ flutterLoader.ensureInitializationComplete(applicationContext, null)
+ val appBundlePath = flutterLoader.findAppBundlePath()
Log.d(Constants.logTag, "Loading callback info")
- val info = ApplicationInfoLoader.load(applicationContext)
workerEngine = FlutterEngine(applicationContext)
currentJobs.set(0)
@@ -81,7 +80,7 @@ class DartWorker(context: Context, workerParams: WorkerParameters): ListenableWo
}
}
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(applicationContext.getSharedPreferences("FlutterSharedPreferences", 0).getLong("flutter.backgroundCallbackHandle", -1))
- val callback = DartExecutor.DartCallback(applicationContext.assets, info.flutterAssetsDir, callbackInfo)
+ val callback = DartExecutor.DartCallback(applicationContext.assets, appBundlePath, callbackInfo)
Log.d(Constants.logTag, "Executing Dart callback")
workerEngine!!.dartExecutor.executeDartCallback(callback)
@@ -207,4 +206,4 @@ class DartWorker(context: Context, workerParams: WorkerParameters): ListenableWo
.build()
return Futures.immediateFuture(ForegroundInfo(Constants.dartWorkerNotificationId, notification))
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt
index e759c517fa..22a1e243ba 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/MethodCallHandler.kt
@@ -24,6 +24,7 @@ import com.bluebubbles.messaging.services.notifications.NotificationChannelHandl
import com.bluebubbles.messaging.services.notifications.NotificationListenerPermissionRequestHandler
import com.bluebubbles.messaging.services.notifications.StartNotificationListenerHandler
import com.bluebubbles.messaging.services.notifications.UnifiedPushHandler
+import com.bluebubbles.messaging.services.rustpush.APNService
import com.bluebubbles.messaging.services.rustpush.NotifyNativeConfiguredHandler
import com.bluebubbles.messaging.services.rustpush.SIMInfoQuery
import com.bluebubbles.messaging.services.rustpush.SMSAuthGateway
@@ -54,6 +55,7 @@ import com.bluebubbles.messaging.services.system.GetZenMode
import com.bluebubbles.messaging.services.system.HeifDecoder
import com.bluebubbles.messaging.services.system.HeifEncoder
import com.bluebubbles.messaging.services.system.NativeSyncIsolateHandler
+import com.bluebubbles.messaging.services.system.NearbyFindMyAccessoryHandler
import com.bluebubbles.messaging.services.system.OpenSMSAppHandler
import com.bluebubbles.messaging.services.system.RecentContactsRequestHandler
import com.bluebubbles.messaging.services.system.ShizukuGrantPermissionHandler
@@ -138,12 +140,17 @@ class MethodCallHandler {
CircleProximitySessionHandler.tag -> CircleProximitySessionHandler().handleMethodCall(call, result, context)
EnableBTHandler.tag -> EnableBTHandler().handleMethodCall(call, result, context)
NativeSyncIsolateHandler.tag -> NativeSyncIsolateHandler().handleMethodCall(call, result, context)
+ NearbyFindMyAccessoryHandler.scanTag,
+ NearbyFindMyAccessoryHandler.playTag -> NearbyFindMyAccessoryHandler.instance.handleMethodCall(call, result, context)
SMSLessAuthGateway.tag -> SMSLessAuthGateway().handleMethodCall(call, result, context)
ShizukuGrantPermissionHandler.tag -> ShizukuGrantPermissionHandler().handleMethodCall(call, result, context)
ProvisionNative.tag -> ProvisionNative().handleMethodCall(call, result, context)
EAPAKAGateway.tag -> EAPAKAGateway().handleMethodCall(call, result, context)
KeystoreUnlockHandler.tag -> KeystoreUnlockHandler().handleMethodCall(call, result, context)
- "ready" -> { MainActivity.engine_ready = true }
+ "ready" -> {
+ MainActivity.engine_ready = true
+ APNService.onMainEngineReady()
+ }
else -> {
val error = "Could not find method call handler for ${call.method}!"
Log.d(Constants.logTag, error)
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt
index 028ebdc68c..5e964d8ef0 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebview.kt
@@ -8,9 +8,11 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
+import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
@@ -24,7 +26,60 @@ import java.io.File
@SuppressLint("SetJavaScriptEnabled")
class CachedWebview(context: Context, name: String?, desc: String, url: String) {
+ companion object {
+ private const val diagnosticTag = "FaceTimeDiag"
+
+ /**
+ * Return a JavaScript string literal for values supplied by the user.
+ *
+ * This deliberately follows JSON string escaping, which is also valid
+ * JavaScript, and additionally escapes the two Unicode line separators
+ * that are valid JSON but terminate a classic JavaScript string literal.
+ */
+ internal fun javascriptStringLiteral(value: String): String = buildString {
+ append('"')
+ var index = 0
+ while (index < value.length) {
+ val character = value[index]
+ when (character) {
+ '"' -> append("\\\"")
+ '\\' -> append("\\\\")
+ '\b' -> append("\\b")
+ '\u000C' -> append("\\f")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ '\u2028', '\u2029' -> append(
+ "\\u${character.code.toString(16).padStart(4, '0')}"
+ )
+ in '\u0000'..'\u001F' -> append(
+ "\\u${character.code.toString(16).padStart(4, '0')}"
+ )
+ in '\uD800'..'\uDBFF' -> {
+ val lowSurrogate = value.getOrNull(index + 1)
+ if (lowSurrogate != null && lowSurrogate in '\uDC00'..'\uDFFF') {
+ append(character)
+ append(lowSurrogate)
+ index++
+ } else {
+ append("\\u${character.code.toString(16).padStart(4, '0')}")
+ }
+ }
+ in '\uDC00'..'\uDFFF' -> append(
+ "\\u${character.code.toString(16).padStart(4, '0')}"
+ )
+ else -> append(character)
+ }
+ index++
+ }
+ append('"')
+ }
+ }
+
val webView = WebView(context)
+ private val applicationContext = context.applicationContext
+ private val callbackHandler = Handler(Looper.getMainLooper())
+ private var mirrorReadyRunnable: Runnable? = null
var mirrorReady = false
var mirrorReadyCall: (() -> Unit)? = null
@@ -36,8 +91,32 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
val deferredRequests = arrayListOf()
var deferredRequestsUpdated: () -> Unit = {}
+ fun cancelCallbacks() {
+ mirrorReadyRunnable?.let(callbackHandler::removeCallbacks)
+ mirrorReadyRunnable = null
+ mirrorReadyCall = null
+ deferredRequestsUpdated = {}
+ }
+
+ private fun safeResourceLabel(requestUrl: String?): String {
+ if (requestUrl == null) return "unknown"
+ return try {
+ val uri = android.net.Uri.parse(requestUrl)
+ val segment = uri.lastPathSegment.orEmpty()
+ when {
+ segment.endsWith(".js", ignoreCase = true) -> "script"
+ segment.endsWith(".css", ignoreCase = true) -> "style"
+ else -> "page-or-media"
+ }
+ } catch (_: Exception) {
+ "unparseable"
+ }
+ }
+
+ private fun diagnosticsEnabled(): Boolean = FaceTimeDiagnostics.isEnabled(applicationContext)
+
fun getScriptData(request: WebResourceRequest, client: OkHttpClient, name: String?, desc: String): String {
- Log.i("FT", "Getting script")
+ if (diagnosticsEnabled()) Log.i(diagnosticTag, "getting main.js")
// OKHTTP should handle caching for us
val okhttp = Request.Builder()
.method(request.method, null)
@@ -55,17 +134,172 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
}
val body = response.body() ?: throw Exception("Failed to load resource! Empty body!")
var string = body.string()
+ val waitingPattern = """"GenericToast\.Waiting": *"Waiting to be let in…",""".toRegex()
+ val bannerPattern = """"SessionBanner\.FaceTime": *"FaceTime Call",""".toRegex()
+ val submitNamePattern = "(submitName: *([a-zA-Z]+?)[ a-zA-Z,}=:]*?;)".toRegex()
+ val diagnosticsEnabled = diagnosticsEnabled()
+ val waitingMatches = if (diagnosticsEnabled) waitingPattern.findAll(string).count() else 0
+ val bannerMatches = if (diagnosticsEnabled) bannerPattern.findAll(string).count() else 0
+ val leaveMatches = if (diagnosticsEnabled) "this.onLeave.notifyListeners()".toRegex().findAll(string).count() else 0
+ val submitNameMatches = if (diagnosticsEnabled && name != null) submitNamePattern.findAll(string).count() else 0
+
+ string = string
.replace(""""GenericToast\.Waiting": *"Waiting to be let in…",""".toRegex(), """"GenericToast.Waiting":"Connecting…",""")
.replace(""""SessionBanner\.FaceTime": *"FaceTime Call",""".toRegex(), """"SessionBanner.FaceTime":"$desc",""")
.replace("this.onLeave.notifyListeners()", "Native.leave(), this.onLeave.notifyListeners()")
if (name != null) {
- string = string.replace("(submitName: *([a-zA-Z]+?)[ a-zA-Z,}=:]*?;)".toRegex(), "$1 $2(\"$name\").then(() => Native.mirrored());")
+ val javascriptName = javascriptStringLiteral(name)
+ string = string.replace(submitNamePattern) { match ->
+ "${match.groupValues[1]} ${match.groupValues[2]}($javascriptName).then(() => Native.mirrored());"
+ }
+ }
+
+ val patchCount = waitingMatches + bannerMatches + leaveMatches + submitNameMatches
+ if (diagnosticsEnabled) {
+ FaceTimeDiagnostics.logStage(
+ applicationContext,
+ FaceTimeDiagnosticStage.JS_PATCHED,
+ state = if (patchCount > 0) "true" else "false",
+ count = patchCount,
+ )
}
- return string
+ return webRtcDiagnosticBootstrap + string
}
+ private val webRtcDiagnosticBootstrap = """
+ (() => {
+ if (window.__obFaceTimeDiagnostics) return;
+ const state = {
+ peers: [],
+ nextPeerId: 1,
+ };
+ const updateIceState = (peerState) => {
+ peerState.iceState = peerState.peer.iceConnectionState || peerState.peer.connectionState || "unknown";
+ };
+ const watchPeer = (pc) => {
+ const peerState = {
+ id: state.nextPeerId++,
+ peer: pc,
+ iceState: "unknown",
+ previousInboundBytes: null,
+ remoteAudioTracks: new Map(),
+ remoteVideoTracks: new Map()
+ };
+ state.peers.push(peerState);
+ updateIceState(peerState);
+ pc.addEventListener("iceconnectionstatechange", () => updateIceState(peerState));
+ pc.addEventListener("connectionstatechange", () => updateIceState(peerState));
+ pc.addEventListener("track", (event) => {
+ if (!event.track || !event.track.id) return;
+ const tracks = event.track.kind === "audio"
+ ? peerState.remoteAudioTracks
+ : event.track.kind === "video" ? peerState.remoteVideoTracks : null;
+ if (!tracks) return;
+ const track = event.track;
+ tracks.set(track.id, track);
+ track.addEventListener("ended", () => {
+ if (tracks.get(track.id) === track) tracks.delete(track.id);
+ });
+ });
+ };
+ const install = () => {
+ const original = window.RTCPeerConnection;
+ if (!original || window.__obFaceTimeRtcWrapped) return !!original;
+ window.__obFaceTimeRtcWrapped = true;
+ window.RTCPeerConnection = new Proxy(original, {
+ construct(target, args, newTarget) {
+ const peer = Reflect.construct(target, args, newTarget);
+ watchPeer(peer);
+ return peer;
+ }
+ });
+ return true;
+ };
+ const controlText = (button) =>
+ (button.innerText || button.textContent || button.getAttribute("aria-label") || "")
+ .trim()
+ .replace(/\s+/g, " ")
+ .toLowerCase();
+ const isVisible = (button) =>
+ button.hidden !== true &&
+ button.getAttribute("aria-hidden") !== "true" &&
+ button.offsetParent !== null;
+ const controlState = (names) => {
+ const matches = Array.from(document.querySelectorAll("button"))
+ .filter((button) => names.includes(controlText(button)));
+ const visible = matches.some((button) => isVisible(button));
+ const enabled = matches.some((button) =>
+ isVisible(button) &&
+ button.disabled !== true &&
+ button.getAttribute("aria-disabled") !== "true"
+ );
+ return { visible, enabled, count: matches.length };
+ };
+ install();
+ if (!window.__obFaceTimeRtcInstallTimer) {
+ window.__obFaceTimeRtcInstallTimer = window.setInterval(() => {
+ if (install()) window.clearInterval(window.__obFaceTimeRtcInstallTimer);
+ }, 100);
+ }
+ window.__obFaceTimeDiagnostics = {
+ snapshot: async () => {
+ const candidates = [];
+ for (const peerState of state.peers) {
+ updateIceState(peerState);
+ if (peerState.iceState === "closed") continue;
+ let bytes = 0;
+ let bytesObserved = false;
+ const peer = peerState.peer;
+ try {
+ const reports = await peer.getStats();
+ reports.forEach((report) => {
+ if (report.type === "inbound-rtp" && typeof report.bytesReceived === "number") {
+ bytes += report.bytesReceived;
+ bytesObserved = true;
+ }
+ });
+ } catch (_) {}
+ const remoteAudioTracks = Array.from(peerState.remoteAudioTracks.values())
+ .filter((track) => track.readyState !== "ended").length;
+ const remoteVideoTracks = Array.from(peerState.remoteVideoTracks.values())
+ .filter((track) => track.readyState !== "ended").length;
+ const bytesAdvancing = bytesObserved &&
+ peerState.previousInboundBytes !== null &&
+ bytes > peerState.previousInboundBytes;
+ peerState.previousInboundBytes = bytesObserved ? bytes : null;
+ candidates.push({
+ peerId: peerState.id,
+ iceState: peerState.iceState,
+ remoteAudioTracks,
+ remoteVideoTracks,
+ mediaBytes: bytesObserved ? bytes : null,
+ bytesAdvancing,
+ });
+ }
+ const active = [...candidates].reverse().find((candidate) => candidate.bytesAdvancing)
+ || candidates.at(-1)
+ || null;
+ const controls = {
+ join: controlState(["join"]),
+ rejoin: controlState(["rejoin"]),
+ leave: controlState(["leave", "end call"]),
+ };
+ return JSON.stringify({
+ peerId: active ? active.peerId : null,
+ iceState: active ? active.iceState : "unknown",
+ remoteAudioTracks: active ? active.remoteAudioTracks : 0,
+ remoteVideoTracks: active ? active.remoteVideoTracks : 0,
+ mediaBytes: active ? active.mediaBytes : null,
+ webLeaveVisible: controls.leave.visible,
+ webControls: controls
+ });
+ }
+ };
+ })();
+ """.trimIndent()
+
init {
val client = OkHttpClient.Builder()
.cache(
@@ -82,10 +316,25 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
request: WebResourceRequest?
): WebResourceResponse? {
if (request == null) return null
- if (!request.url.toString().endsWith("main.js")) return null
+ if (!request.url.toString().endsWith("main.js")) {
+ if (diagnosticsEnabled() && request.url.lastPathSegment == "main.js") {
+ Log.w(diagnosticTag, "main.js candidate was not intercepted because its URL has a suffix")
+ }
+ return null
+ }
// intercept and patch request
- val scriptData = getScriptData(request, client, name, desc)
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "intercepting ${safeResourceLabel(request.url.toString())}")
+ }
+ val scriptData = try {
+ getScriptData(request, client, name, desc)
+ } catch (error: Exception) {
+ if (diagnosticsEnabled()) {
+ Log.e(diagnosticTag, "main.js interception failed: ${error.javaClass.simpleName}")
+ }
+ throw error
+ }
return WebResourceResponse(
"application/javascript",
@@ -93,34 +342,106 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
ByteArrayInputStream(scriptData.encodeToByteArray())
)
}
+
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "page started ${safeResourceLabel(url)}")
+ }
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ if (diagnosticsEnabled()) {
+ FaceTimeDiagnostics.logStage(
+ applicationContext,
+ FaceTimeDiagnosticStage.WEBVIEW_LOADED,
+ state = "true",
+ )
+ }
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?
+ ) {
+ if (diagnosticsEnabled()) {
+ Log.w(
+ diagnosticTag,
+ "resource error mainFrame=${request?.isForMainFrame} code=${error?.errorCode} resource=${safeResourceLabel(request?.url?.toString())}"
+ )
+ }
+ }
+
+ override fun onReceivedHttpError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ errorResponse: WebResourceResponse?
+ ) {
+ if (diagnosticsEnabled()) {
+ Log.w(
+ diagnosticTag,
+ "http error mainFrame=${request?.isForMainFrame} status=${errorResponse?.statusCode} resource=${safeResourceLabel(request?.url?.toString())}"
+ )
+ }
+ }
}
webView.setBackgroundColor(Color.BLACK)
webView.addJavascriptInterface(object {
@JavascriptInterface
fun leave() {
- endTask()
+ callbackHandler.post { endTask() }
}
@JavascriptInterface
fun mirrored() {
+ if (mirrorReady || mirrorReadyRunnable != null) {
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "duplicate Native.mirrored ignored")
+ }
+ return
+ }
// takes a second for the mirror to be ready
- Handler(Looper.getMainLooper()).postDelayed({
+ val runnable = Runnable {
+ mirrorReadyRunnable = null
mirrorReady = true
mirrorReadyCall?.let {
it()
}
- }, 250)
- Log.i("Got Mirror", "")
+ }
+ mirrorReadyRunnable = runnable
+ callbackHandler.postDelayed(runnable, 250)
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "Native.mirrored received; mirrorReady scheduled")
+ }
}
}, "Native")
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest?) {
if (request == null) return
+ if (diagnosticsEnabled()) {
+ FaceTimeDiagnostics.logStage(
+ applicationContext,
+ FaceTimeDiagnosticStage.PERMISSIONS_REQUESTED,
+ count = request.resources.size,
+ )
+ }
deferredRequests.add(request)
deferredRequestsUpdated()
}
+ override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
+ if (!diagnosticsEnabled() || consoleMessage == null) return false
+ if (consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.ERROR ||
+ consoleMessage.messageLevel() == ConsoleMessage.MessageLevel.WARNING) {
+ Log.w(
+ diagnosticTag,
+ "console ${consoleMessage.messageLevel()} line=${consoleMessage.lineNumber()} source=${safeResourceLabel(consoleMessage.sourceId())} message="
+ )
+ }
+ return false
+ }
+
override fun getDefaultVideoPoster(): Bitmap {
return Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565)
}
@@ -129,4 +450,4 @@ class CachedWebview(context: Context, name: String?, desc: String, url: String)
webView.loadUrl(url)
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt
index ad4ea24429..aacc49f73f 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeActivity.kt
@@ -22,6 +22,7 @@ import android.util.Log
import android.util.Rational
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
+import android.view.Gravity
import android.view.WindowInsets
import android.view.WindowManager
import android.webkit.PermissionRequest
@@ -43,6 +44,12 @@ import com.google.android.material.math.MathUtils
import kotlin.math.roundToInt
class FaceTimeActivity : Activity() {
+ companion object {
+ private const val diagnosticTag = "FaceTimeDiag"
+ var activeFaceTimeActivity: FaceTimeActivity? = null
+ var cachedWebview: CachedWebview? = null
+ }
+
private lateinit var binding: ActivityFaceTimeBinding
private var permissionRequests = ArrayList()
@@ -59,14 +66,210 @@ class FaceTimeActivity : Activity() {
private lateinit var webView: WebView
private var initialMediaVolume: Int? = null;
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val joinPolicy = FaceTimeJoinPolicy()
+ private val callLifecycle = FaceTimeCallLifecycle()
+ private var joinRetryRunnable: Runnable? = null
+ private var manualRecoveryRunnable: Runnable? = null
+ private var connectionProbeRunnable: Runnable? = null
+ private var endFallbackRunnable: Runnable? = null
+ private var connectionProbeCount = 0
+ private var callEnding = false
+
+ private fun diagnosticsEnabled(): Boolean = FaceTimeDiagnostics.isEnabled(this)
+
+ private val joinButtonScript = """
+ (() => {
+ const visible = (element) => !!element && element.offsetParent !== null;
+ const label = (element) => (element?.innerText || element?.textContent || element?.getAttribute?.("aria-label") || "").trim();
+ const buttons = Array.from(document.querySelectorAll("button"));
+ const leave = document.getElementById("callcontrols-leave-button-session-banner") ||
+ buttons.find((button) => /^(leave|end call)$/i.test(label(button)));
+ if (visible(leave)) return "already-joined";
+ const join = document.getElementById("callcontrols-join-button-session-banner") ||
+ buttons.find((button) => /^(join|rejoin)$/i.test(label(button)));
+ if (!join) return "missing";
+ if (join.disabled || join.getAttribute("aria-disabled") === "true") return "disabled";
+ if (!visible(join)) return "hidden";
+ join.click();
+ return "clicked";
+ })()
+ """.trimIndent()
+
+ private fun logJoinButtonState(reason: String) {
+ if (!diagnosticsEnabled()) return
+ webView.evaluateJavascript(
+ """(() => { const button = document.getElementById("callcontrols-join-button-session-banner"); return button ? "present:" + (!button.disabled) + ":" + (button.offsetParent !== null) : "missing"; })()"""
+ ) { result ->
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "join button state reason=$reason result=$result mirrorReady=$mirrorReady answered=$answered")
+ }
+ }
+ }
- companion object {
- var activeFaceTimeActivity: FaceTimeActivity? = null
- var cachedWebview: CachedWebview? = null
+ private fun positionNativeEndControl(webLeaveVisible: Boolean) {
+ val layoutParams = binding.nativeCallControls.layoutParams as? android.widget.FrameLayout.LayoutParams
+ ?: return
+ val density = resources.displayMetrics.density
+ val topMargin = (48 * density).roundToInt()
+ val bottomMargin = (96 * density).roundToInt()
+ when (FaceTimeControlPolicy.nativeEndPlacement(webLeaveVisible)) {
+ FaceTimeNativeEndPlacement.TOP_RIGHT -> {
+ layoutParams.gravity = Gravity.TOP or Gravity.END
+ layoutParams.topMargin = topMargin
+ layoutParams.bottomMargin = 0
+ }
+ FaceTimeNativeEndPlacement.BOTTOM_LEFT -> {
+ layoutParams.gravity = Gravity.BOTTOM or Gravity.START
+ layoutParams.topMargin = 0
+ layoutParams.bottomMargin = bottomMargin
+ }
+ }
+ if (webLeaveVisible) {
+ layoutParams.marginStart = (20 * density).roundToInt()
+ layoutParams.marginEnd = 0
+ } else {
+ layoutParams.marginStart = 0
+ layoutParams.marginEnd = (20 * density).roundToInt()
+ }
+ binding.nativeCallControls.layoutParams = layoutParams
+ binding.nativeCallControls.elevation = (12 * density)
+ }
+
+ private fun showCallUi(joined: Boolean, webLeaveVisible: Boolean = false) {
+ binding.mainFrame.visibility = View.VISIBLE
+ binding.splashLayout.visibility = View.GONE
+ positionNativeEndControl(webLeaveVisible)
+ binding.nativeCallControls.visibility = if (FaceTimeControlPolicy.shouldShowNativeEndControl()) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+ binding.connectionStatus.visibility = if (joined) View.GONE else View.VISIBLE
+ if (!joined) {
+ binding.connectionStatus.text = if (joinPolicy.completedJoin) {
+ "FaceTime media unavailable"
+ } else {
+ "Finishing FaceTime connection..."
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ window.setBackgroundBlurRadius(0)
+ }
+ }
+
+ private fun scheduleConnectionProbe(delayMillis: Long = 0) {
+ if (callEnding || isFinishing || isDestroyed || connectionProbeCount >= FaceTimeConnectionProbePolicy.maxProbes) return
+ connectionProbeRunnable?.let(mainHandler::removeCallbacks)
+ val runnable = Runnable {
+ if (callEnding || isFinishing || isDestroyed) return@Runnable
+ webView.evaluateJavascript(
+ """window.__obFaceTimeDiagnostics ? window.__obFaceTimeDiagnostics.snapshot() : JSON.stringify({peerId:null,iceState:"unknown",remoteAudioTracks:0,remoteVideoTracks:0,mediaBytes:null,webLeaveVisible:false})"""
+ ) { result ->
+ connectionProbeCount += 1
+ val evidence = parseMediaEvidence(result)
+ if (evidence == null) {
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.ICE_STATE, state = "unknown")
+ scheduleConnectionProbe(FaceTimeConnectionProbePolicy.pendingDelayMillis)
+ return@evaluateJavascript
+ }
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.ICE_STATE, state = evidence.iceState.name.lowercase())
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.REMOTE_AUDIO_TRACK, count = evidence.remoteAudioTracks)
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.REMOTE_VIDEO_TRACK, count = evidence.remoteVideoTracks)
+ evidence.mediaBytes?.let { FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.MEDIA_BYTES, bytes = it) }
+ val decision = joinPolicy.recordMediaEvidence(evidence)
+ if (decision.joined) {
+ joinRetryRunnable?.let(mainHandler::removeCallbacks)
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.ADMITTED, state = "true")
+ } else if (joinPolicy.completedJoin) {
+ joinRetryRunnable?.let(mainHandler::removeCallbacks)
+ FaceTimeDiagnostics.logStage(
+ this,
+ FaceTimeDiagnosticStage.MEDIA_LOST,
+ state = decision.outcome.name.lowercase(),
+ )
+ }
+ showCallUi(joined = decision.joined, webLeaveVisible = evidence.webLeaveVisible)
+ scheduleConnectionProbe(
+ if (decision.joined) {
+ FaceTimeConnectionProbePolicy.connectedDelayMillis
+ } else {
+ FaceTimeConnectionProbePolicy.pendingDelayMillis
+ }
+ )
+ }
+ }
+ connectionProbeRunnable = runnable
+ mainHandler.postDelayed(runnable, delayMillis)
+ }
+
+ private fun scheduleJoinAttempt(reason: String, delayMillis: Long = 0) {
+ if (!answered || callEnding || joinPolicy.joined || joinPolicy.completedJoin || isFinishing || isDestroyed) return
+ joinRetryRunnable?.let(mainHandler::removeCallbacks)
+ val runnable = Runnable { attemptJoin(reason) }
+ joinRetryRunnable = runnable
+ mainHandler.postDelayed(runnable, delayMillis)
+ }
+
+ private fun attemptJoin(reason: String) {
+ if (!answered || callEnding || joinPolicy.joined || joinPolicy.completedJoin || isFinishing || isDestroyed) return
+ webView.evaluateJavascript(joinButtonScript) { result ->
+ if (callEnding || isFinishing || isDestroyed) return@evaluateJavascript
+ val decision = joinPolicy.record(result)
+ if (diagnosticsEnabled()) {
+ FaceTimeDiagnostics.logStage(
+ this,
+ FaceTimeDiagnosticStage.ADMISSION_REQUESTED,
+ state = decision.outcome.name.lowercase(),
+ count = joinPolicy.attempts,
+ )
+ }
+ if (decision.revealManualRecovery) {
+ showCallUi(
+ joined = false,
+ webLeaveVisible = decision.outcome == FaceTimeJoinOutcome.ALREADY_JOINED,
+ )
+ }
+ scheduleConnectionProbe(FaceTimeConnectionProbePolicy.initialDelayMillis)
+ if (decision.retry) {
+ scheduleJoinAttempt("retry-${decision.outcome}", 750)
+ } else {
+ showCallUi(joined = false)
+ binding.connectionStatus.text = "Tap Join or Rejoin to connect"
+ if (diagnosticsEnabled()) {
+ Log.w(diagnosticTag, "automatic join attempts exhausted")
+ }
+ }
+ }
}
fun endCall() {
- webView.loadUrl("javascript:document.getElementById(\"callcontrols-leave-button-session-banner\").click()")
+ if (callEnding) return
+ callEnding = true
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.LEAVE, state = "requested")
+ joinRetryRunnable?.let(mainHandler::removeCallbacks)
+ binding.connectionStatus.text = "Ending FaceTime..."
+ binding.connectionStatus.visibility = View.VISIBLE
+ binding.endCall.isEnabled = false
+ val fallback = Runnable {
+ if (!isFinishing && !isDestroyed) {
+ if (diagnosticsEnabled()) {
+ Log.w(diagnosticTag, "native end call fallback finishing activity")
+ }
+ finishAndRemoveTask()
+ }
+ }
+ endFallbackRunnable = fallback
+ mainHandler.postDelayed(fallback, 1500)
+ webView.evaluateJavascript(
+ """(() => { const buttons = Array.from(document.querySelectorAll("button")); const label = (element) => (element?.innerText || element?.textContent || element?.getAttribute?.("aria-label") || "").trim(); const button = document.getElementById("callcontrols-leave-button-session-banner") || buttons.find((item) => /^(leave|end call)$/i.test(label(item))); if (!button) return "missing"; button.click(); return "clicked"; })()"""
+ ) { result ->
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "native end call result=$result")
+ }
+ mainHandler.removeCallbacks(fallback)
+ mainHandler.postDelayed(fallback, 500)
+ }
}
private fun hideControlsForPIP() {
@@ -163,8 +366,15 @@ class FaceTimeActivity : Activity() {
}
private fun answerCall() {
+ if (answered) return
answered = true
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.ADMISSION_REQUESTED, state = "answer")
+
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "answer requested mirrorReady=$mirrorReady deferredPermissions=${cached.deferredRequests.size}")
+ }
+
handlePermissionRequests()
if (notificationId != 0) {
@@ -172,12 +382,8 @@ class FaceTimeActivity : Activity() {
}
if (mirrorReady) {
- binding.mainFrame.visibility = View.VISIBLE
- binding.splashLayout.visibility = View.GONE
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- window.setBackgroundBlurRadius(0)
- }
- webView.loadUrl("javascript:document.getElementById(\"callcontrols-join-button-session-banner\").click()")
+ logJoinButtonState("answer-ready")
+ scheduleJoinAttempt("answer-ready")
} else {
connecting()
}
@@ -185,7 +391,38 @@ class FaceTimeActivity : Activity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
+ if (intent == null) return
+ setIntent(intent)
+ when (val disposition = callLifecycle.acceptIntent(intent.getStringExtra("callUuid"))) {
+ FaceTimeIntentDisposition.DUPLICATE -> {
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.LIFECYCLE, state = "duplicate_intent")
+ if (intent.getBooleanExtra("answer", false)) answerCall()
+ }
+ FaceTimeIntentDisposition.REJECTED_MISMATCHED_CALL,
+ FaceTimeIntentDisposition.REJECTED_MISSING_CALL_ID -> {
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.LIFECYCLE, state = "ignored_intent")
+ }
+ FaceTimeIntentDisposition.ACCEPTED -> {
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.LIFECYCLE, state = disposition.name.lowercase())
+ }
+ }
+ }
+ private fun startOutgoingCall() {
+ if (answered) return
+ // The shared admission loop is also required for outgoing FaceTime
+ // links. Previously only answered incoming calls could click Join or
+ // Rejoin, leaving an outgoing page probing forever if Apple did not
+ // auto-admit it.
+ answered = true
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.ADMISSION_REQUESTED, state = "outgoing")
+ handlePermissionRequests()
+ if (mirrorReady) {
+ logJoinButtonState("outgoing-ready")
+ scheduleJoinAttempt("outgoing-ready")
+ } else {
+ connecting()
+ }
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -222,6 +459,10 @@ class FaceTimeActivity : Activity() {
decline()
}
+ binding.endCall.setOnClickListener {
+ endCall()
+ }
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -281,8 +522,15 @@ class FaceTimeActivity : Activity() {
fun handlePermissionRequest(request: PermissionRequest) {
val permissions = request.resources.flatMap { i -> permissionMap[i] ?: listOf() }
- if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
+ if (diagnosticsEnabled()) {
+ Log.i(
+ diagnosticTag,
+ "handling WebView permission resources=${request.resources.sorted().joinToString()} androidPermissions=${permissions.joinToString()} alreadyGranted=${permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }}"
+ )
+ }
+ if (permissions.isNotEmpty() && permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
request.grant(request.resources)
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.PERMISSIONS_RESULT, state = "granted")
startService()
return
}
@@ -291,27 +539,40 @@ class FaceTimeActivity : Activity() {
}
override fun onDestroy() {
- webView.destroy()
- activeFaceTimeActivity = null
-
- val intent = Intent(this, FaceTimeInCallService::class.java)
- stopService(intent)
- serviceStarted = false
-
- // restore default media volume
- initialMediaVolume?.let {
- try {
- val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
- audioManager.setStreamVolume(
- AudioManager.STREAM_MUSIC,
- it,
- 0
- )
- } catch (e: SecurityException) {
- Log.w("FaceTime", "Unable to set stream volume!")
+ joinRetryRunnable?.let(mainHandler::removeCallbacks)
+ manualRecoveryRunnable?.let(mainHandler::removeCallbacks)
+ connectionProbeRunnable?.let(mainHandler::removeCallbacks)
+ endFallbackRunnable?.let(mainHandler::removeCallbacks)
+ if (::cached.isInitialized) {
+ cached.cancelCallbacks()
+ }
+
+ val isCurrentActivity = activeFaceTimeActivity === this
+ if (isCurrentActivity) {
+ activeFaceTimeActivity = null
+ val intent = Intent(this, FaceTimeInCallService::class.java)
+ stopService(intent)
+ serviceStarted = false
+
+ // An older FaceTime activity must not mute or reroute a newer call.
+ initialMediaVolume?.let {
+ try {
+ val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
+ audioManager.setStreamVolume(
+ AudioManager.STREAM_MUSIC,
+ it,
+ 0
+ )
+ } catch (e: SecurityException) {
+ Log.w("FaceTime", "Unable to set stream volume!")
+ }
}
}
+ if (::webView.isInitialized) webView.destroy()
+
+ callLifecycle.reset()
+
contentObserver?.let {
applicationContext.contentResolver.unregisterContentObserver(it)
}
@@ -325,28 +586,54 @@ class FaceTimeActivity : Activity() {
grantResults: IntArray
) {
if (requestCode != 1) return
+ if (diagnosticsEnabled()) {
+ Log.i(
+ diagnosticTag,
+ "Android permission result ${permissions.zip(grantResults.toTypedArray()).joinToString { (permission, result) -> "$permission=${result == PackageManager.PERMISSION_GRANTED}" }}"
+ )
+ }
for (request in permissionRequests) {
request.grant(request.resources.filter { i ->
- (permissionMap[i] ?: listOf()).all {
+ permissionMap[i]?.takeIf { it.isNotEmpty() }?.all {
val permissionIdx = permissions.indexOf(it)
- grantResults[permissionIdx] == PackageManager.PERMISSION_GRANTED
- }
+ FaceTimePermissionPolicy.isGranted(grantResults, permissionIdx)
+ } == true
}.toTypedArray())
}
permissionRequests = arrayListOf()
- startService()
+ FaceTimeDiagnostics.logStage(
+ this,
+ FaceTimeDiagnosticStage.PERMISSIONS_RESULT,
+ state = if (FaceTimePermissionPolicy.shouldStartInCallService(permissions.size, grantResults)) {
+ "granted"
+ } else {
+ "denied"
+ },
+ )
+ if (FaceTimePermissionPolicy.shouldStartInCallService(permissions.size, grantResults)) {
+ startService()
+ } else if (diagnosticsEnabled()) {
+ Log.w(diagnosticTag, "not starting in-call service because camera/microphone permission was denied or incomplete")
+ }
}
private fun connecting() {
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "waiting for mirrorReady")
+ }
binding.acceptButtons.visibility = View.GONE
binding.loadingBanner.text = "Connecting..."
- Handler(Looper.getMainLooper()).postDelayed({
- binding.mainFrame.visibility = View.VISIBLE
- binding.splashLayout.visibility = View.GONE
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- window.setBackgroundBlurRadius(0)
+ scheduleJoinAttempt("connecting")
+ val recoveryRunnable = Runnable {
+ if (callEnding || isFinishing || isDestroyed || joinPolicy.joined) return@Runnable
+ if (diagnosticsEnabled()) {
+ Log.w(diagnosticTag, "mirrorReady timeout reached mirrorReady=$mirrorReady answered=$answered")
}
- }, 15000)
+ logJoinButtonState("mirror-timeout")
+ showCallUi(joined = false)
+ }
+ manualRecoveryRunnable = recoveryRunnable
+ mainHandler.postDelayed(recoveryRunnable, 15000)
}
private fun handleConfig(extras: Bundle) {
@@ -368,13 +655,12 @@ class FaceTimeActivity : Activity() {
mirrorReady = cached.mirrorReady
cached.mirrorReadyCall = {
mirrorReady = true
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "mirrorReady callback answered=$answered")
+ }
if (answered) {
- binding.mainFrame.visibility = View.VISIBLE
- binding.splashLayout.visibility = View.GONE
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- window.setBackgroundBlurRadius(0)
- }
- webView.loadUrl("javascript:document.getElementById(\"callcontrols-join-button-session-banner\").click()")
+ logJoinButtonState("mirror-ready")
+ scheduleJoinAttempt("mirror-ready")
}
}
@@ -383,13 +669,18 @@ class FaceTimeActivity : Activity() {
val isAnsweringCall = extras.containsKey("answer")
notificationId = extras.getString("notificationId")?.toInt() ?: 0
callUuid = extras.getString("callUuid")
+ if (callLifecycle.acceptIntent(callUuid) == FaceTimeIntentDisposition.REJECTED_MISSING_CALL_ID) {
+ FaceTimeDiagnostics.logStage(this, FaceTimeDiagnosticStage.LIFECYCLE, state = "missing_call_id")
+ }
if (CreateIncomingFaceTimeNotification.avatarCache.containsKey(callUuid)) {
val bitmap = CreateIncomingFaceTimeNotification.avatarCache.remove(callUuid)!!
binding.avatarView.setImageBitmap(bitmap)
}
- Log.i("FaceTime", "started activity for call $callUuid")
+ if (diagnosticsEnabled()) {
+ Log.i(diagnosticTag, "started activity hasCallUuid=${callUuid != null} answering=$isAnsweringCall")
+ }
val poster = extras.getString("poster")
if (poster != null) {
@@ -413,10 +704,16 @@ class FaceTimeActivity : Activity() {
} else {
binding.splashLayout.visibility = View.GONE
binding.mainFrame.visibility = View.VISIBLE
+ positionNativeEndControl(webLeaveVisible = false)
+ binding.nativeCallControls.visibility = View.VISIBLE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
window.setBackgroundBlurRadius(0)
}
- handlePermissionRequests()
+ startOutgoingCall()
+ scheduleConnectionProbe(1000)
}
}
-}
\ No newline at end of file
+
+ private fun parseMediaEvidence(rawResult: String?): FaceTimeMediaEvidence? =
+ FaceTimeMediaEvidenceParser.parse(rawResult)
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt
index 61cc59f7ce..2b57a65723 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeCallStateHandler.kt
@@ -36,6 +36,7 @@ class FaceTimeCallStateHandler: MethodCallHandlerImpl() {
}
// cancel any unused webview
FaceTimeActivity.cachedWebview?.let {
+ it.cancelCallbacks()
it.webView.destroy()
FaceTimeActivity.cachedWebview = null
}
@@ -44,4 +45,4 @@ class FaceTimeCallStateHandler: MethodCallHandlerImpl() {
result.success(null)
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt
new file mode 100644
index 0000000000..d7da44c97f
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnostics.kt
@@ -0,0 +1,76 @@
+package com.bluebubbles.messaging.services.facetime
+
+import android.content.Context
+import android.util.Log
+
+internal enum class FaceTimeDiagnosticStage(val wireName: String) {
+ WEBVIEW_LOADED("webview_loaded"),
+ JS_PATCHED("js_patched"),
+ PERMISSIONS_REQUESTED("permissions_requested"),
+ PERMISSIONS_RESULT("permissions_result"),
+ ADMISSION_REQUESTED("admission_requested"),
+ ADMITTED("admitted"),
+ ICE_STATE("ice_state"),
+ REMOTE_AUDIO_TRACK("remote_audio_track"),
+ REMOTE_VIDEO_TRACK("remote_video_track"),
+ MEDIA_BYTES("media_bytes"),
+ MEDIA_LOST("media_lost"),
+ LEAVE("leave"),
+ LIFECYCLE("lifecycle"),
+}
+
+internal object FaceTimeDiagnostics {
+ private const val diagnosticTag = "FaceTimeDiag"
+ private const val preferencesName = "FlutterSharedPreferences"
+ private const val developerModeKey = "flutter.developerEnabled"
+ private const val diagnosticsKey = "flutter.faceTimeDiagnosticsEnabled"
+
+ fun isEnabled(context: Context): Boolean {
+ val preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+ return shouldEnable(
+ developerModeEnabled = preferences.getBoolean(developerModeKey, false),
+ diagnosticsEnabled = preferences.getBoolean(diagnosticsKey, false),
+ )
+ }
+
+ internal fun shouldEnable(
+ developerModeEnabled: Boolean,
+ diagnosticsEnabled: Boolean,
+ ): Boolean = developerModeEnabled && diagnosticsEnabled
+
+ internal fun formatStage(
+ stage: FaceTimeDiagnosticStage,
+ state: String? = null,
+ count: Int? = null,
+ bytes: Long? = null,
+ ): String {
+ val fields = buildList {
+ add("stage=${stage.wireName}")
+ state?.let { add("state=${safeValue(it)}") }
+ count?.let { add("count=${it.coerceAtLeast(0)}") }
+ bytes?.let { add("bytes=${it.coerceAtLeast(0)}") }
+ }
+ return fields.joinToString(" ")
+ }
+
+ internal fun logStage(
+ context: Context,
+ stage: FaceTimeDiagnosticStage,
+ state: String? = null,
+ count: Int? = null,
+ bytes: Long? = null,
+ ) {
+ if (isEnabled(context)) {
+ Log.i(diagnosticTag, formatStage(stage, state, count, bytes))
+ }
+ }
+
+ internal fun safeIceState(rawValue: String?): String = when (rawValue?.lowercase()) {
+ "new", "checking", "connected", "completed", "disconnected", "failed", "closed" -> rawValue.lowercase()
+ else -> "unknown"
+ }
+
+ private fun safeValue(value: String): String = value
+ .lowercase()
+ .replace(Regex("[^a-z0-9_.-]"), "_")
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt
new file mode 100644
index 0000000000..910d10788b
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicy.kt
@@ -0,0 +1,213 @@
+package com.bluebubbles.messaging.services.facetime
+
+internal enum class FaceTimeJoinOutcome {
+ CLICKED,
+ ALREADY_JOINED,
+ MISSING,
+ DISABLED,
+ HIDDEN,
+ UNKNOWN,
+ MEDIA_PENDING,
+ MEDIA_CONNECTED,
+ MEDIA_FAILED,
+}
+
+internal enum class FaceTimeIceState {
+ NEW,
+ CHECKING,
+ CONNECTED,
+ COMPLETED,
+ DISCONNECTED,
+ FAILED,
+ CLOSED,
+ UNKNOWN,
+ ;
+
+ companion object {
+ fun fromWireValue(rawValue: String?): FaceTimeIceState = when (rawValue?.lowercase()) {
+ "new" -> NEW
+ "checking" -> CHECKING
+ "connected" -> CONNECTED
+ "completed" -> COMPLETED
+ "disconnected" -> DISCONNECTED
+ "failed" -> FAILED
+ "closed" -> CLOSED
+ else -> UNKNOWN
+ }
+ }
+}
+
+internal data class FaceTimeMediaEvidence(
+ val iceState: FaceTimeIceState,
+ val remoteAudioTracks: Int,
+ val remoteVideoTracks: Int,
+ val mediaBytes: Long?,
+ val webLeaveVisible: Boolean,
+ val peerId: Int? = null,
+) {
+ val hasRemoteTrack: Boolean
+ get() = remoteAudioTracks > 0 || remoteVideoTracks > 0
+
+ val hasConnectedIce: Boolean
+ get() = iceState == FaceTimeIceState.CONNECTED || iceState == FaceTimeIceState.COMPLETED
+
+ val isConnected: Boolean
+ get() = hasConnectedIce && hasRemoteTrack
+}
+
+internal data class FaceTimeJoinDecision(
+ val outcome: FaceTimeJoinOutcome,
+ val joined: Boolean,
+ val revealManualRecovery: Boolean,
+ val retry: Boolean,
+)
+
+internal enum class FaceTimeIntentDisposition {
+ ACCEPTED,
+ DUPLICATE,
+ REJECTED_MISMATCHED_CALL,
+ REJECTED_MISSING_CALL_ID,
+}
+
+internal enum class FaceTimeNativeEndPlacement {
+ TOP_RIGHT,
+ BOTTOM_LEFT,
+}
+
+internal object FaceTimeConnectionProbePolicy {
+ const val maxProbes = 80
+ const val initialDelayMillis = 500L
+ const val pendingDelayMillis = 1500L
+ const val connectedDelayMillis = 5000L
+}
+
+/** Keeps a new FaceTime intent attached to one call. */
+internal class FaceTimeCallLifecycle {
+ private var activeCallUuid: String? = null
+
+ fun acceptIntent(incomingCallUuid: String?): FaceTimeIntentDisposition {
+ val active = activeCallUuid
+ if (active == null) {
+ if (incomingCallUuid == null) {
+ return FaceTimeIntentDisposition.REJECTED_MISSING_CALL_ID
+ }
+ activeCallUuid = incomingCallUuid
+ return FaceTimeIntentDisposition.ACCEPTED
+ }
+
+ return when {
+ incomingCallUuid == active -> FaceTimeIntentDisposition.DUPLICATE
+ incomingCallUuid == null -> FaceTimeIntentDisposition.REJECTED_MISSING_CALL_ID
+ else -> FaceTimeIntentDisposition.REJECTED_MISMATCHED_CALL
+ }
+ }
+
+ fun reset() {
+ activeCallUuid = null
+ }
+}
+
+internal object FaceTimeControlPolicy {
+ fun shouldShowNativeEndControl(): Boolean = true
+
+ fun nativeEndPlacement(webLeaveVisible: Boolean): FaceTimeNativeEndPlacement =
+ if (webLeaveVisible) FaceTimeNativeEndPlacement.BOTTOM_LEFT else FaceTimeNativeEndPlacement.TOP_RIGHT
+}
+
+internal class FaceTimeJoinPolicy(
+ private val manualRecoveryAttempt: Int = 20,
+ private val maxAttempts: Int = 80,
+) {
+ private var previousPeerId: Int? = null
+ private var previousInboundMediaBytes: Long? = null
+
+ var attempts: Int = 0
+ private set
+
+ var admissionRequested: Boolean = false
+ private set
+
+ var joined: Boolean = false
+ private set
+
+ var completedJoin: Boolean = false
+ private set
+
+ fun record(rawResult: String?): FaceTimeJoinDecision {
+ attempts += 1
+ val outcome = parseOutcome(rawResult)
+ if (outcome == FaceTimeJoinOutcome.CLICKED || outcome == FaceTimeJoinOutcome.ALREADY_JOINED) {
+ admissionRequested = true
+ }
+
+ // A click and a visible Leave button are only signaling evidence. The
+ // WebView must later report connected ICE and advancing inbound media.
+ return decision(outcome)
+ }
+
+ fun recordMediaEvidence(evidence: FaceTimeMediaEvidence): FaceTimeJoinDecision {
+ val currentBytes = evidence.mediaBytes
+ val previousBytes = previousInboundMediaBytes
+ val currentPeerId = evidence.peerId
+ val validBaseline = evidence.isConnected &&
+ currentPeerId != null &&
+ currentBytes != null &&
+ currentBytes in 1 until Long.MAX_VALUE
+ val mediaIsAdvancing = validBaseline &&
+ previousPeerId == currentPeerId &&
+ previousBytes != null &&
+ currentBytes > previousBytes
+
+ previousPeerId = if (validBaseline) currentPeerId else null
+ previousInboundMediaBytes = if (validBaseline) currentBytes else null
+
+ if (mediaIsAdvancing) {
+ joined = true
+ completedJoin = true
+ return decision(FaceTimeJoinOutcome.MEDIA_CONNECTED)
+ }
+
+ joined = false
+
+ val outcome = if (evidence.iceState == FaceTimeIceState.FAILED) {
+ FaceTimeJoinOutcome.MEDIA_FAILED
+ } else {
+ FaceTimeJoinOutcome.MEDIA_PENDING
+ }
+ return decision(outcome)
+ }
+
+ fun reset() {
+ attempts = 0
+ admissionRequested = false
+ joined = false
+ completedJoin = false
+ previousPeerId = null
+ previousInboundMediaBytes = null
+ }
+
+ private fun decision(outcome: FaceTimeJoinOutcome): FaceTimeJoinDecision = FaceTimeJoinDecision(
+ outcome = outcome,
+ joined = joined,
+ revealManualRecovery = joined || attempts >= manualRecoveryAttempt,
+ retry = !joined && !completedJoin && attempts < maxAttempts,
+ )
+
+ companion object {
+ fun parseOutcome(rawResult: String?): FaceTimeJoinOutcome {
+ val normalized = rawResult
+ ?.trim()
+ ?.removeSurrounding("\"")
+ ?.lowercase()
+
+ return when (normalized) {
+ "clicked" -> FaceTimeJoinOutcome.CLICKED
+ "already-joined" -> FaceTimeJoinOutcome.ALREADY_JOINED
+ "missing" -> FaceTimeJoinOutcome.MISSING
+ "disabled" -> FaceTimeJoinOutcome.DISABLED
+ "hidden" -> FaceTimeJoinOutcome.HIDDEN
+ else -> FaceTimeJoinOutcome.UNKNOWN
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaEvidenceParser.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaEvidenceParser.kt
new file mode 100644
index 0000000000..57a88b244b
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaEvidenceParser.kt
@@ -0,0 +1,117 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.json.JSONObject
+import java.math.BigDecimal
+import java.math.BigInteger
+
+/** Parses the JSON string returned by WebView.evaluateJavascript safely. */
+internal object FaceTimeMediaEvidenceParser {
+ fun parse(rawResult: String?): FaceTimeMediaEvidence? {
+ val raw = rawResult?.trim()
+ if (raw.isNullOrEmpty() || raw == "null" || raw == "undefined") return null
+
+ return try {
+ val payload = decodeJavascriptResult(raw)?.trim() ?: return null
+ if (!payload.startsWith("{") || !payload.endsWith("}")) return null
+ val decodedPayload = payload
+ JSONObject(decodedPayload)
+
+ FaceTimeMediaEvidence(
+ peerId = parseNullablePositiveInt(fieldValue(decodedPayload, "peerId")),
+ iceState = FaceTimeIceState.fromWireValue(
+ fieldValue(decodedPayload, "iceState")
+ .takeUnless { it == null || it.isBlank() || it == "null" },
+ ),
+ remoteAudioTracks = parseBoundedInt(fieldValue(decodedPayload, "remoteAudioTracks")),
+ remoteVideoTracks = parseBoundedInt(fieldValue(decodedPayload, "remoteVideoTracks")),
+ mediaBytes = parseNullableBoundedLong(fieldValue(decodedPayload, "mediaBytes")),
+ webLeaveVisible = fieldValue(decodedPayload, "webLeaveVisible")?.toBoolean() ?: false,
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun decodeJavascriptResult(raw: String): String? {
+ if (raw.startsWith("{")) return raw
+ if (!raw.startsWith("\"") || !raw.endsWith("\"")) return null
+
+ val result = StringBuilder()
+ var index = 1
+ val end = raw.length - 1
+ while (index < end) {
+ val character = raw[index]
+ if (character != '\\') {
+ if (character == '"') return null
+ result.append(character)
+ index += 1
+ continue
+ }
+
+ if (index + 1 >= end) return null
+ when (val escaped = raw[index + 1]) {
+ '"', '\\', '/' -> result.append(escaped)
+ 'b' -> result.append('\b')
+ 'f' -> result.append('\u000C')
+ 'n' -> result.append('\n')
+ 'r' -> result.append('\r')
+ 't' -> result.append('\t')
+ 'u' -> {
+ if (index + 5 >= end) return null
+ val codePoint = raw.substring(index + 2, index + 6).toIntOrNull(16) ?: return null
+ result.append(codePoint.toChar())
+ index += 4
+ }
+ else -> return null
+ }
+ index += 2
+ }
+ return result.toString()
+ }
+
+ private fun parseBoundedInt(value: String?): Int =
+ parseBoundedLong(value, Int.MAX_VALUE.toLong()).toInt()
+
+ private fun parseNullableBoundedLong(value: String?): Long? {
+ if (value == null || value == "null") return null
+ val decimal = value.toBigDecimalOrNull() ?: return 0L
+ if (decimal.signum() <= 0) return 0L
+ val integer = decimal.toBigIntegerExactOrNull() ?: return 0L
+ return if (integer > BigInteger.valueOf(Long.MAX_VALUE)) null else integer.toLong()
+ }
+
+ private fun parseNullablePositiveInt(value: String?): Int? {
+ if (value == null || value == "null") return null
+ val parsed = parseBoundedLong(value, Int.MAX_VALUE.toLong()).toInt()
+ return parsed.takeIf { it > 0 }
+ }
+
+ private fun fieldValue(payload: String, key: String): String? {
+ val token = Regex(
+ "\\\"${Regex.escape(key)}\\\"\\s*:\\s*(\\\"(?:\\\\.|[^\\\"\\\\])*\\\"|[^,}\\s]+)"
+ ).find(payload)?.groupValues?.get(1) ?: return null
+ if (!token.startsWith("\"") || !token.endsWith("\"")) return token
+ return token.substring(1, token.length - 1)
+ .replace("\\\"", "\"")
+ .replace("\\/", "/")
+ .replace("\\\\", "\\")
+ }
+
+ private fun parseBoundedLong(value: String?, maximum: Long): Long {
+ val decimal = value?.toBigDecimalOrNull() ?: return 0L
+ if (decimal.signum() <= 0) return 0L
+
+ val integer = decimal.toBigIntegerExactOrNull() ?: return 0L
+ return if (integer > BigInteger.valueOf(maximum)) {
+ maximum
+ } else {
+ integer.toLong()
+ }
+ }
+
+ private fun BigDecimal.toBigIntegerExactOrNull(): BigInteger? = try {
+ toBigIntegerExact()
+ } catch (_: ArithmeticException) {
+ null
+ }
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimePermissionPolicy.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimePermissionPolicy.kt
new file mode 100644
index 0000000000..39c0184e3a
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimePermissionPolicy.kt
@@ -0,0 +1,14 @@
+package com.bluebubbles.messaging.services.facetime
+
+/** Pure decision logic for starting the FaceTime in-call foreground service. */
+internal object FaceTimePermissionPolicy {
+ private const val grantedResult = 0
+
+ fun isGranted(grantResults: IntArray, index: Int): Boolean =
+ index >= 0 && grantResults.getOrNull(index) == grantedResult
+
+ fun shouldStartInCallService(permissionCount: Int, grantResults: IntArray): Boolean =
+ permissionCount > 0 &&
+ permissionCount == grantResults.size &&
+ grantResults.all { it == grantedResult }
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt
index 02ab55955d..55830ee081 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/intents/InternalIntentReceiver.kt
@@ -49,6 +49,7 @@ class InternalIntentReceiver: BroadcastReceiver() {
val notificationId: Int = intent.getIntExtra("notificationId", 0)
DeleteNotificationHandler().deleteNotification(context, notificationId, null)
FaceTimeActivity.cachedWebview?.let {
+ it.cancelCallbacks()
it.webView.destroy()
FaceTimeActivity.cachedWebview = null
}
@@ -115,4 +116,4 @@ class InternalIntentReceiver: BroadcastReceiver() {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt
index 8f531715df..9c4f1f6deb 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt
@@ -43,15 +43,39 @@ import uniffi.rust_lib_bluebubbles.setupKeystore
import uniffi.rust_lib_bluebubbles.start
class APNService : Service(), MsgReceiver {
+ companion object {
+ private const val RECEIVE_LOG_TAG = "RustPushReceive"
+ private const val MAX_PENDING_ENGINE_DISPATCHES = 256
+
+ @Volatile
+ private var activeService: APNService? = null
+
+ fun onMainEngineReady() {
+ activeService?.flushPendingMainEngineDispatches()
+ }
+
+ fun onMainEngineUnavailable() {
+ activeService?.handoffPendingMainEngineDispatches()
+ }
+ }
+
var pushState: NativePushState? = null
private var started = false
private val binder = APNBinder()
private var ready = false
private val waitingHandleCb = ArrayList<(handle: ULong) -> Unit>()
private val waitingStartedCb = ArrayList<() -> Unit>()
+ private val pendingMainEngineDispatches =
+ PendingApnDispatchQueue(MAX_PENDING_ENGINE_DISPATCHES)
+ private val mainHandler = Handler(Looper.getMainLooper())
private val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.IO + job)
+ override fun onCreate() {
+ super.onCreate()
+ activeService = this
+ }
+
fun ready() {
Log.i("launching agent", "ready")
synchronized(waitingHandleCb) {
@@ -94,15 +118,122 @@ class APNService : Service(), MsgReceiver {
}
override fun receievedMsg(ptr: ULong, retry: ULong) {
- Handler(Looper.getMainLooper()).post {
+ mainHandler.post {
+ if (MainActivity.engine != null && MainActivity.engine_ready) {
+ dispatchToMainEngine(PendingApnDispatch(ptr, retry))
+ return@post
+ }
+
if (MainActivity.engine != null) {
- // app is alive, deliver directly there
- MethodCallHandler.invokeMethod("APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString()))
+ val result = pendingMainEngineDispatches.enqueue(ptr, retry)
+ when {
+ result.evicted -> Log.w(
+ RECEIVE_LOG_TAG,
+ "engine_buffer_evicted retry=$retry pending_count=${result.size}",
+ )
+ result.added -> Log.i(
+ RECEIVE_LOG_TAG,
+ "engine_buffered retry=$retry pending_count=${result.size}",
+ )
+ else -> Log.d(
+ RECEIVE_LOG_TAG,
+ "engine_buffer_coalesced retry=$retry pending_count=${result.size}",
+ )
+ }
return@post
}
- CoroutineScope(Dispatchers.Main).launch {
- DartWorker.callMethod(this@APNService, "APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString()))
+
+ dispatchToHeadless(
+ listOf(PendingApnDispatch(ptr, retry)),
+ reason = "no_main_engine",
+ )
+ }
+ }
+
+ private fun dispatchToMainEngine(dispatch: PendingApnDispatch) {
+ MethodCallHandler.invokeMethod(
+ "APNMsg",
+ mapOf(
+ "pointer" to dispatch.pointer.toString(),
+ "retry" to dispatch.retry.toString(),
+ ),
+ )
+ }
+
+ private fun flushPendingMainEngineDispatches() {
+ runOnMainThread {
+ if (MainActivity.engine == null || !MainActivity.engine_ready) {
+ return@runOnMainThread
+ }
+
+ val dispatches = pendingMainEngineDispatches.drain()
+ if (dispatches.isEmpty()) {
+ return@runOnMainThread
}
+
+ Log.i(
+ RECEIVE_LOG_TAG,
+ "engine_buffer_flush count=${dispatches.size}",
+ )
+ for (dispatch in dispatches) {
+ dispatchToMainEngine(dispatch)
+ }
+ }
+ }
+
+ private fun handoffPendingMainEngineDispatches() {
+ runOnMainThread {
+ if (MainActivity.engine != null) {
+ if (MainActivity.engine_ready) {
+ flushPendingMainEngineDispatches()
+ }
+ return@runOnMainThread
+ }
+
+ val dispatches = pendingMainEngineDispatches.drain()
+ dispatchToHeadless(dispatches, reason = "main_engine_unavailable")
+ }
+ }
+
+ private fun dispatchToHeadless(
+ dispatches: List,
+ reason: String,
+ ) {
+ if (dispatches.isEmpty()) {
+ return
+ }
+
+ Log.i(
+ RECEIVE_LOG_TAG,
+ "headless_handoff reason=$reason count=${dispatches.size}",
+ )
+ CoroutineScope(Dispatchers.Main).launch {
+ for (dispatch in dispatches) {
+ Log.d(RECEIVE_LOG_TAG, "headless_dispatch retry=${dispatch.retry}")
+ try {
+ DartWorker.callMethod(
+ this@APNService,
+ "APNMsg",
+ mapOf(
+ "pointer" to dispatch.pointer.toString(),
+ "retry" to dispatch.retry.toString(),
+ ),
+ )
+ } catch (error: Exception) {
+ Log.w(
+ RECEIVE_LOG_TAG,
+ "headless_dispatch_failed retry=${dispatch.retry} error=${error.javaClass.simpleName}",
+ )
+ }
+ }
+ }
+ }
+
+ private inline fun runOnMainThread(crossinline block: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ block()
+ } else {
+ mainHandler.post { block() }
}
}
@@ -287,6 +418,16 @@ class APNService : Service(), MsgReceiver {
}
override fun onDestroy() {
+ if (activeService === this) {
+ activeService = null
+ }
+ val discarded = pendingMainEngineDispatches.clear()
+ if (discarded > 0) {
+ Log.i(
+ RECEIVE_LOG_TAG,
+ "engine_buffer_cleared_on_service_destroy count=$discarded",
+ )
+ }
super.onDestroy()
pushState?.destroy()
job.cancel()
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkPolicy.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkPolicy.kt
new file mode 100644
index 0000000000..d1f6998186
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkPolicy.kt
@@ -0,0 +1,43 @@
+package com.bluebubbles.messaging.services.rustpush
+
+/**
+ * Pure scheduling policy for the dormant Cloud Sync V2 Android adapter.
+ *
+ * This is intentionally independent of WorkManager so its safety invariants
+ * can be tested without starting Android framework code. It is a scheduling
+ * policy only: it does not authorize a CloudKit operation.
+ */
+internal enum class CloudSyncV2WorkKind {
+ METADATA,
+ AUTOMATIC_MEDIA,
+ USER_VISIBLE_MANUAL,
+}
+
+internal enum class CloudSyncV2NetworkRequirement {
+ CONNECTED,
+ UNMETERED,
+}
+
+internal data class CloudSyncV2WorkPolicy(
+ val networkRequirement: CloudSyncV2NetworkRequirement,
+ val requiresBatteryNotLow: Boolean = true,
+ val requiresStorageNotLow: Boolean = true,
+ val initialDelayMillis: Long = CloudSyncV2WorkPolicy.COALESCE_DELAY_MILLIS,
+ /**
+ * Kept explicit so a future user-visible flow cannot accidentally become
+ * expedited. V2 currently never requests expedited execution.
+ */
+ val requestsExpeditedExecution: Boolean = false,
+) {
+ companion object {
+ const val COALESCE_DELAY_MILLIS = 15_000L
+
+ fun forKind(kind: CloudSyncV2WorkKind): CloudSyncV2WorkPolicy = when (kind) {
+ CloudSyncV2WorkKind.METADATA,
+ CloudSyncV2WorkKind.USER_VISIBLE_MANUAL ->
+ CloudSyncV2WorkPolicy(CloudSyncV2NetworkRequirement.CONNECTED)
+ CloudSyncV2WorkKind.AUTOMATIC_MEDIA ->
+ CloudSyncV2WorkPolicy(CloudSyncV2NetworkRequirement.UNMETERED)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkScheduler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkScheduler.kt
new file mode 100644
index 0000000000..376c99496b
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkScheduler.kt
@@ -0,0 +1,91 @@
+package com.bluebubbles.messaging.services.rustpush
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import java.security.MessageDigest
+import java.util.concurrent.TimeUnit
+
+/**
+ * Android-side durable scheduling boundary for Cloud Sync V2.
+ *
+ * The default gate is closed. IDS/APNs callers may eventually submit a hint to
+ * this class, but must return immediately and never perform CloudKit work on
+ * that latency-sensitive path. WorkManager's unique-work KEEP semantics only
+ * coalesce wake hints; it never replaces ObjectBox coordinator leases.
+ */
+internal object CloudSyncV2WorkScheduler {
+ const val INPUT_SCOPE_HASH = "cloud_sync_v2_scope_hash"
+ const val INPUT_WORK_KIND = "cloud_sync_v2_work_kind"
+ private const val UNIQUE_WORK_PREFIX = "cloud-sync-v2/"
+
+ /** Closed unless a future reviewed composition explicitly opens it. */
+ @Volatile
+ var schedulingEnabled: Boolean = false
+
+ fun enqueueHint(
+ context: Context,
+ scopeKey: String,
+ kind: CloudSyncV2WorkKind = CloudSyncV2WorkKind.METADATA,
+ ): Boolean {
+ if (!schedulingEnabled) return false
+
+ val scopeHash = hashScope(scopeKey)
+ val policy = CloudSyncV2WorkPolicy.forKind(kind)
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(
+ when (policy.networkRequirement) {
+ CloudSyncV2NetworkRequirement.CONNECTED -> NetworkType.CONNECTED
+ CloudSyncV2NetworkRequirement.UNMETERED -> NetworkType.UNMETERED
+ },
+ )
+ .setRequiresBatteryNotLow(policy.requiresBatteryNotLow)
+ .setRequiresStorageNotLow(policy.requiresStorageNotLow)
+ .build()
+
+ val request = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .setInitialDelay(policy.initialDelayMillis, TimeUnit.MILLISECONDS)
+ .setInputData(
+ androidx.work.Data.Builder()
+ .putString(INPUT_SCOPE_HASH, scopeHash)
+ .putString(INPUT_WORK_KIND, kind.name)
+ .build(),
+ )
+ .addTag(UNIQUE_WORK_PREFIX + scopeHash)
+ // Do not use setExpedited. Even USER_VISIBLE_MANUAL remains normal
+ // work until a separately reviewed foreground/user-visible design
+ // can prove Android policy compliance.
+ .build()
+
+ WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
+ uniqueWorkName(scopeHash),
+ ExistingWorkPolicy.KEEP,
+ request,
+ )
+ return true
+ }
+
+ fun cancel(context: Context, scopeKey: String) {
+ WorkManager.getInstance(context.applicationContext).cancelUniqueWork(
+ uniqueWorkName(hashScope(scopeKey)),
+ )
+ }
+
+ internal fun uniqueWorkNameForScopeKey(scopeKey: String): String =
+ uniqueWorkName(hashScope(scopeKey))
+
+ internal fun hashScope(scopeKey: String): String {
+ require(scopeKey.isNotBlank()) { "Cloud Sync V2 scope key must not be blank" }
+ return MessageDigest.getInstance("SHA-256")
+ .digest(scopeKey.toByteArray(Charsets.UTF_8))
+ .joinToString(separator = "") { byte ->
+ "%02x".format(byte.toInt() and 0xff)
+ }
+ }
+
+ private fun uniqueWorkName(scopeHash: String): String = UNIQUE_WORK_PREFIX + scopeHash
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2Worker.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2Worker.kt
new file mode 100644
index 0000000000..168174ab04
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2Worker.kt
@@ -0,0 +1,34 @@
+package com.bluebubbles.messaging.services.rustpush
+
+import android.content.Context
+import android.util.Log
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.bluebubbles.messaging.Constants
+
+/**
+ * Durable WorkManager endpoint for the future Cloud Sync V2 handoff.
+ *
+ * This deliberately performs no CloudKit work. WorkManager preserves the
+ * request across process death, but ObjectBox's coordinator lease remains the
+ * only authority that may later claim and run a scoped sync. A future rollout
+ * must add that lease-aware handoff behind its own reviewed feature gate.
+ */
+class CloudSyncV2Worker(
+ appContext: Context,
+ params: WorkerParameters,
+) : Worker(appContext, params) {
+ override fun doWork(): Result {
+ val scopeHash = inputData.getString(CloudSyncV2WorkScheduler.INPUT_SCOPE_HASH)
+ val kind = inputData.getString(CloudSyncV2WorkScheduler.INPUT_WORK_KIND)
+ if (scopeHash.isNullOrBlank() || kind.isNullOrBlank()) {
+ Log.w(Constants.logTag, "Cloud Sync V2 work rejected: missing safe scheduling input")
+ return Result.failure()
+ }
+
+ // Deliberate no-op while V2 remains disabled. In particular, do not
+ // initialize a Flutter engine or call Rust/CloudKit from this worker.
+ Log.i(Constants.logTag, "Cloud Sync V2 durable handoff reached kind=$kind")
+ return Result.success()
+ }
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/PendingApnDispatchQueue.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/PendingApnDispatchQueue.kt
new file mode 100644
index 0000000000..18e6bb5c82
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/PendingApnDispatchQueue.kt
@@ -0,0 +1,75 @@
+package com.bluebubbles.messaging.services.rustpush
+
+internal data class PendingApnDispatch(
+ val pointer: ULong,
+ val retry: ULong,
+)
+
+internal data class PendingApnEnqueueResult(
+ val added: Boolean,
+ val evicted: Boolean,
+ val size: Int,
+)
+
+/**
+ * A short-lived Android-side dispatch buffer for APNs pointers received while
+ * the main Flutter engine exists but has not registered its method channel yet.
+ *
+ * Rust remains the source of truth for retrying and dropping each pointer.
+ * Removing an entry here never acknowledges or removes it from the Rust queue.
+ */
+internal class PendingApnDispatchQueue(
+ private val capacity: Int,
+) {
+ private val pending = LinkedHashMap()
+
+ init {
+ require(capacity > 0) { "capacity must be positive" }
+ }
+
+ @Synchronized
+ fun enqueue(pointer: ULong, retry: ULong): PendingApnEnqueueResult {
+ val existing = pending[pointer]
+ if (existing != null) {
+ if (retry > existing.retry) {
+ pending[pointer] = existing.copy(retry = retry)
+ }
+ return PendingApnEnqueueResult(
+ added = false,
+ evicted = false,
+ size = pending.size,
+ )
+ }
+
+ var evicted = false
+ if (pending.size >= capacity) {
+ val oldest = pending.entries.iterator()
+ if (oldest.hasNext()) {
+ oldest.next()
+ oldest.remove()
+ evicted = true
+ }
+ }
+
+ pending[pointer] = PendingApnDispatch(pointer, retry)
+ return PendingApnEnqueueResult(
+ added = true,
+ evicted = evicted,
+ size = pending.size,
+ )
+ }
+
+ @Synchronized
+ fun drain(): List {
+ val result = pending.values.toList()
+ pending.clear()
+ return result
+ }
+
+ @Synchronized
+ fun clear(): Int {
+ val size = pending.size
+ pending.clear()
+ return size
+ }
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NativeSyncIsolateHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NativeSyncIsolateHandler.kt
index f88b2b592d..c137eae5cf 100644
--- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NativeSyncIsolateHandler.kt
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NativeSyncIsolateHandler.kt
@@ -5,13 +5,12 @@ import android.util.Log
import com.bluebubbles.messaging.Constants
import com.bluebubbles.messaging.models.MethodCallHandlerImpl
import com.bluebubbles.messaging.services.backend_ui_interop.MethodCallHandler
+import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
-import io.flutter.embedding.engine.loader.ApplicationInfoLoader
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
-import io.flutter.view.FlutterMain
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -42,11 +41,12 @@ class NativeSyncIsolateHandler : MethodCallHandlerImpl() {
return
}
- FlutterMain.startInitialization(context)
- FlutterMain.ensureInitializationComplete(context, null)
+ val flutterLoader = FlutterInjector.instance().flutterLoader()
+ flutterLoader.startInitialization(context)
+ flutterLoader.ensureInitializationComplete(context, null)
+ val appBundlePath = flutterLoader.findAppBundlePath()
Log.d(Constants.logTag, "Loading callback info")
- val info = ApplicationInfoLoader.load(context)
val workerEngine = FlutterEngine(context)
engine = workerEngine
MethodChannel(workerEngine.dartExecutor.binaryMessenger, Constants.methodChannel).setMethodCallHandler {
@@ -63,9 +63,9 @@ class NativeSyncIsolateHandler : MethodCallHandlerImpl() {
}
}
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(context.getSharedPreferences("FlutterSharedPreferences", 0).getLong("flutter.backgroundSyncIsolate", -1))
- val callback = DartExecutor.DartCallback(context.assets, info.flutterAssetsDir, callbackInfo)
+ val callback = DartExecutor.DartCallback(context.assets, appBundlePath, callbackInfo)
Log.d(Constants.logTag, "Executing Dart callback")
workerEngine.dartExecutor.executeDartCallback(callback)
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NearbyFindMyAccessoryHandler.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NearbyFindMyAccessoryHandler.kt
new file mode 100644
index 0000000000..7282337f6e
--- /dev/null
+++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/system/NearbyFindMyAccessoryHandler.kt
@@ -0,0 +1,574 @@
+package com.bluebubbles.messaging.services.system
+
+import android.Manifest
+import android.app.Activity
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothManager
+import android.bluetooth.le.BluetoothLeScanner
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.ParcelUuid
+import com.bluebubbles.messaging.models.MethodCallHandlerImpl
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import java.util.Locale
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Foreground-only, user-triggered nearby sound support for compatible Find My trackers.
+ *
+ * The protocol compatibility was informed by the Apache-2.0 AirGuard project, especially
+ * its AppleFindMy model and BluetoothLeService. This is an original, deliberately narrow
+ * implementation: it never exposes addresses or advertisement bytes, and it does not run
+ * in the background or claim ownership of a discovered tracker.
+ */
+class NearbyFindMyAccessoryHandler private constructor() : MethodCallHandlerImpl() {
+ companion object {
+ const val scanTag = "scanNearbyFindMyAccessories"
+ const val playTag = "playNearbyFindMyAccessorySound"
+
+ val instance: NearbyFindMyAccessoryHandler by lazy { NearbyFindMyAccessoryHandler() }
+
+ private const val defaultScanDurationMs = 8_000L
+ private const val minScanDurationMs = 3_000L
+ private const val maxScanDurationMs = 15_000L
+ private const val tokenLifetimeMs = 60_000L
+ private const val soundDurationMs = 5_000L
+ private const val operationTimeoutMs = 20_000L
+ private const val cccdUuidString = "00002902-0000-1000-8000-00805f9b34fb"
+ private const val appleManufacturerId = 0x004C
+ private const val findMyAdvertisementType = 0x12
+ private const val separatedFindMyAdvertisementType = 0x19
+
+ private val dultServiceUuid = UUID.fromString("15190001-12F4-C226-88ED-2AC5579F2A85")
+ private val dultCharacteristicUuid = UUID.fromString("8E0C0001-1D68-FB92-BF61-48377421680E")
+ private val findMyCharacteristicUuid = UUID.fromString("4F860003-943B-49EF-BED4-2F730304427A")
+ private val airtagServiceUuid = UUID.fromString("7DFC9000-7D1C-4951-86AA-8D9728F8D66C")
+ private val airtagCharacteristicUuid = UUID.fromString("7DFC9001-7D1C-4951-86AA-8D9728F8D66C")
+ }
+
+ private enum class Protocol(val wireName: String) {
+ DULT("dult"),
+ FIND_MY("find_my"),
+ AIRTAG("airtag")
+ }
+
+ private enum class CommandPhase { START, STOP }
+
+ private data class TokenEntry(
+ val device: BluetoothDevice,
+ val protocol: Protocol,
+ val expiresAtMs: Long,
+ )
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val tokenLock = Any()
+ private val tokens = HashMap()
+ private var activeScan: ScanOperation? = null
+
+ override fun handleMethodCall(
+ call: MethodCall,
+ result: MethodChannel.Result,
+ context: Context,
+ ) {
+ when (call.method) {
+ scanTag -> scan(call, result, context)
+ playTag -> play(call, result, context)
+ else -> result.error("NOT_IMPLEMENTED", "Unsupported nearby Find My method", null)
+ }
+ }
+
+ private fun scan(call: MethodCall, result: MethodChannel.Result, context: Context) {
+ val once = OnceResult(result)
+ if (!isUsableForeground(context)) {
+ once.error("FOREGROUND_REQUIRED", "Nearby tracker actions require the app to be open", null)
+ return
+ }
+
+ val permissionError = permissionError(context, requireConnect = true)
+ if (permissionError != null) {
+ once.error("PERMISSION_DENIED", permissionError, null)
+ return
+ }
+
+ val adapter = bluetoothAdapter(context)
+ if (adapter == null) {
+ once.error("BLUETOOTH_UNAVAILABLE", "This device does not provide Bluetooth", null)
+ return
+ }
+ if (!adapter.isEnabled) {
+ once.error("BLUETOOTH_DISABLED", "Bluetooth is turned off", null)
+ return
+ }
+
+ val scanner = try {
+ adapter.bluetoothLeScanner
+ } catch (_: SecurityException) {
+ null
+ }
+ if (scanner == null) {
+ once.error("BLUETOOTH_UNAVAILABLE", "Bluetooth scanning is unavailable", null)
+ return
+ }
+
+ val requestedDuration = call.argument("scanDurationMs")?.toLong() ?: defaultScanDurationMs
+ val durationMs = requestedDuration.coerceIn(minScanDurationMs, maxScanDurationMs)
+ synchronized(tokenLock) {
+ expireTokensLocked(System.currentTimeMillis())
+ if (activeScan != null) {
+ once.error("SCAN_IN_PROGRESS", "A nearby tracker scan is already running", null)
+ return
+ }
+ val operation = ScanOperation(context.applicationContext, scanner, once, durationMs)
+ activeScan = operation
+ operation.start()
+ }
+ }
+
+ private fun play(call: MethodCall, result: MethodChannel.Result, context: Context) {
+ val once = OnceResult(result)
+ if (!isUsableForeground(context)) {
+ once.error("FOREGROUND_REQUIRED", "Nearby tracker actions require the app to be open", null)
+ return
+ }
+
+ val permissionError = permissionError(context, requireConnect = true)
+ if (permissionError != null) {
+ once.error("PERMISSION_DENIED", permissionError, null)
+ return
+ }
+
+ val token = call.argument("token")?.trim()
+ if (token.isNullOrEmpty()) {
+ once.error("INVALID_TOKEN", "A nearby tracker token is required", null)
+ return
+ }
+
+ val entry = synchronized(tokenLock) {
+ expireTokensLocked(System.currentTimeMillis())
+ tokens.remove(token)
+ }
+ if (entry == null) {
+ once.error("TOKEN_EXPIRED", "That nearby tracker result has expired", null)
+ return
+ }
+
+ val adapter = bluetoothAdapter(context)
+ if (adapter == null) {
+ once.error("BLUETOOTH_UNAVAILABLE", "This device does not provide Bluetooth", null)
+ return
+ }
+ if (!adapter.isEnabled) {
+ once.error("BLUETOOTH_DISABLED", "Bluetooth is turned off", null)
+ return
+ }
+
+ SoundGattSession(context.applicationContext, entry, once).start()
+ }
+
+ private fun bluetoothAdapter(context: Context): BluetoothAdapter? {
+ val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return null
+ return try {
+ manager.adapter
+ } catch (_: SecurityException) {
+ null
+ }
+ }
+
+ private fun permissionError(context: Context, requireConnect: Boolean): String? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null
+ if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
+ return "Nearby device scan permission is not granted"
+ }
+ if (requireConnect && context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
+ return "Nearby device connection permission is not granted"
+ }
+ return null
+ }
+
+ private fun isUsableForeground(context: Context): Boolean {
+ val activity = context as? Activity ?: return false
+ return !activity.isFinishing && (Build.VERSION.SDK_INT < 17 || !activity.isDestroyed)
+ }
+
+ private fun expireTokensLocked(nowMs: Long) {
+ tokens.entries.removeIf { it.value.expiresAtMs <= nowMs }
+ }
+
+ private inner class ScanOperation(
+ private val context: Context,
+ private val scanner: BluetoothLeScanner,
+ private val result: OnceResult,
+ private val durationMs: Long,
+ ) {
+ private val completed = AtomicBoolean(false)
+ private val devicesByAddress = HashMap()
+ private val timeout = Runnable { finishSuccess() }
+ private val callback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, scanResult: ScanResult) {
+ record(scanResult)
+ }
+
+ override fun onBatchScanResults(results: MutableList) {
+ results.forEach(::record)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ finishError("SCAN_FAILED", "Bluetooth scan failed", errorCode)
+ }
+ }
+
+ fun start() {
+ try {
+ scanner.startScan(
+ null,
+ ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .setReportDelay(0L)
+ .build(),
+ callback,
+ )
+ mainHandler.postDelayed(timeout, durationMs)
+ } catch (_: SecurityException) {
+ finishError("PERMISSION_DENIED", "Bluetooth scan permission is not granted", null)
+ } catch (_: Exception) {
+ finishError("BLUETOOTH_UNAVAILABLE", "Bluetooth scanning is unavailable", null)
+ }
+ }
+
+ private fun record(scanResult: ScanResult) {
+ if (completed.get()) return
+ val protocol = protocolFor(scanResult.scanRecord) ?: return
+ try {
+ val address = scanResult.device.address
+ val previous = devicesByAddress[address]
+ if (previous == null || scanResult.rssi > previous.rssi) {
+ devicesByAddress[address] = DiscoveredDevice(scanResult.device, protocol, scanResult.rssi)
+ }
+ } catch (_: SecurityException) {
+ finishError("PERMISSION_DENIED", "Bluetooth connection permission is not granted", null)
+ }
+ }
+
+ private fun finishSuccess() {
+ if (!completed.compareAndSet(false, true)) return
+ stop()
+ val now = System.currentTimeMillis()
+ val output = synchronized(tokenLock) {
+ expireTokensLocked(now)
+ devicesByAddress.values.sortedByDescending { it.rssi }.map { discovered ->
+ val token = UUID.randomUUID().toString()
+ tokens[token] = TokenEntry(
+ device = discovered.device,
+ protocol = discovered.protocol,
+ expiresAtMs = now + tokenLifetimeMs,
+ )
+ mapOf(
+ "token" to token,
+ "protocol" to discovered.protocol.wireName,
+ "signal" to signalFor(discovered.rssi),
+ "rssi" to discovered.rssi,
+ )
+ }
+ }
+ result.success(output)
+ synchronized(tokenLock) {
+ if (activeScan === this) activeScan = null
+ }
+ }
+
+ private fun finishError(code: String, message: String, details: Any?) {
+ if (!completed.compareAndSet(false, true)) return
+ stop()
+ result.error(code, message, details)
+ synchronized(tokenLock) {
+ if (activeScan === this) activeScan = null
+ }
+ }
+
+ private fun stop() {
+ mainHandler.removeCallbacks(timeout)
+ try {
+ scanner.stopScan(callback)
+ } catch (_: SecurityException) {
+ // The operation is already completing. Do not call the result twice.
+ }
+ }
+ }
+
+ private data class DiscoveredDevice(
+ val device: BluetoothDevice,
+ val protocol: Protocol,
+ val rssi: Int,
+ )
+
+ private inner class SoundGattSession(
+ private val context: Context,
+ private val entry: TokenEntry,
+ private val result: OnceResult,
+ ) : BluetoothGattCallback() {
+ private val completed = AtomicBoolean(false)
+ private val callbackTimeout = Runnable { fail("GATT_TIMEOUT", "The nearby tracker did not respond") }
+ private var stopRunnable: Runnable? = null
+ private var gatt: BluetoothGatt? = null
+ private var characteristic: BluetoothGattCharacteristic? = null
+ private var activeProtocol = entry.protocol
+ private var commandPhase = CommandPhase.START
+ private var started = false
+
+ fun start() {
+ try {
+ gatt = entry.device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
+ if (gatt == null) {
+ fail("GATT_UNAVAILABLE", "Could not connect to the nearby tracker")
+ return
+ }
+ mainHandler.postDelayed(callbackTimeout, operationTimeoutMs)
+ } catch (_: SecurityException) {
+ fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted")
+ } catch (_: Exception) {
+ fail("GATT_UNAVAILABLE", "Could not connect to the nearby tracker")
+ }
+ }
+
+ override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
+ if (completed.get()) return
+ if (status == 19 && activeProtocol == Protocol.AIRTAG && started) {
+ succeed()
+ return
+ }
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ fail("GATT_CONNECTION_FAILED", "The nearby tracker connection failed")
+ return
+ }
+ if (newState == BluetoothGatt.STATE_CONNECTED) {
+ try {
+ if (!gatt.discoverServices()) {
+ fail("GATT_DISCOVERY_FAILED", "Could not inspect the nearby tracker")
+ }
+ } catch (_: SecurityException) {
+ fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted")
+ }
+ } else if (newState == BluetoothGatt.STATE_DISCONNECTED && !started) {
+ fail("GATT_DISCONNECTED", "The nearby tracker disconnected before sounding")
+ }
+ }
+
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
+ if (completed.get()) return
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ fail("GATT_DISCOVERY_FAILED", "Could not inspect the nearby tracker")
+ return
+ }
+
+ val protocols = listOf(entry.protocol) + Protocol.values().filter { it != entry.protocol }
+ val match = protocols.firstNotNullOfOrNull { protocol ->
+ findService(gatt, protocol)?.getCharacteristic(characteristicUuidFor(protocol))?.let { protocol to it }
+ }
+ if (match == null) {
+ fail("UNSUPPORTED_TRACKER", "The nearby tracker sound protocol is unavailable")
+ return
+ }
+ activeProtocol = match.first
+ val target = match.second
+ characteristic = target
+
+ if (activeProtocol == Protocol.AIRTAG) {
+ writeCommand(byteArrayOf(0xAF.toByte()))
+ } else {
+ enableNotifications(gatt, target)
+ }
+ }
+
+ override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
+ if (completed.get()) return
+ if (descriptor.uuid.toString().equals(cccdUuidString, ignoreCase = true) && status == BluetoothGatt.GATT_SUCCESS) {
+ writeCommand(startCommandFor(activeProtocol))
+ } else {
+ fail("GATT_NOTIFICATION_FAILED", "Could not prepare the nearby tracker sound command")
+ }
+ }
+
+ override fun onCharacteristicWrite(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: Int,
+ ) {
+ if (completed.get()) return
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ fail("GATT_WRITE_FAILED", "The nearby tracker rejected the sound command")
+ return
+ }
+
+ if (commandPhase == CommandPhase.START) {
+ started = true
+ if (activeProtocol == Protocol.AIRTAG) {
+ // First-generation AirTags confirm the trigger by closing the GATT link
+ // with status 19. Keep the session open until that confirmation arrives.
+ return
+ } else {
+ commandPhase = CommandPhase.STOP
+ stopRunnable = Runnable {
+ if (!completed.get()) writeCommand(stopCommandFor(activeProtocol))
+ }
+ mainHandler.postDelayed(stopRunnable!!, soundDurationMs)
+ }
+ } else {
+ succeed()
+ }
+ }
+
+ private fun findService(gatt: BluetoothGatt, protocol: Protocol) = gatt.services.firstOrNull { service ->
+ when (protocol) {
+ Protocol.DULT -> service.uuid == dultServiceUuid
+ Protocol.FIND_MY -> service.uuid.toString().lowercase(Locale.US).contains("fd44")
+ Protocol.AIRTAG -> service.uuid == airtagServiceUuid
+ }
+ }
+
+ private fun characteristicUuidFor(protocol: Protocol): UUID = when (protocol) {
+ Protocol.DULT -> dultCharacteristicUuid
+ Protocol.FIND_MY -> findMyCharacteristicUuid
+ Protocol.AIRTAG -> airtagCharacteristicUuid
+ }
+
+ private fun startCommandFor(protocol: Protocol): ByteArray = when (protocol) {
+ Protocol.DULT -> byteArrayOf(0x00, 0x03)
+ Protocol.FIND_MY -> byteArrayOf(0x01, 0x00, 0x03)
+ Protocol.AIRTAG -> byteArrayOf(0xAF.toByte())
+ }
+
+ private fun stopCommandFor(protocol: Protocol): ByteArray = when (protocol) {
+ Protocol.DULT -> byteArrayOf(0x01, 0x03)
+ Protocol.FIND_MY -> byteArrayOf(0x01, 0x01, 0x03)
+ Protocol.AIRTAG -> byteArrayOf(0xAF.toByte())
+ }
+
+ private fun enableNotifications(gatt: BluetoothGatt, target: BluetoothGattCharacteristic) {
+ try {
+ if (!gatt.setCharacteristicNotification(target, true)) {
+ fail("GATT_NOTIFICATION_FAILED", "Could not enable nearby tracker notifications")
+ return
+ }
+ val descriptor = target.getDescriptor(UUID.fromString(cccdUuidString))
+ if (descriptor == null) {
+ fail("GATT_NOTIFICATION_FAILED", "The nearby tracker has no notification control")
+ return
+ }
+ val cccdValue = if (
+ (target.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0
+ ) BluetoothGattDescriptor.ENABLE_INDICATION_VALUE else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ val accepted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gatt.writeDescriptor(descriptor, cccdValue) == BluetoothGatt.GATT_SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ descriptor.value = cccdValue
+ @Suppress("DEPRECATION")
+ gatt.writeDescriptor(descriptor)
+ }
+ if (!accepted) fail("GATT_NOTIFICATION_FAILED", "Could not prepare the nearby tracker sound command")
+ } catch (_: SecurityException) {
+ fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted")
+ }
+ }
+
+ private fun writeCommand(value: ByteArray) {
+ val gatt = gatt ?: return fail("GATT_UNAVAILABLE", "The nearby tracker connection is gone")
+ val target = characteristic ?: return fail("GATT_UNAVAILABLE", "The nearby tracker sound channel is gone")
+ try {
+ val accepted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ gatt.writeCharacteristic(target, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == BluetoothGatt.GATT_SUCCESS
+ } else {
+ @Suppress("DEPRECATION")
+ target.value = value
+ @Suppress("DEPRECATION")
+ gatt.writeCharacteristic(target)
+ }
+ if (!accepted) fail("GATT_WRITE_FAILED", "The nearby tracker rejected the sound command")
+ } catch (_: SecurityException) {
+ fail("PERMISSION_DENIED", "Bluetooth connection permission is not granted")
+ }
+ }
+
+ private fun fail(code: String, message: String) {
+ if (!completed.compareAndSet(false, true)) return
+ cleanup()
+ result.error(code, message, null)
+ }
+
+ private fun succeed() {
+ if (!completed.compareAndSet(false, true)) return
+ cleanup()
+ result.success(true)
+ }
+
+ private fun cleanup() {
+ completed.set(true)
+ mainHandler.removeCallbacks(callbackTimeout)
+ stopRunnable?.let(mainHandler::removeCallbacks)
+ stopRunnable = null
+ val currentGatt = gatt
+ gatt = null
+ try {
+ currentGatt?.disconnect()
+ currentGatt?.close()
+ } catch (_: SecurityException) {
+ // Cleanup is best effort after the one result has been completed.
+ }
+ }
+
+ }
+
+ private class OnceResult(private val delegate: MethodChannel.Result) {
+ private val completed = AtomicBoolean(false)
+
+ fun success(value: Any?) {
+ if (completed.compareAndSet(false, true)) delegate.success(value)
+ }
+
+ fun error(code: String, message: String, details: Any?) {
+ if (completed.compareAndSet(false, true)) delegate.error(code, message, details)
+ }
+ }
+
+ private fun protocolFor(scanRecord: android.bluetooth.le.ScanRecord?): Protocol? {
+ val normalized = (scanRecord?.serviceUuids ?: emptyList())
+ .map { it.uuid.toString().lowercase(Locale.US) }
+ // Legacy/separated Find My advertisements often expose no service UUID. AirGuard's
+ // AppleFindMy detector identifies this Apple company-data format as type 0x12 with
+ // the separated/offline marker 0x19 in the second byte. Treat this only as a scan
+ // hint. GATT discovery below independently tries every supported sound protocol.
+ val appleFindMyAdvertisement = scanRecord
+ ?.getManufacturerSpecificData(appleManufacturerId)
+ ?.let { data ->
+ data.size >= 2 &&
+ (data[0].toInt() and 0xFF) == findMyAdvertisementType &&
+ (data[1].toInt() and 0xFF) == separatedFindMyAdvertisementType
+ } == true
+ return when {
+ normalized.any { it == dultServiceUuid.toString().lowercase(Locale.US) } -> Protocol.DULT
+ normalized.any { it.contains("fd44") } -> Protocol.FIND_MY
+ normalized.any { it == airtagServiceUuid.toString().lowercase(Locale.US) } -> Protocol.AIRTAG
+ appleFindMyAdvertisement -> Protocol.AIRTAG
+ else -> null
+ }
+ }
+
+ private fun signalFor(rssi: Int): String = when {
+ rssi >= -60 -> "strong"
+ rssi >= -80 -> "medium"
+ else -> "weak"
+ }
+}
diff --git a/android/app/src/main/res/layout/activity_face_time.xml b/android/app/src/main/res/layout/activity_face_time.xml
index 00447c806d..8e0c5c5545 100644
--- a/android/app/src/main/res/layout/activity_face_time.xml
+++ b/android/app/src/main/res/layout/activity_face_time.xml
@@ -140,4 +140,51 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/FlutterBackgroundIsolateEmbeddingContractTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/FlutterBackgroundIsolateEmbeddingContractTest.kt
new file mode 100644
index 0000000000..e1b75d5306
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/backend_ui_interop/FlutterBackgroundIsolateEmbeddingContractTest.kt
@@ -0,0 +1,113 @@
+package com.bluebubbles.messaging.services.backend_ui_interop
+
+import java.io.File
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FlutterBackgroundIsolateEmbeddingContractTest {
+ private val sourceRoot = locateSourceRoot()
+
+ @Test
+ fun `background isolate paths use the current Flutter loader`() {
+ for (source in backgroundIsolateSources()) {
+ assertTrue(
+ "${source.name} must obtain FlutterLoader from FlutterInjector",
+ source.text.contains("FlutterInjector.instance().flutterLoader()"),
+ )
+ assertOrdered(
+ source,
+ "flutterLoader.startInitialization(",
+ "flutterLoader.ensureInitializationComplete(",
+ "flutterLoader.findAppBundlePath()",
+ "DartExecutor.DartCallback(",
+ "executeDartCallback(callback)",
+ )
+ }
+ }
+
+ @Test
+ fun `callback handles retain their exact shared preference keys`() {
+ val worker = readSource(
+ "services/backend_ui_interop/DartWorker.kt",
+ )
+ val nativeSync = readSource(
+ "services/system/NativeSyncIsolateHandler.kt",
+ )
+
+ assertTrue(
+ worker.text.contains(
+ "FlutterCallbackInformation.lookupCallbackInformation(",
+ ),
+ )
+ assertTrue(worker.text.contains("\"flutter.backgroundCallbackHandle\""))
+ assertTrue(
+ nativeSync.text.contains(
+ "FlutterCallbackInformation.lookupCallbackInformation(",
+ ),
+ )
+ assertTrue(nativeSync.text.contains("\"flutter.backgroundSyncIsolate\""))
+ }
+
+ @Test
+ fun `background isolate sources contain no removed v1 embedding APIs`() {
+ val removedApis = listOf(
+ "io.flutter.view.Flutter" + "Main",
+ "Flutter" + "Main.",
+ "PluginRegistry." + "Registrar",
+ "ShimPlugin" + "Registry",
+ "Shim" + "Registrar",
+ )
+
+ for (source in backgroundIsolateSources()) {
+ for (removedApi in removedApis) {
+ assertFalse(
+ "${source.name} still references removed API $removedApi",
+ source.text.contains(removedApi),
+ )
+ }
+ }
+ }
+
+ private fun backgroundIsolateSources(): List = listOf(
+ readSource("services/backend_ui_interop/DartWorker.kt"),
+ readSource("services/system/NativeSyncIsolateHandler.kt"),
+ )
+
+ private fun readSource(relativePath: String): KotlinSource {
+ val file = File(sourceRoot, relativePath)
+ assertTrue("Missing Android source file: ${file.absolutePath}", file.isFile)
+ return KotlinSource(file.name, file.readText())
+ }
+
+ private fun assertOrdered(source: KotlinSource, vararg snippets: String) {
+ var previousIndex = -1
+ for (snippet in snippets) {
+ val index = source.text.indexOf(snippet)
+ assertTrue(
+ "${source.name} is missing or reorders '$snippet'",
+ index > previousIndex,
+ )
+ previousIndex = index
+ }
+ }
+
+ private data class KotlinSource(
+ val name: String,
+ val text: String,
+ )
+
+ companion object {
+ private fun locateSourceRoot(): File {
+ val candidates = listOf(
+ File("src/main/kotlin/com/bluebubbles/messaging"),
+ File("android/app/src/main/kotlin/com/bluebubbles/messaging"),
+ )
+ return candidates.firstOrNull(File::isDirectory)
+ ?: error(
+ "Unable to locate Android source root from " +
+ File(".").absolutePath,
+ )
+ }
+ }
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebviewTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebviewTest.kt
new file mode 100644
index 0000000000..fac30b0230
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/CachedWebviewTest.kt
@@ -0,0 +1,21 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CachedWebviewTest {
+ @Test
+ fun `javascript string literal escapes injection characters and line separators`() {
+ val value = "A\"B\\C\nD\rE\u2028F\u2029G\u0000H"
+
+ assertEquals(
+ "\"A\\\"B\\\\C\\nD\\rE\\u2028F\\u2029G\\u0000H\"",
+ CachedWebview.javascriptStringLiteral(value),
+ )
+ }
+
+ @Test
+ fun `javascript string literal preserves ordinary unicode`() {
+ assertEquals("\"Rami 👋 مرحبًا\"", CachedWebview.javascriptStringLiteral("Rami 👋 مرحبًا"))
+ }
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnosticsTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnosticsTest.kt
new file mode 100644
index 0000000000..8e22b66c88
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeDiagnosticsTest.kt
@@ -0,0 +1,36 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FaceTimeDiagnosticsTest {
+ @Test
+ fun diagnosticsRequireDeveloperModeAndExplicitOptIn() {
+ assertFalse(FaceTimeDiagnostics.shouldEnable(false, false))
+ assertFalse(FaceTimeDiagnostics.shouldEnable(false, true))
+ assertFalse(FaceTimeDiagnostics.shouldEnable(true, false))
+ assertTrue(FaceTimeDiagnostics.shouldEnable(true, true))
+ }
+
+ @Test
+ fun structuredStagesContainOnlyRedactedFields() {
+ val line = FaceTimeDiagnostics.formatStage(
+ stage = FaceTimeDiagnosticStage.MEDIA_BYTES,
+ state = "Connected",
+ count = 2,
+ bytes = 4096,
+ )
+
+ assertEquals("stage=media_bytes state=connected count=2 bytes=4096", line)
+ assertFalse(line.contains("http", ignoreCase = true))
+ assertFalse(line.contains("sdp", ignoreCase = true))
+ }
+
+ @Test
+ fun unknownIceStateIsRedacted() {
+ assertEquals("connected", FaceTimeDiagnostics.safeIceState("connected"))
+ assertEquals("unknown", FaceTimeDiagnostics.safeIceState("secret-token"))
+ }
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt
new file mode 100644
index 0000000000..549bde47d7
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeJoinPolicyTest.kt
@@ -0,0 +1,213 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FaceTimeJoinPolicyTest {
+ @Test
+ fun clickedRequestsAdmissionButDoesNotClaimJoined() {
+ val decision = FaceTimeJoinPolicy().record("\"clicked\"")
+
+ assertEquals(FaceTimeJoinOutcome.CLICKED, decision.outcome)
+ assertTrue(decision.revealManualRecovery.not())
+ assertFalse(decision.joined)
+ assertTrue(decision.retry)
+ }
+
+ @Test
+ fun visibleLeaveButtonIsNotConnectionEvidence() {
+ val policy = FaceTimeJoinPolicy()
+
+ val action = policy.record("\"already-joined\"")
+ assertFalse(action.joined)
+ val decision = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CHECKING,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 0,
+ mediaBytes = null,
+ webLeaveVisible = true,
+ )
+ )
+
+ assertTrue(policy.admissionRequested)
+ assertFalse(decision.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_PENDING, decision.outcome)
+ }
+
+ @Test
+ fun connectedIceAndRemoteAudioAdmitAudioOnlyCall() {
+ val policy = FaceTimeJoinPolicy()
+ policy.record("\"clicked\"")
+
+ policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 0,
+ mediaBytes = 64,
+ webLeaveVisible = true,
+ peerId = 1,
+ )
+ )
+
+ val decision = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 0,
+ mediaBytes = 128,
+ webLeaveVisible = true,
+ peerId = 1,
+ )
+ )
+
+ assertTrue(decision.joined)
+ assertFalse(decision.retry)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_CONNECTED, decision.outcome)
+ }
+
+ @Test
+ fun failedIceDoesNotAdmitCall() {
+ val policy = FaceTimeJoinPolicy()
+ policy.record("\"clicked\"")
+
+ val decision = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.FAILED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 0,
+ webLeaveVisible = false,
+ )
+ )
+
+ assertFalse(decision.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_FAILED, decision.outcome)
+ assertTrue(decision.retry)
+ }
+
+ @Test
+ fun mediaLossClearsJoinedButDoesNotBlindlyRetryAfterCompletedJoin() {
+ val policy = FaceTimeJoinPolicy()
+ policy.record("\"clicked\"")
+ policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 512,
+ webLeaveVisible = true,
+ peerId = 1,
+ )
+ )
+ assertTrue(policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 1024,
+ webLeaveVisible = true,
+ peerId = 1,
+ )
+ ).joined)
+
+ val decision = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.DISCONNECTED,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 0,
+ mediaBytes = null,
+ webLeaveVisible = true,
+ )
+ )
+
+ assertFalse(decision.joined)
+ assertTrue(policy.completedJoin)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_PENDING, decision.outcome)
+ assertFalse(decision.retry)
+ }
+
+ @Test
+ fun delayedEvidenceCanAdmitAfterMultipleJoinAttempts() {
+ val policy = FaceTimeJoinPolicy(manualRecoveryAttempt = 2, maxAttempts = 4)
+
+ assertFalse(policy.record("\"missing\"").joined)
+ assertFalse(policy.record("\"clicked\"").joined)
+ policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.COMPLETED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 1024,
+ webLeaveVisible = true,
+ peerId = 1,
+ )
+ )
+ assertTrue(policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.COMPLETED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 2048,
+ webLeaveVisible = true,
+ peerId = 1,
+ )
+ ).joined)
+ }
+
+ @Test
+ fun retriesEventuallyStopWithoutClaimingJoined() {
+ val policy = FaceTimeJoinPolicy(manualRecoveryAttempt = 1, maxAttempts = 2)
+
+ policy.record("\"disabled\"")
+ val finalDecision = policy.record(null)
+
+ assertEquals(FaceTimeJoinOutcome.UNKNOWN, finalDecision.outcome)
+ assertTrue(finalDecision.revealManualRecovery)
+ assertFalse(finalDecision.retry)
+ assertFalse(finalDecision.joined)
+ }
+
+ @Test
+ fun duplicateIntentDoesNotReplaceActiveCall() {
+ val lifecycle = FaceTimeCallLifecycle()
+
+ assertEquals(FaceTimeIntentDisposition.ACCEPTED, lifecycle.acceptIntent("call-a"))
+ assertEquals(FaceTimeIntentDisposition.DUPLICATE, lifecycle.acceptIntent("call-a"))
+ assertEquals(
+ FaceTimeIntentDisposition.REJECTED_MISMATCHED_CALL,
+ lifecycle.acceptIntent("call-b"),
+ )
+ assertEquals(
+ FaceTimeIntentDisposition.REJECTED_MISSING_CALL_ID,
+ lifecycle.acceptIntent(null),
+ )
+ }
+
+ @Test
+ fun lifecycleCanAcceptNewCallAfterReset() {
+ val lifecycle = FaceTimeCallLifecycle()
+
+ lifecycle.acceptIntent("call-a")
+ lifecycle.reset()
+
+ assertEquals(FaceTimeIntentDisposition.ACCEPTED, lifecycle.acceptIntent("call-b"))
+ }
+
+ @Test
+ fun nativeEndRemainsAvailableAndMovesAwayFromWebLeave() {
+ assertTrue(FaceTimeControlPolicy.shouldShowNativeEndControl())
+ assertEquals(FaceTimeNativeEndPlacement.TOP_RIGHT, FaceTimeControlPolicy.nativeEndPlacement(false))
+ assertEquals(FaceTimeNativeEndPlacement.BOTTOM_LEFT, FaceTimeControlPolicy.nativeEndPlacement(true))
+ }
+
+ @Test
+ fun connectionProbeWindowAllowsLateMediaWithoutExtendingJoinRetries() {
+ assertEquals(80, FaceTimeConnectionProbePolicy.maxProbes)
+ assertTrue(FaceTimeConnectionProbePolicy.pendingDelayMillis in 750L..1500L)
+ assertTrue(FaceTimeConnectionProbePolicy.initialDelayMillis < FaceTimeConnectionProbePolicy.pendingDelayMillis)
+ }
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaAdmissionReplayTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaAdmissionReplayTest.kt
new file mode 100644
index 0000000000..cc6719ed0a
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaAdmissionReplayTest.kt
@@ -0,0 +1,227 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FaceTimeMediaAdmissionReplayTest {
+ @Test
+ fun replaysPendingToConnectedMediaAdmissionAndMediaLoss() {
+ val policy = FaceTimeJoinPolicy()
+
+ val checking = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.CHECKING,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 0,
+ mediaBytes = null,
+ peerId = null,
+ ),
+ )
+ assertFalse(checking.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_PENDING, checking.outcome)
+
+ val failed = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.FAILED,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 0,
+ mediaBytes = null,
+ peerId = null,
+ ),
+ )
+ assertFalse(failed.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_FAILED, failed.outcome)
+
+ val connectedWithoutTrack = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 0,
+ mediaBytes = 0,
+ peerId = 7,
+ ),
+ )
+ assertFalse(connectedWithoutTrack.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_PENDING, connectedWithoutTrack.outcome)
+
+ val zeroInboundBytes = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 0,
+ peerId = 7,
+ ),
+ )
+ assertFalse(zeroInboundBytes.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_PENDING, zeroInboundBytes.outcome)
+
+ val firstInboundSample = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 128,
+ peerId = 7,
+ ),
+ )
+ assertFalse(firstInboundSample.joined)
+
+ val admitted = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 256,
+ peerId = 7,
+ ),
+ )
+ assertTrue(admitted.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_CONNECTED, admitted.outcome)
+
+ val mediaLoss = policy.recordMediaEvidence(
+ evidence(
+ iceState = FaceTimeIceState.DISCONNECTED,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 0,
+ mediaBytes = null,
+ peerId = null,
+ ),
+ )
+ assertFalse(mediaLoss.joined)
+ assertEquals(FaceTimeJoinOutcome.MEDIA_PENDING, mediaLoss.outcome)
+ assertTrue(policy.completedJoin)
+ assertFalse(mediaLoss.retry)
+ }
+
+ @Test
+ fun connectedVideoOnlyCallAdmitsVideoWithoutAudio() {
+ val policy = FaceTimeJoinPolicy()
+ policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 1,
+ mediaBytes = 64,
+ webLeaveVisible = true,
+ peerId = 1,
+ ),
+ )
+ val decision = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 0,
+ remoteVideoTracks = 1,
+ mediaBytes = 128,
+ webLeaveVisible = true,
+ peerId = 1,
+ ),
+ )
+
+ assertTrue(decision.joined)
+ }
+
+ @Test
+ fun zeroInboundBytesMustNotAdmitEvenWhenRemoteTracksExist() {
+ val decision = FaceTimeJoinPolicy().recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 0,
+ webLeaveVisible = true,
+ peerId = 1,
+ ),
+ )
+
+ assertFalse("a connected track with no inbound bytes is not verified media", decision.joined)
+ }
+
+ @Test
+ fun inboundBytesMustIncreaseBeforeMediaIsAdmitted() {
+ val policy = FaceTimeJoinPolicy()
+
+ val firstSample = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 128,
+ webLeaveVisible = true,
+ peerId = 1,
+ ),
+ )
+ val secondSample = policy.recordMediaEvidence(
+ FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = 256,
+ webLeaveVisible = true,
+ peerId = 1,
+ ),
+ )
+
+ assertFalse("one positive counter sample does not prove media is flowing", firstSample.joined)
+ assertTrue("a later larger counter sample proves inbound progress", secondSample.joined)
+ }
+
+ @Test
+ fun peerReplacementRequiresASecondSampleFromTheNewPeer() {
+ val policy = FaceTimeJoinPolicy()
+ policy.recordMediaEvidence(connectedEvidence(peerId = 1, mediaBytes = 100))
+ val replacement = policy.recordMediaEvidence(connectedEvidence(peerId = 2, mediaBytes = 200))
+ val confirmed = policy.recordMediaEvidence(connectedEvidence(peerId = 2, mediaBytes = 250))
+
+ assertFalse(replacement.joined)
+ assertTrue(confirmed.joined)
+ }
+
+ @Test
+ fun counterResetRequiresTwoFreshPostResetSamples() {
+ val policy = FaceTimeJoinPolicy()
+ policy.recordMediaEvidence(connectedEvidence(peerId = 1, mediaBytes = 100))
+ val reset = policy.recordMediaEvidence(connectedEvidence(peerId = 1, mediaBytes = 0))
+ val firstAfterReset = policy.recordMediaEvidence(connectedEvidence(peerId = 1, mediaBytes = 1))
+ val advancingAfterReset = policy.recordMediaEvidence(connectedEvidence(peerId = 1, mediaBytes = 2))
+
+ assertFalse(reset.joined)
+ assertFalse(firstAfterReset.joined)
+ assertTrue(advancingAfterReset.joined)
+ }
+
+ @Test
+ fun missingPeerIdentityCannotAdmitMedia() {
+ val policy = FaceTimeJoinPolicy()
+ policy.recordMediaEvidence(connectedEvidence(peerId = null, mediaBytes = 100))
+ val decision = policy.recordMediaEvidence(connectedEvidence(peerId = null, mediaBytes = 200))
+
+ assertFalse(decision.joined)
+ }
+
+ private fun connectedEvidence(peerId: Int?, mediaBytes: Long) = FaceTimeMediaEvidence(
+ iceState = FaceTimeIceState.CONNECTED,
+ remoteAudioTracks = 1,
+ remoteVideoTracks = 1,
+ mediaBytes = mediaBytes,
+ webLeaveVisible = true,
+ peerId = peerId,
+ )
+
+ private fun evidence(
+ iceState: FaceTimeIceState,
+ remoteAudioTracks: Int,
+ remoteVideoTracks: Int,
+ mediaBytes: Long?,
+ peerId: Int?,
+ ) = FaceTimeMediaEvidence(
+ iceState = iceState,
+ remoteAudioTracks = remoteAudioTracks,
+ remoteVideoTracks = remoteVideoTracks,
+ mediaBytes = mediaBytes,
+ webLeaveVisible = true,
+ peerId = peerId,
+ )
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaEvidenceParserTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaEvidenceParserTest.kt
new file mode 100644
index 0000000000..086122fc6e
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimeMediaEvidenceParserTest.kt
@@ -0,0 +1,111 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FaceTimeMediaEvidenceParserTest {
+ @Test
+ fun nullAndEmptyJavascriptResultsAreUnavailable() {
+ assertNull(FaceTimeMediaEvidenceParser.parse(null))
+ assertNull(FaceTimeMediaEvidenceParser.parse("null"))
+ assertNull(FaceTimeMediaEvidenceParser.parse(" undefined "))
+ assertNull(FaceTimeMediaEvidenceParser.parse(""))
+ }
+
+ @Test
+ fun parsesEscapedEvaluateJavascriptJson() {
+ val json = """{"peerId":7,"iceState":"connected","remoteAudioTracks":1,"remoteVideoTracks":1,"mediaBytes":128,"webLeaveVisible":true}"""
+
+ val evidence = FaceTimeMediaEvidenceParser.parse(asEvaluateJavascriptString(json))
+
+ assertEquals(FaceTimeIceState.CONNECTED, evidence?.iceState)
+ assertEquals(7, evidence?.peerId)
+ assertEquals(1, evidence?.remoteAudioTracks)
+ assertEquals(1, evidence?.remoteVideoTracks)
+ assertEquals(128L, evidence?.mediaBytes)
+ assertTrue(evidence?.webLeaveVisible == true)
+ assertTrue(evidence?.isConnected == true)
+ }
+
+ @Test
+ fun parsesDirectJsonObjectToo() {
+ val evidence = FaceTimeMediaEvidenceParser.parse(
+ """{"iceState":"completed","remoteAudioTracks":1,"remoteVideoTracks":0}"""
+ )
+
+ assertEquals(FaceTimeIceState.COMPLETED, evidence?.iceState)
+ assertEquals(1, evidence?.remoteAudioTracks)
+ assertEquals(0, evidence?.remoteVideoTracks)
+ assertNull(evidence?.mediaBytes)
+ }
+
+ @Test
+ fun malformedOuterOrInnerJsonReturnsUnavailable() {
+ assertNull(FaceTimeMediaEvidenceParser.parse("{not-json"))
+ assertNull(FaceTimeMediaEvidenceParser.parse(asEvaluateJavascriptString("{not-json")))
+ assertNull(FaceTimeMediaEvidenceParser.parse(asEvaluateJavascriptString("not an object")))
+ }
+
+ @Test
+ fun missingAndExplicitlyNullMediaBytesRemainNull() {
+ val missing = FaceTimeMediaEvidenceParser.parse(
+ """{"iceState":"connected","remoteAudioTracks":1}"""
+ )
+ val explicitNull = FaceTimeMediaEvidenceParser.parse(
+ """{"iceState":"connected","remoteAudioTracks":1,"mediaBytes":null}"""
+ )
+
+ assertNull(missing?.mediaBytes)
+ assertNull(explicitNull?.mediaBytes)
+ }
+
+ @Test
+ fun unknownIceStateIsSafeAndDoesNotClaimConnected() {
+ val evidence = FaceTimeMediaEvidenceParser.parse(
+ """{"iceState":"future-state","remoteAudioTracks":1,"remoteVideoTracks":1,"mediaBytes":1}"""
+ )
+
+ assertEquals(FaceTimeIceState.UNKNOWN, evidence?.iceState)
+ assertTrue(evidence?.hasRemoteTrack == true)
+ assertTrue(evidence?.isConnected == false)
+ }
+
+ @Test
+ fun negativeCountersAndBytesClampToZero() {
+ val evidence = FaceTimeMediaEvidenceParser.parse(
+ """{"iceState":"connected","remoteAudioTracks":-2,"remoteVideoTracks":-9,"mediaBytes":-1}"""
+ )
+
+ assertEquals(0, evidence?.remoteAudioTracks)
+ assertEquals(0, evidence?.remoteVideoTracks)
+ assertEquals(0L, evidence?.mediaBytes)
+ assertTrue(evidence?.isConnected == false)
+ }
+
+ @Test
+ fun oversizedValuesFailClosedWithoutOverflow() {
+ val evidence = FaceTimeMediaEvidenceParser.parse(
+ """{"iceState":"connected","remoteAudioTracks":999999999999999999999999999999,"remoteVideoTracks":1,"mediaBytes":999999999999999999999999999999999999999999999999999999999999}"""
+ )
+
+ assertEquals(Int.MAX_VALUE, evidence?.remoteAudioTracks)
+ assertEquals(1, evidence?.remoteVideoTracks)
+ assertNull(evidence?.mediaBytes)
+ }
+
+ @Test
+ fun nonIntegralNumericValuesAreIgnoredSafely() {
+ val evidence = FaceTimeMediaEvidenceParser.parse(
+ """{"remoteAudioTracks":1.5,"remoteVideoTracks":"2.5","mediaBytes":"3.25"}"""
+ )
+
+ assertEquals(0, evidence?.remoteAudioTracks)
+ assertEquals(0, evidence?.remoteVideoTracks)
+ assertEquals(0L, evidence?.mediaBytes)
+ }
+
+ private fun asEvaluateJavascriptString(json: String): String =
+ "\"" + json.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimePermissionPolicyTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimePermissionPolicyTest.kt
new file mode 100644
index 0000000000..db1be49321
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/facetime/FaceTimePermissionPolicyTest.kt
@@ -0,0 +1,32 @@
+package com.bluebubbles.messaging.services.facetime
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FaceTimePermissionPolicyTest {
+ @Test
+ fun startsServiceOnlyWhenEveryRequestedPermissionIsGranted() {
+ assertTrue(FaceTimePermissionPolicy.shouldStartInCallService(2, intArrayOf(0, 0)))
+ assertTrue(FaceTimePermissionPolicy.shouldStartInCallService(1, intArrayOf(0)))
+ }
+
+ @Test
+ fun denialPartialGrantEmptyAndMismatchedResultsDoNotStartService() {
+ assertFalse(FaceTimePermissionPolicy.shouldStartInCallService(2, intArrayOf(0, -1)))
+ assertFalse(FaceTimePermissionPolicy.shouldStartInCallService(2, intArrayOf(-1, 0)))
+ assertFalse(FaceTimePermissionPolicy.shouldStartInCallService(2, intArrayOf(0)))
+ assertFalse(FaceTimePermissionPolicy.shouldStartInCallService(2, intArrayOf()))
+ assertFalse(FaceTimePermissionPolicy.shouldStartInCallService(0, intArrayOf()))
+ }
+
+ @Test
+ fun permissionLookupRejectsMissingAndOutOfRangeResults() {
+ val results = intArrayOf(0, -1)
+
+ assertTrue(FaceTimePermissionPolicy.isGranted(results, 0))
+ assertFalse(FaceTimePermissionPolicy.isGranted(results, 1))
+ assertFalse(FaceTimePermissionPolicy.isGranted(results, -1))
+ assertFalse(FaceTimePermissionPolicy.isGranted(results, 2))
+ }
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkPolicyTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkPolicyTest.kt
new file mode 100644
index 0000000000..292c7f247d
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/rustpush/CloudSyncV2WorkPolicyTest.kt
@@ -0,0 +1,56 @@
+package com.bluebubbles.messaging.services.rustpush
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CloudSyncV2WorkPolicyTest {
+ @Test
+ fun `durable scheduling gate is closed by default`() {
+ assertFalse(CloudSyncV2WorkScheduler.schedulingEnabled)
+ }
+
+ @Test
+ fun `metadata work is battery safe and coalesces for fifteen seconds`() {
+ val policy = CloudSyncV2WorkPolicy.forKind(CloudSyncV2WorkKind.METADATA)
+
+ assertEquals(CloudSyncV2NetworkRequirement.CONNECTED, policy.networkRequirement)
+ assertTrue(policy.requiresBatteryNotLow)
+ assertTrue(policy.requiresStorageNotLow)
+ assertEquals(15_000L, policy.initialDelayMillis)
+ assertFalse(policy.requestsExpeditedExecution)
+ }
+
+ @Test
+ fun `automatic media requires unmetered network`() {
+ val policy = CloudSyncV2WorkPolicy.forKind(CloudSyncV2WorkKind.AUTOMATIC_MEDIA)
+
+ assertEquals(CloudSyncV2NetworkRequirement.UNMETERED, policy.networkRequirement)
+ assertTrue(policy.requiresBatteryNotLow)
+ assertTrue(policy.requiresStorageNotLow)
+ assertFalse(policy.requestsExpeditedExecution)
+ }
+
+ @Test
+ fun `user visible work is explicitly modeled but never expedited`() {
+ val policy = CloudSyncV2WorkPolicy.forKind(CloudSyncV2WorkKind.USER_VISIBLE_MANUAL)
+
+ assertEquals(CloudSyncV2NetworkRequirement.CONNECTED, policy.networkRequirement)
+ assertFalse(policy.requestsExpeditedExecution)
+ }
+
+ @Test
+ fun `unique work name hashes the complete scope and does not expose it`() {
+ val scope = "account-fingerprint\u001fcontainer\u001fprivate\u001fzone\u001fmessages\u001f2"
+ val name = CloudSyncV2WorkScheduler.uniqueWorkNameForScopeKey(scope)
+
+ assertTrue(name.startsWith("cloud-sync-v2/"))
+ assertFalse(name.contains("account-fingerprint"))
+ assertNotEquals(
+ name,
+ CloudSyncV2WorkScheduler.uniqueWorkNameForScopeKey("$scope-other"),
+ )
+ }
+}
diff --git a/android/app/src/test/kotlin/com/bluebubbles/messaging/services/rustpush/PendingApnDispatchQueueTest.kt b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/rustpush/PendingApnDispatchQueueTest.kt
new file mode 100644
index 0000000000..978c9fd5e2
--- /dev/null
+++ b/android/app/src/test/kotlin/com/bluebubbles/messaging/services/rustpush/PendingApnDispatchQueueTest.kt
@@ -0,0 +1,78 @@
+package com.bluebubbles.messaging.services.rustpush
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PendingApnDispatchQueueTest {
+ @Test
+ fun `coalesces retries without changing dispatch order`() {
+ val queue = PendingApnDispatchQueue(capacity = 4)
+
+ queue.enqueue(pointer = 10UL, retry = 0UL)
+ queue.enqueue(pointer = 20UL, retry = 0UL)
+ val duplicate = queue.enqueue(pointer = 10UL, retry = 2UL)
+
+ assertFalse(duplicate.added)
+ assertFalse(duplicate.evicted)
+ assertEquals(2, duplicate.size)
+ assertEquals(
+ listOf(
+ PendingApnDispatch(pointer = 10UL, retry = 2UL),
+ PendingApnDispatch(pointer = 20UL, retry = 0UL),
+ ),
+ queue.drain(),
+ )
+ }
+
+ @Test
+ fun `evicts only the oldest Android dispatch when capacity is reached`() {
+ val queue = PendingApnDispatchQueue(capacity = 2)
+
+ queue.enqueue(pointer = 10UL, retry = 0UL)
+ queue.enqueue(pointer = 20UL, retry = 0UL)
+ val overflow = queue.enqueue(pointer = 30UL, retry = 1UL)
+
+ assertTrue(overflow.added)
+ assertTrue(overflow.evicted)
+ assertEquals(2, overflow.size)
+ assertEquals(
+ listOf(
+ PendingApnDispatch(pointer = 20UL, retry = 0UL),
+ PendingApnDispatch(pointer = 30UL, retry = 1UL),
+ ),
+ queue.drain(),
+ )
+ }
+
+ @Test
+ fun `drain is deterministic and empties the buffer`() {
+ val queue = PendingApnDispatchQueue(capacity = 3)
+
+ queue.enqueue(pointer = 1UL, retry = 0UL)
+ queue.enqueue(pointer = 2UL, retry = 1UL)
+
+ assertEquals(listOf(1UL, 2UL), queue.drain().map { it.pointer })
+ assertTrue(queue.drain().isEmpty())
+ assertEquals(0, queue.clear())
+ }
+
+ @Test
+ fun `headless handoff removes stale pointer before a later retry`() {
+ val queue = PendingApnDispatchQueue(capacity = 3)
+
+ queue.enqueue(pointer = 7UL, retry = 0UL)
+ val handedOff = queue.drain()
+ queue.enqueue(pointer = 7UL, retry = 1UL)
+
+ assertEquals(
+ listOf(PendingApnDispatch(pointer = 7UL, retry = 0UL)),
+ handedOff,
+ )
+ assertEquals(
+ listOf(PendingApnDispatch(pointer = 7UL, retry = 1UL)),
+ queue.drain(),
+ )
+ }
+}
diff --git a/android/gradle.properties b/android/gradle.properties
index 3313a4d1da..d0089e0542 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx6400M
android.useAndroidX=true
android.enableJetifier=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/docs/CLOUD_SYNC_V2.md b/docs/CLOUD_SYNC_V2.md
new file mode 100644
index 0000000000..65df923f27
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2.md
@@ -0,0 +1,616 @@
+---
+type: architecture_plan
+title: OpenBubbles Cloud Sync V2
+description: Safety-first architecture for message reconciliation across Pixel Android, Windows ARM64, and Windows x64.
+resource: openbubbles-app
+tags: [android, pixel, windows, arm64, x64, cloudkit, sync, objectbox, rust, security]
+timestamp: 2026-07-31
+---
+
+# OpenBubbles Cloud Sync V2
+
+## Decision
+
+Build one architecture-neutral Cloud Sync V2 engine for Android, Windows ARM64,
+and Windows x64. Keep IDS as the live messaging path, ObjectBox as the local
+source of truth, and Apple's private Messages CloudKit container as a delayed
+reconciliation layer.
+
+Do not enable new CloudKit writes until Windows secret storage, durable
+checkpoints, and a read-only shadow-sync phase pass the gates in this document.
+Do not automatically reset an iCloud Keychain clique or delete CloudKit zones.
+
+## Why this boundary
+
+The current implementation talks to Apple's private
+`com.apple.messages.cloud` container. It does not use a supported public
+CloudKit SDK or a documented third-party contract. It requires Apple
+authentication, CloudKit tokens, iCloud Keychain clique membership, and PCS
+keys. Apple can change the protocol or server policy without notice.
+
+CloudKit is therefore useful for eventual reconciliation, but it is the wrong
+place to put latency-sensitive message delivery. A CloudKit outage must never
+delay IDS sends, incoming pushes, local persistence, or UI updates.
+
+## Current implementation findings
+
+| Finding | Evidence | Risk |
+| --- | --- | --- |
+| Private Apple Messages container | `rustpush/src/imessage/cloud_messages.rs`, container declaration | No supported compatibility guarantee |
+| Three implemented zones | `chatManateeZone`, `messageManateeZone`, and `attachmentManateeZone` | Other Apple zones and features are not synchronized |
+| SharedPreferences checkpoints | `chatSyncToken`, `messageSyncToken`, and `attachmentSyncToken` in `rustpush_service.dart` | Tokens are not account-scoped or transactionally coupled to applied data |
+| Fixed retries | Three attempts with a constant five-second delay in `cloud_messages.rs` | Restarts and outages can cause retry bursts |
+| Static desktop encryption key | `SoftwareEncryptor(*b"desktopisinsecureyoushouldn'tber")` in `rust/src/api/api.rs` | Apple secrets are not protected by the Windows user account |
+| Daily/startup scheduling | Timers in `rustpush_service.dart` | Not real-time and not durable across crashes |
+| Existing-GUID short circuit | Current pull logic updates limited CloudKit state for some existing messages | Reads, edits, retractions, and other mutable state can remain stale |
+| Random server record IDs | Upload path and later logical-GUID deduplication | Two clients can race and create duplicate logical records |
+
+The current uncommitted `cloud_message_upload_state.dart` logic is worth
+preserving. It serializes operations and treats only explicit per-record success
+as confirmation. Missing or failed response entries remain retryable.
+
+### Uncommitted implementation snapshot (2026-08-01)
+
+The current working tree now contains the Phase 0 foundation and a dormant
+Phase 1 read-only path:
+
+- Rust returns one ordered, bounded raw page for the chat, message, or
+ attachment zone. It preserves tombstones and malformed or unsupported records
+ for quarantine and applies request and response size limits. Dart additionally
+ enforces a 32 MiB whole-page admission before the first protector call,
+ including binary payloads, UTF-8 metadata, continuation token, and
+ conservative object overhead. The uncommitted native path now preserves
+ HTTP `Retry-After` and maps repeated continuation tokens to a typed,
+ nonretryable no-progress failure. Those new bridge and rustpush tests still
+ require a clean ARM64 and x64 native compile before the gate is closed.
+- The V2 transport adapter exposes that page through generated Flutter Rust
+ Bridge bindings and refuses every V2 write/delete operation. The wider
+ generated API still contains legacy CloudKit mutation methods, so a live V2
+ composition also needs an explicit write-call tripwire.
+- A second, narrower protected-fetch bridge now keeps raw CloudKit record
+ names, etags, tombstone payloads, encrypted envelopes, and continuation
+ tokens in Rust. Its Dart transport receives only keyed digests, bounded safe
+ scalars, fixed codes, `obcs2.ref.*` protected capabilities, and
+ `obcs2.lease.*` adoption leases. It is generated and contract-tested but
+ remains absent from runtime composition. Native protected-blob liveness and
+ bounded garbage collection, concrete semantic decoding, platform reopen
+ tests, and process-kill testing are required before it may replace the raw
+ shadow transport.
+- Dart converts each record into one authenticated protected envelope before
+ journaling. Persisted record IDs, etags, change tags, and batch identifiers are
+ hashed; raw account identifiers are limited to the native HMAC boundary.
+- Cloud Sync journal values on Windows use current-user DPAPI plus a per-install
+ HMAC secret. Android uses AES-256-GCM with a non-auth-bound Android Keystore
+ key. Protection context includes account, container, database, zone, stream,
+ schema, and purpose. The broader Windows Apple-keystore initialization still
+ contains a legacy static software-encryption path and must be migrated before
+ Windows live testing.
+- A preflight-invalid record is journaled and quarantined without invoking the
+ semantic decoder. A page that exceeds the entry, byte, age, or generation
+ boundary is rejected atomically with no continuation-token movement.
+- Automatic startup, network, IDS, and gap triggers remain disabled. Existing
+ message tables and Apple's CloudKit container are not mutated by this path.
+ Runtime disposal is idempotent and waits for active work to become quiescent
+ before an account can replace its credentials.
+- Incoming attachment materialization now has a durable, account- and
+ generation-scoped state machine. It records only protected references and a
+ contiguous native-verified byte boundary. A crash tail is truncated back to
+ that boundary, an incomplete verified prefix restarts from zero, and a final
+ file cannot be referenced until content verification and atomic placement
+ have both completed.
+
+Local validation re-measured on 2026-08-06 on a Windows-on-ARM host:
+
+- 294 Cloud Sync Dart/ObjectBox tests pass on both the ARM64 and the x64 Dart
+ test host, with a clean focused analyzer.
+- 388 tests pass across the whole Dart suite.
+- The standalone `cloud_sync_protector_harness` passes 39 tests on ARM64.
+- 12 Alpha Kotlin/JUnit tests pass, including Kotlin compilation.
+- `cargo check --locked --all-targets` is clean for
+ `aarch64-pc-windows-msvc`, and release libraries build for
+ `aarch64-pc-windows-msvc`, `x86_64-pc-windows-msvc`, and
+ `aarch64-linux-android` with the expected PE and ELF machine types.
+
+`cargo test` on the main crate is currently blocked by this host's Smart App
+Control policy rather than by the repository; see
+[Windows host build environment](WINDOWS_HOST_BUILD_ENVIRONMENT.md).
+
+This is still not live CloudKit, native semantic-decoder, crash-injection, or
+physical-device validation. The native bridge must also compile after every
+binding regeneration before any app build is considered installable.
+
+## Architecture
+
+```text
+IDS receive/send
+ |
+ v
+Semantic message upsert <---- Cloud inbox apply
+ | ^
+ v |
+ObjectBox source of truth Cloud fetch journal
+ |
+ +---- local mutation ----> Cloud outbox
+ |
+ v
+ Rust Apple transport/crypto
+```
+
+The same semantic upsert pipeline must process IDS events and CloudKit changes.
+This prevents the two transports from implementing different merge behavior.
+
+Suggested Dart module:
+
+```text
+lib/services/rustpush/cloud_sync/
+ cloud_sync_engine.dart
+ cloud_sync_store.dart
+ cloud_merge_policy.dart
+ cloud_sync_scheduler.dart
+ cloud_sync_observability.dart
+```
+
+Rust should retain Apple protocol, authentication, PCS, and cryptography work.
+The existing `rustpush_service.dart` should become a thin facade rather than
+owning sync transactions and retry policy.
+
+## Durable records
+
+Use ObjectBox records rather than SharedPreferences for Cloud Sync V2 state.
+
+### `CloudSyncCheckpoint`
+
+- Account fingerprint, never a raw DSID
+- Container, database, zone, typed stream, and schema version
+- Fetched server token
+- Last completed apply position
+- Last successful run and last error category
+
+Every inbox, outbox, record-map, lease, and run row stores the same hashed
+scope key. Querying by account plus zone alone is forbidden because a zone name
+can be reused across containers, databases, streams, or schema generations.
+
+### `CloudInboxChange`
+
+- Account fingerprint and zone
+- Server record ID hash and etag
+- Change type
+- Original PCS ciphertext or an encrypted local reference. Do not duplicate
+ decrypted message content in the sync journal.
+- Fetch sequence
+- `pending`, `applied`, or `quarantined` status
+- Typed failure category and retry count
+
+### `CloudOutboxOperation`
+
+- Stable local operation ID
+- Logical entity key
+- Save or delete action
+- Dependency operation IDs
+- Payload version
+- Durable, monotonically increasing local mutation revision. Wall-clock time
+ and operation-ID lexical order must not decide which save is newer.
+- Attempt count and next eligible time
+- Last typed error
+- Explicit server confirmation state
+
+### `CloudRecordMap`
+
+- Logical application key
+- Apple record ID
+- Last-known etag and server metadata
+- Last-known encrypted raw record reference
+
+### `CloudSyncRun`
+
+- Trigger and architecture
+- Fetched, applied, quarantined, confirmed, and retried counts
+- Redacted timing and failure categories
+- Start and finish timestamps
+
+### `CloudAttachmentMaterialization`
+
+- Complete account scope, generation, and keyed logical attachment identity
+- Expected byte count and a keyed digest of the native MMCS integrity tag
+- Monotonic stage: metadata, streaming, verified, placed, then referenced
+- Contiguous native-verified byte count
+- Protected temporary-file, resume-manifest, verification, and final-file
+ references, never raw paths or MMCS credentials
+- Last update timestamp
+
+Creation and every transition use an ObjectBox transaction. Updates are
+compare-and-swap operations over the complete expected state so a stale
+process, isolate, or resumed worker cannot regress a verified boundary. Network
+I/O, hashing, decryption, truncation, and atomic file placement remain outside
+the ObjectBox transaction.
+
+## Pull transaction
+
+1. Fetch records with record ID, etag, type, server metadata, and next token.
+2. Persist the inbox batch and fetched token in one ObjectBox transaction.
+3. Apply inbox entries separately through the shared semantic upsert pipeline.
+4. Mark each entry `applied` only after its local mutation commits.
+5. Quarantine malformed or undecryptable records. Never silently drop them.
+6. Advance the applied position only through a contiguous successful range.
+
+This makes a crash or malformed record recoverable without replaying the entire
+zone or permanently skipping a change.
+
+### Read-only shadow journal budget
+
+Phase 1 currently contains a dormant runtime and scheduler foundation designed
+for a future manual sampler. It has no production composition or invocation
+path yet. Startup, network-reconnect, IDS-reconnect, and detected-gap callbacks
+do nothing unless a later rollout explicitly enables automatic triggers.
+Read-only fetch, semantic apply, saves, deletes, profiles, and notification
+hints remain independently gated. In this phase, read-only fetch is the only
+permitted capability; the shadow runtime rejects every engine with read-only
+fetch disabled or with a mutation, profile, or notification gate enabled.
+
+Pending shadow rows have deterministic limits per complete account scope:
+
+- 4,096 pending entries
+- 32 MiB conservative journal-row estimate
+- Seven days from the oldest pending entry
+
+These are client safety limits, not Apple quotas and not estimates of attachment
+payload size or the complete ObjectBox file. Byte accounting uses fixed row and
+index overhead plus UTF-8 lengths of protected references and redacted hashes.
+It never inspects or logs decrypted message content.
+
+Admission is all-or-nothing with the continuation token. Exact entry and byte
+boundaries are accepted. A page that would cross either boundary is rejected
+without persisting any row or advancing the token. Once the current journal is
+at a limit or older than the age limit, the engine blocks before another
+network fetch where possible. Diagnostics expose only retained entry count,
+estimated bytes, rejected entry count, and the typed reason `maximumAge`,
+`maximumEntries`, or `maximumEstimatedBytes`.
+
+The policy never deletes inbox data. Existing databases created before the
+budget are measured in place on first use; an over-budget database becomes
+blocked while its rows and checkpoint remain unchanged. Pending records remain
+correctness-relevant until a separately reviewed migration explicitly marks
+them as disposable shadow samples or replays the zone from a safe checkpoint.
+Do not enable semantic apply by silently pruning them.
+
+## Outbox transaction
+
+1. Queue a CloudKit operation in the same ObjectBox transaction as its local
+ mutation.
+2. Coalesce repeated saves for the same logical entity.
+3. Let a confirmed delete supersede an unconfirmed save only after tombstone
+ support passes its dedicated safety gate.
+4. Pull before pushing after startup, reconnect, or a detected IDS gap.
+5. Upload attachment records before their owning message record.
+6. Clear an operation only after explicit per-record confirmation.
+7. Persist typed retry state across process restarts.
+
+IDS delivery state and CloudKit backup state must remain separate. A message can
+be delivered through IDS while its CloudKit copy is still pending.
+
+## Merge policy
+
+Stable logical keys:
+
+- Message: Apple message GUID
+- Reaction: reaction GUID plus parent GUID
+- Attachment: attachment GUID plus owning message GUID
+- Chat: Apple group or chat identifier, with a participant-set fallback
+
+Rules:
+
+- Immutable content: first valid canonical event wins. Conflicting content is
+ quarantined for diagnosis.
+- Read and delivery timestamps: monotonic maximum.
+- Reactions without a parent: defer until the parent exists.
+- Edits: union message parts by Apple edit metadata.
+- Retractions: win only when newer or represented by Apple's canonical state.
+- Group metadata: higher group version wins.
+- Equal group versions: retain the last-known server base and log the conflict.
+- Mark-unread: local only until Apple's server representation is verified.
+- Unknown fields: retain the last-known raw record and etag.
+
+## Scope
+
+### Supported after validation
+
+- iMessage chats and messages
+- Read and delivery metadata
+- Reactions
+- Edit summaries and retractions
+- Attachment metadata and encrypted assets
+- Group photos
+- Explicitly shared profile records when the pointer and decryption key are
+ already known
+
+### Not provided by this design
+
+- SMS or RCS synchronization
+- Windows or Android contact synchronization
+- General iCloud Contacts synchronization
+- Reliable discovery of an existing iPhone profile on a fresh Windows device
+- Conversation wallpaper or call-background parity
+- Custom records inside Apple's Messages container
+- Scheduled-message or recoverable-delete zones
+
+Profile sharing uses separate Apple records and encryption material. It should
+be a later opt-in module, not a dependency of message reconciliation.
+
+## Security gate
+
+Cloud Sync V2 writes are blocked until all items below pass:
+
+- Replace the static desktop key with Windows DPAPI or Credential Manager.
+- Preserve protected state across ARM64 and x64 upgrades for the same Windows
+ user and application identity.
+- Protect Apple tokens, PCS material, keychain state, and profile keys.
+- Hash account identifiers before writing journals or diagnostics.
+- Redact message content, handles, DSIDs, tokens, keys, and raw server IDs.
+- Require an explicit user action for any clique or zone reset.
+- Prove that an account switch cannot reuse another account's checkpoints,
+ inbox, outbox, or record map.
+- Coordinate Android's foreground and background Flutter engines with the same
+ durable database lease. A process-local Dart lock is not sufficient.
+
+### Windows secret-storage design
+
+Protect one randomly generated 256-bit master key with current-user Windows
+DPAPI, then continue using the existing `SoftwareEncryptor` AES-GCM path for
+normal keystore operations. DPAPI runs only during initialization, so signing,
+IDS, CloudKit, and message processing do not gain per-operation IPC or
+cryptography overhead.
+
+Use the official Windows Rust bindings for:
+
+- `CryptProtectData`
+- `CryptUnprotectData`
+- `CRYPTPROTECT_UI_FORBIDDEN`
+- `DATA_BLOB`
+- `LocalFree`
+- `FlushFileBuffers`
+- `ReplaceFileW` with flags set to zero
+
+The repository lockfiles already contain `windows-sys` 0.59.0 and its ARM64
+MSVC target. Use current-user scope. Do not use machine scope or optional
+entropy, since either can break ARM64/x64 access for the same Windows user.
+
+Version the persisted software-keystore format:
+
+```text
+format_version = 2
+protected_master_key =
+keys =
+secrets =
+```
+
+Migration must:
+
+1. Acquire an exclusive profile lock.
+2. Strictly parse the existing file. Existing corruption must never become an
+ empty default keystore.
+3. Authenticate and decrypt every legacy entry in memory.
+4. Re-encrypt all entries with the random master.
+5. DPAPI-protect a complete recovery copy of the original legacy file.
+6. Write a same-directory temporary V2 file and flush its file handle.
+7. Reopen it, unlock the master, and verify every entry.
+8. Atomically replace the original with `ReplaceFileW`, using a backup path and
+ flags set to zero. Microsoft documents `REPLACEFILE_WRITE_THROUGH` as
+ unsupported.
+9. Reopen and verify the installed file before global initialization.
+
+The old static key may exist only in an isolated legacy decoder. It must never
+encrypt new or migrated state. If DPAPI cannot unlock a V2 master, fail closed
+with a typed error. Do not fall back to the legacy key, create an empty
+keystore, or reset the account.
+
+Before migration ships, harden AES-GCM parsing so truncated nonces, invalid
+tags, and unauthenticated data return typed errors rather than panicking. Make
+all later writes atomic, return an error on duplicate global initialization,
+correct the existing AES key-type label, and remove private-key material from
+logs.
+
+Ship DPAPI-capable x64 and ARM64 builds together. A pre-migration executable
+cannot read V2 state. Validate x64 to ARM64 to x64 read/write compatibility
+against a synthetic copied profile before touching a live profile.
+
+### Android secret-storage and worker design
+
+Use the existing Android Keystore integration to protect a random application
+master key with a non-biometric AES-GCM key. The protected key must remain
+available to the existing background APNs Flutter engine while the phone is
+locked. Do not store the raw master, CloudKit tokens, Apple record IDs, or PCS
+material in SharedPreferences.
+
+The foreground app and background APNs service can run in separate Flutter
+engines. They must coordinate through the ObjectBox coordinator lease and
+outbox leases, not a process-local Dart mutex. Every lease acquisition,
+renewal, release, and expired-lease recovery must be a short transaction.
+Network work and Apple cryptography happen outside ObjectBox transactions.
+
+An Android account switch must create a new account-scoped checkpoint, journal,
+outbox, record map, and lease namespace. Old encrypted state is retained for
+explicit recovery or deletion, but it is never automatically opened under the
+new account.
+
+### Platform and architecture parity
+
+Android, Windows ARM64, and Windows x64 must use the same:
+
+- Dart and Rust sync source
+- ObjectBox schema and migration hashes
+- CloudKit fixtures
+- Merge tests
+- Feature flags
+- Application identity and protected-state migration contract
+
+Only platform adapters, compiled native binaries, and packaging should differ.
+Windows CI must inspect the PE architecture of the executable, Rust library,
+ObjectBox library, media libraries, and TLS dependencies. Android CI must
+inspect the APK or app bundle for the expected `arm64-v8a` Rust and ObjectBox
+libraries.
+
+Use a cross-process single-instance lock before opening the database. An ARM64
+and x64 process must never open the same ObjectBox store concurrently.
+
+### Windows shared-profile process boundary
+
+Windows shared-profile support requires the official OpenBubbles runner. The
+runner atomically creates or opens its named mutex before it creates the window
+or initializes Flutter, keeps the first-instance handle alive until shutdown,
+and makes later official launches forward their app link and exit without
+opening the ObjectBox profile.
+
+Opening the same profile from a custom runner, a second Windows session, a test
+harness, or simultaneous x64 and ARM64 processes is unsupported. Those
+processes do not inherit this invariant and can corrupt or race the shared
+store. Release validation must keep the runner source-contract test green and
+must never use a shared profile for parallel architecture testing.
+
+## Scheduler and retry policy
+
+Triggers:
+
+- Startup
+- Network reconnection
+- Local outbox activity
+- IDS reconnect or detected event gap
+- Manual synchronization
+- Optional Apple notification hint after read-only validation
+
+Policy:
+
+- One active coordinator per account and database.
+- Debounce bursts of local mutations.
+- Preserve the existing maximum batch size of 256 records.
+- Use exponential backoff with full jitter for network, throttling, and server
+ errors.
+- Honor `Retry-After` when present.
+- Refresh authentication once after an authorization failure.
+- Pause cleanly when clique or PCS access is unavailable.
+- Persist backoff so restarting cannot create a retry storm.
+
+Apple does not publish dependable limits for this private service. These values
+are conservative client policy, not claimed Apple quotas.
+
+## Rollout
+
+### Phase 0: protocol and security baseline
+
+- Replace static-key secret storage.
+- Define typed Rust errors and response envelopes.
+- Return record IDs, etags, server metadata, and per-record outcomes.
+- Freeze the exact ObjectBox schema, IDs, indexes, uniqueness rules, and
+ forward/rollback migration invariants before transport code depends on it.
+- Create sanitized fixtures for chat, message, attachment, reaction, edit,
+ retraction, missing-key, and corrupt-record cases.
+
+Exit gate: no secret leaves DPAPI-protected storage, and ARM64/x64 fixture
+outputs are identical after normalization.
+
+### Phase 1: read-only durable shadow sync
+
+- Add ObjectBox checkpoint, inbox, map, and run records.
+- Fetch without mutating existing message tables.
+- Compare normalized CloudKit records with current local state.
+- Quarantine failures and expose redacted diagnostics.
+- Keep automatic triggers dormant and enforce per-scope entry, estimated-byte,
+ and age admission limits without deleting pending data.
+
+Exit gate: repeated runs lose no records, leak no account state, and resume
+correctly after crash injection. Boundary rejection and a keystore failure must
+leave both the inbox and continuation token unchanged.
+
+### Phase 2: semantic pull
+
+- Apply chats, messages, reactions, read state, and attachment metadata through
+ the shared upsert path.
+- Keep writes and deletions disabled.
+
+Exit gate: deterministic results across replay, process restart, ARM64, and x64.
+
+### Phase 3: durable saves
+
+- Add the durable outbox.
+- Require explicit per-record confirmation.
+- Keep deletions disabled.
+
+Exit gate: a failed, partial, or missing server response cannot suppress retry
+or lose the local operation.
+
+### Phase 4: mutable conflicts and guarded tombstones
+
+- Add edits, retractions, group changes, and conflict tests.
+- Enable deletion only behind a separate opt-in flag.
+
+Exit gate: two-device races, offline changes, and repeated replay produce one
+logical result without destructive resets.
+
+### Phase 5: profile and notification experiments
+
+- Add group photos and explicit shared profiles.
+- Evaluate notification hints only as a scheduler optimization.
+
+Exit gate: feature failure cannot block messaging, startup, or core sync.
+
+## Feature flags and rollback
+
+Use independent flags for:
+
+- Read-only fetch
+- Semantic apply
+- Saves
+- Deletions
+- Profiles
+- Notification hints
+
+Rollback disables network writes while retaining checkpoints, quarantined
+records, and pending outbox operations. Never repair a failed rollout by
+silently clearing the clique, deleting Apple zones, or discarding the local
+database.
+
+## Validation matrix
+
+- Crash before and after every inbox/checkpoint transaction
+- Exact-limit, one-over-limit, stale-journal, pre-budget migration, and
+ checkpoint-protection fault cases
+- Duplicate-record and cross-device race
+- Existing-message semantic update
+- Reaction before parent
+- Edit, unsend, delivery, and read-state conflicts
+- Attachment dependency, partial response, and bounded-memory behavior
+- Interrupted attachment download before and after each verified chunk,
+ corrupt tail truncation, manifest reauthorization, content-integrity failure,
+ atomic placement, and restart after every materialization stage
+- Authorization failure, throttling, server failure, and disk full
+- Account switch and application-architecture switch
+- Corrupt record and missing PCS key with no Rust panic
+- Identical normalized output on Pixel Android, Windows ARM64, and Windows x64
+- Android foreground and background-engine contention, process death, and
+ expired-lease recovery
+- Two Windows devices plus an iPhone, including offline sends, media, group
+ changes, process restart, and network changes
+
+Acceptance requires:
+
+- Zero lost logical message GUIDs
+- Zero duplicate logical messages after replay
+- No account-token bleed
+- Durable retry across restarts
+- Bounded attachment memory
+- No automatic destructive reset
+- No CloudKit failure delaying IDS messaging
+
+## Estimate and priority
+
+A trustworthy beta is approximately two to four weeks:
+
+- Security and durable foundation: four to seven engineering days
+- Safe pull and cross-architecture parity: four to seven days
+- Uploads, conflicts, and attachment reliability: five to ten days
+- Profiles, notification experiments, and multi-device soak: one to two
+ additional calendar weeks
+
+The highest-value contribution is Phase 0 followed by read-only Phase 1.
+Profile bootstrap and conversation backgrounds should wait. They are
+higher-risk and deliver less user value than proving that messages cannot be
+lost, duplicated, or exposed.
diff --git a/docs/CLOUD_SYNC_V2_ANDROID_SCHEDULING.md b/docs/CLOUD_SYNC_V2_ANDROID_SCHEDULING.md
new file mode 100644
index 0000000000..dd9a30a774
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_ANDROID_SCHEDULING.md
@@ -0,0 +1,47 @@
+---
+type: implementation_note
+title: Cloud Sync V2 Android Scheduling Foundation
+description: Dormant WorkManager policy, constraints, and integration gates for Cloud Sync V2.
+resource: openbubbles-app
+tags: [android, workmanager, cloud-sync, battery, safety]
+timestamp: 2026-08-01
+---
+
+# Cloud Sync V2 Android Scheduling Foundation
+
+## Current behavior
+
+The Android scheduling adapter is present but disabled by default. Nothing in
+`MainActivity`, `APNService`, `MethodCallHandler`, or the APNs/IDS receive path
+calls it. The dormant worker validates only redacted scheduling input and exits
+successfully. It does not start a Flutter engine, call Rust, open ObjectBox, or
+perform CloudKit I/O.
+
+When a future reviewed composition explicitly enables it, each request will:
+
+- wait 15 seconds from the first hint, coalescing same-scope hints with one
+ scope-hashed WorkManager unique-work name and `ExistingWorkPolicy.KEEP`;
+- require `CONNECTED`, `BatteryNotLow`, and `StorageNotLow` for metadata;
+- additionally require `UNMETERED` for automatic media;
+- use normal one-time work only. No polling, battery-optimization prompt, or
+ expedited request is present. A user-visible manual kind is explicit but is
+ still normal work until its foreground/user-notification contract is reviewed;
+- allow explicit cancellation by the same scope-hashed unique-work name.
+
+The WorkManager request surviving process death is only a wake handoff. It is
+not a lock and never authorizes work by itself. Any live handoff must acquire,
+renew, and release the existing ObjectBox coordinator lease transactionally.
+Network I/O, Apple cryptography, and CloudKit remain outside ObjectBox
+transactions.
+
+## Remaining gates before any activation
+
+1. Compose a Flutter/Rust entrypoint that reads the durable account-scoped
+ ObjectBox state and treats a lost lease or cancellation as a clean stop.
+2. Test foreground-engine versus APNs-worker contention, process death before
+ and during lease acquisition, and cancellation while the durable worker is
+ pending or active.
+3. Require the V2 read-only shadow, protected storage, and explicit rollout
+ gates already documented in `CLOUD_SYNC_V2.md`. Keep all writes blocked.
+4. Add a user-visible foreground/notification design before considering any
+ expedited behavior. Do not place CloudKit on the IDS/APNs receive path.
diff --git a/docs/CLOUD_SYNC_V2_CANONICAL_MAPPING.md b/docs/CLOUD_SYNC_V2_CANONICAL_MAPPING.md
new file mode 100644
index 0000000000..d23995632a
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_CANONICAL_MAPPING.md
@@ -0,0 +1,750 @@
+---
+type: design-spec
+title: Cloud Sync V2 Canonical Mapping
+description: Field-level contract between native CloudKit decoding, transient Flutter Rust Bridge payloads, canonical app entities, and content-free sync metadata.
+resource: C:\Codex\OpenBubblesReview\openbubbles-app
+tags:
+ - openbubbles
+ - cloud-sync-v2
+ - cloudkit
+ - reconciliation
+ - privacy
+timestamp: 2026-08-01
+---
+
+# Cloud Sync V2 Canonical Mapping
+
+## Status and scope
+
+This document is the reviewed semantic boundary required before Cloud Sync V2
+may write a chat, message, reaction, attachment, or group photo into the
+canonical application database.
+
+It is based on the current Rust CloudKit structs and the existing Dart
+`Chat.applyFromCloud`, `Message.applyFromCloud`, and
+`Attachment.applyFromCloud` behavior. It is a mapping specification, not proof
+that semantic pull is production-ready.
+
+The current V2 decoder only produces a content-free identity projection. The
+current Dart semantic payloads are deliberately narrow scaffolding. Neither is
+yet sufficient to perform the mappings below. `semanticApply` must remain
+disabled until the blockers and fixture gates in this document pass.
+
+This specification does not cover SMS, RCS, iCloud Contacts, scheduled-message
+zones, or general profile discovery. Shared profile records remain a separate,
+opt-in module.
+
+## Source of truth and terminology
+
+The relevant sources are:
+
+- `rustpush/src/imessage/cloud_messages.rs`: `CloudChat`, `CloudMessage`,
+ `CloudAttachment`, `AttachmentMeta`, `MMCSAttachmentMeta`, and edit metadata.
+- `rustpush/src/imessage/cloud_messages.proto`: the four message protobuf
+ envelopes.
+- `lib/database/io/chat.dart`: current chat lookup, upload, and download
+ mapping.
+- `lib/database/io/message.dart`: attributed-body, message, reaction, reply,
+ edit, and retraction mapping.
+- `lib/database/io/attachment.dart`: attachment identity and metadata mapping.
+- `lib/services/rustpush/cloud_sync/cloud_inbox_applier.dart`: dormant
+ transient semantic payload and transactional apply boundary.
+- `rust/src/cloud_sync_semantic_decoder.rs`: current native content-free
+ projection and tombstone reversal.
+
+The words **observed**, **proposed**, and **blocked** are used precisely:
+
+- **Observed** means the current source establishes the format or behavior.
+- **Proposed** means V2 should adopt the rule after the specified fixtures pass.
+- **Blocked** means V2 must not apply that field yet.
+
+## Data lanes and privacy classification
+
+Cloud Sync V2 needs four distinct data lanes. Mixing them is a release blocker.
+
+| Class | Meaning | Examples | Allowed lifetime and destination |
+|---|---|---|---|
+| `N0` native secret | Apple transport, PCS, or MMCS material that the Flutter layer does not need | DSID, PCS keys, raw record name, raw decrypted CloudKit record, MMCS signature, owner, URL, decryption key, `Asset` | Native memory or protected native storage only. Never cross FRB as plaintext. Never log. |
+| `T1` transient canonical plaintext | Decrypted information needed to update the app's existing user-visible data | message GUID and body, sender handle, participant handles, chat name, attributed runs, edit text, attachment display name | May cross FRB only in a typed, redacted, non-serializable DTO. Consume immediately in the canonical write transaction. Never persist in sync journals, checkpoints, conflicts, or diagnostics. |
+| `C1` canonical app data | User data the application already stores and renders | `Message`, `Chat`, `Handle`, `Attachment`, edit history, local materialized media file | May be stored in the existing canonical app entities. This is not sync metadata. Existing app backup and at-rest protections still apply. |
+| `D0` durable content-free sync metadata | State needed for replay, merge, and recovery without user content | account-scoped HMAC identities, etag hash, content digest, protected-reference token, timestamps, counters, allowlisted safe code | May be stored in Cloud Sync V2 ObjectBox records and redacted diagnostics. |
+
+`T1` and `C1` are intentionally different. A message body may be transiently
+decoded and then written to the existing `Message` row because the app must
+render it. The same body must never be copied into a V2 inbox, snapshot,
+record-map, checkpoint, run record, or exception.
+
+The protected raw-envelope reference is a `D0` opaque token. Resolving that
+token yields `N0` data and must remain inside the native protector boundary.
+
+## Common envelope rules
+
+Every decoded mutation must carry:
+
+1. account and zone scope, validated against the active generation;
+2. ordered inbox `changeId`;
+3. HMAC of the opaque server record ID;
+4. HMAC of the logical entity identity;
+5. Cloud entity kind and upsert or tombstone kind;
+6. optional etag hash and validated server timestamps;
+7. a protected raw-envelope reference;
+8. a typed transient payload for an upsert, or a mapped logical identity for a
+ tombstone.
+
+The native decoder must validate record type, encrypted-record shape, required
+identity fields, and PCS availability before returning a semantic DTO. Dart
+must not receive a partially decoded raw `CloudChat`, `CloudMessage`, or
+`CloudAttachment`.
+
+Unknown optional fields are retained through the protected raw envelope.
+Unknown or malformed core identity fields quarantine the event. Missing PCS or
+an unclassified native/upstream error is retryable and must not be converted to
+`malformedRecord`.
+
+## Identity rules
+
+### Scope
+
+All HMAC identities are scoped to:
+
+```
+accountIdentityHash + container + database + zone + rebootstrapGeneration
+```
+
+The HMAC input also includes a versioned domain and entity kind. A hash from one
+account, zone, generation, or entity kind must not resolve in another.
+
+Raw Apple IDs may cross FRB only as `T1` values when the canonical app entity
+needs them. Durable V2 metadata stores their HMACs, never their plaintext.
+
+### Server record identity
+
+`serverRecordIdHash` is the immutable identity of the CloudKit record envelope.
+The raw record name remains `N0`. A record map binds:
+
+```
+serverRecordIdHash -> entity kind + logicalEntityKeyHash + protected server ID
+```
+
+Inbound duplicate logical records are mapping conflicts. V2 must not reproduce
+the legacy behavior that deletes a prior server record while processing an
+inbound page.
+
+### Chat identity and aliases
+
+**Proposed primary identity:** HMAC of `CloudChat.guid` under the chat identity
+domain.
+
+`group_id`, `original_group_id`, and `(service_name, chat_identifier)` are
+aliases, not independent chat entities:
+
+- group `CloudMessage.chat_id` normally resolves through `group_id`;
+- direct `CloudMessage.chat_id` normally has
+ `iMessage;-;` shape and may equal the chat GUID;
+- legacy or restored records can refer to `original_group_id`.
+
+Store each alias as an account-scoped HMAC that resolves to the primary chat
+hash. A conflicting alias mapping quarantines the later mutation.
+
+Display name and participant set are never identity. The current
+`Chat.findFromCloud` participant/display-name fallback is useful for manual
+legacy repair but is unsafe for deterministic V2 replay. It can merge two
+different chats with the same members.
+
+### Message identity
+
+The primary identity is HMAC of `CloudMessage.guid` under the message domain.
+The canonical `Message.guid` receives the plaintext GUID through the transient
+DTO.
+
+The message-to-chat link uses the HMAC of `chat_id` resolved through the chat
+alias map. A missing parent chat defers the message. V2 must not attach it to a
+"current" chat or create a chat from message content alone.
+
+### Reaction identity
+
+A reaction is a `CloudMessage` whose decoded
+`associated_message_type` is in the validated add or removal range. Its
+proposed logical identity is:
+
+```
+HMAC(reaction-domain, reactionGuid + NUL + parentGuid + NUL + parentPart)
+```
+
+Its parent link is the message-domain HMAC of `parentGuid`. The plaintext
+reaction GUID is still stored in the canonical `Message.guid`.
+
+The reaction is deferred if the parent is missing. An add and a removal are
+separate immutable reaction records whose canonical reduction determines the
+visible reaction state. Do not mutate or delete the parent message merely
+because a reaction removal arrived.
+
+A sticker (`associated_message_type == 2`) remains a message with a typed parent
+association. It is not classified as a reaction because its attachment and
+rendering semantics differ.
+
+### Attachment identity and ownership
+
+Observed CloudKit attachment IDs commonly use:
+
+```
+at__
+```
+
+Parse the prefix and decimal part, then treat the entire remaining suffix as
+the message GUID. Do not use `split("_")[2]`, because that truncates a GUID that
+contains an underscore.
+
+For an owned attachment:
+
+```
+logical attachment key =
+ HMAC(attachment-domain, messageGuid + NUL + decimalPart)
+canonical local Attachment.guid = messageGuid + "_" + decimalPart
+owner key = HMAC(message-domain, messageGuid)
+```
+
+For an attachment GUID that does not match the owned form, use an HMAC of the
+complete raw attachment GUID and leave the owner unresolved. Filename, path,
+MD5 prefix, record arrival order, and currently open conversation are never
+identity.
+
+### Group photo identity
+
+A group photo is an embedded chat asset, not an independently trustworthy
+message attachment. Its proposed subentity identity is:
+
+```
+HMAC(group-photo-domain, chatLogicalKeyHash + NUL + groupPhotoGuid)
+```
+
+The root `group_photo_guid` and
+`properties.group_photo_guid` must agree when both are present. A mismatch
+quarantines the photo mutation while allowing independently valid chat fields
+to remain reviewable. A photo asset without a usable photo GUID is not applied.
+
+The `Asset`, MMCS descriptors, and download credentials remain `N0`. Dart
+receives only a protected local file reference after native verification and
+materialization.
+
+## Typed parent-reference parsing
+
+### Reaction and sticker parent
+
+Observed upload grammar:
+
+```
+p:/
+```
+
+The native parser must:
+
+1. require the exact `p:` prefix;
+2. split once at the first `/` after the prefix;
+3. require a non-negative decimal part that fits the canonical integer type;
+4. require a non-empty GUID suffix;
+5. return `{ parentGuid, parentPart, rawRangeLocation, rawRangeLength }`;
+6. HMAC `parentGuid` before producing durable metadata.
+
+The associated range is validation evidence. It must not be used to derive the
+part from the reaction message's own attributed body. The current download
+mapper does exactly that and also stores the unparsed `p:` wrapper in
+`associatedMessageGuid`, so current parent lookup can fail.
+
+`bp` and `bpdi` are balloon payload fields in the native incoming message
+schema. They are not aliases for `p:`. No `bp:` parent wrapper should be
+accepted without a sanitized CloudKit fixture proving a separate format.
+
+### Thread reply parent
+
+Observed upload grammar:
+
+```
+r::
+```
+
+Parse the `r:` prefix and the last separator. The middle substring is the part
+string and the final non-empty substring is the parent GUID. A missing
+separator, empty parent, or implausible part is an unresolved association and
+must be quarantined or durably deferred, not silently discarded.
+
+This grammar is independent of the reaction `p:` grammar. Sanitized fixtures
+must determine whether colons can occur in real part strings or GUIDs before
+the parser is declared final.
+
+## Presence, clear, and default semantics
+
+V2 must preserve field presence before Rust `Default` or Serde defaults erase
+the distinction.
+
+Use three semantic states for every optional or defaulted mutable field:
+
+| State | Meaning | Merge behavior |
+|---|---|---|
+| absent | Field was not present in the decrypted record | Preserve the existing canonical value. |
+| value | Field was present with a valid value, including an intentionally empty string or list | Apply according to field merge rules. |
+| explicit clear | The schema and fixture prove that the present representation means removal | Clear only if the mutation is otherwise authoritative. |
+
+Concrete FRB DTOs should use a non-generic `CloudFieldStateDto` plus a nullable
+typed value, because bridge support for generic presence wrappers should not be
+assumed.
+
+Additional rules:
+
+- Missing required identity is malformed. Empty required identity is malformed.
+- A zero message read or delivery timestamp means no timestamp on creation. It
+ must not erase a newer existing timestamp.
+- `last_read_message_timestamp == 0` is not proof that the chat's latest date
+ should be cleared.
+- Negative attachment dates are valid wire values and must not be rejected.
+- Negative attachment byte counts mean unknown for the current non-negative
+ canonical size field. Do not clamp to zero.
+- An absent vector or map is not automatically an explicit empty collection.
+ The native presence bitmap must distinguish them.
+- Unknown flag bits remain in the protected raw envelope. Known bits may be
+ projected to typed canonical fields.
+- A tombstone without server time remains a server-confirmed tombstone with a
+ null `deletedAt`. Never invent the local clock time.
+
+## Chat field mapping
+
+| Cloud source | Canonical target | Classification | Absent/default and merge rule | Status |
+|---|---|---:|---|---|
+| `guid` | `Chat.guid` or stable cloud identity field, plus logical HMAC | `T1/C1`, HMAC `D0` | Required, non-empty, immutable. Never reconcile by display name. | Proposed after identity fixtures |
+| `chat_identifier` | `Chat.chatIdentifier`, alias HMAC | `T1/C1`, HMAC `D0` | Required for current schema. A changed alias must not create a second chat. | Proposed |
+| `group_id` | `Chat.cloudGuid`, alias HMAC | `T1/C1`, HMAC `D0` | Required for group relationship resolution. Empty is malformed. | Proposed |
+| `original_group_id` | alias HMAC only, optionally canonical compatibility field | `T1`, HMAC `D0` | Preserve prior alias if absent. Conflict quarantines. | Proposed |
+| `service_name` | service validation and chat route | `T1/C1` | Only validated `iMessage` is in V2 scope. Other services are unsupported, not coerced. | Proposed |
+| `style` | `Chat.style` and validated group/direct classification | `C1` | Known observed values 43 and 45 may map. Unknown value is retained protected and does not change group routing. | Fixture required |
+| `is_filtered` | no safe canonical target | protected `N0` | Preserve only in protected raw record. | Blocked |
+| `successful_query` | no safe canonical target | protected `N0` | Upload default is observed, meaning is not established. | Blocked |
+| `state` | no safe canonical target | protected `N0` | Value 3 is common, not a proven canonical state machine. | Blocked |
+| `participants[].uri` | `Chat.handles` and `Handle` rows | `T1/C1`, set digest `D0` | Apply an authoritative participant set only on create or a valid higher group version. Respect account scope and URI normalization. | Proposed |
+| `display_name` | `Chat.displayName` | `T1/C1`, digest `D0` | Absent preserves. Explicit clear requires a fixture. Respect `lockChatName`. | Proposed with clear fixture |
+| `last_addressed_handle` | `Chat.usingHandle` | `T1/C1` | Normalize only recognized email/telephone handles. Empty or malformed preserves existing. | Proposed |
+| `last_read_message_timestamp` | content-free snapshot timestamp; possibly chat latest-date hint | `D0` | Do not overwrite `dbOnlyLatestMessageDate` until fixtures prove the field's meaning. Zero preserves existing. | Blocked for canonical date |
+| `prop001.syndication_type` | no safe canonical target | protected `N0` | Upload code sets 0, semantic meaning is marked as a guess. | Blocked |
+| `proto001.unk1` | no canonical target | protected `N0` | Preserve protected. | Blocked |
+| `properties.pv` | `Chat.groupVersion`, snapshot group version | `C1/D0` | Higher version wins. Equal version with different metadata digest is a conflict. Null cannot authorize group mutation. | Proposed |
+| `properties.last_seen_message_guid` | `Chat.lastReadMessageGuid` | `T1/C1`, HMAC `D0` | Apply only when referenced message identity is valid. Missing parent may defer this subfield. | Proposed |
+| `properties.group_photo_guid` | validated photo identity | `T1`, HMAC `D0` | Must agree with root photo GUID if both exist. | Proposed |
+| `properties.last_modification_date` | group metadata modified time | `D0` | Validate finite/range. It is merge evidence, not local wall-clock authority. | Proposed |
+| `properties.gpufc` | no safe canonical target | protected `N0` | Meaning unverified. | Blocked |
+| `properties.number_of_times_respondedto_thread` | no safe canonical target | protected `N0` | Upload value is guessed. | Blocked |
+| `properties.should_force_to_sms` | no V2 iMessage target | protected `N0` | Must not change SMS routing. | Blocked |
+| `properties.message_handshake_state` | no safe canonical target | protected `N0` | Must not be used as authentication proof. | Blocked |
+| `properties.legacy_group_identifiers` | alias candidates only after format validation | `T1`, HMAC `D0` | Never store plaintext in sync metadata or auto-merge on an unvalidated value. | Fixture required |
+| root `group_photo_guid` | group-photo identity and `Chat.photoAttachmentGuid` compatibility value | `T1/C1`, HMAC `D0` | Absent preserves. Explicit clear requires presence evidence and authoritative group version. | Proposed with clear fixture |
+| `group_photo: Asset` | `Chat.customAvatarPath` after verified materialization | native `N0`, protected reference `D0`, file `C1` | Network and file work occur outside ObjectBox transaction. Respect `lockChatIcon`. | Proposed after media adapter |
+| CloudKit etag/create/modify/permission | semantic snapshot and record map | `D0` | Hash etag. Validate timestamps. Permission is safe numeric metadata but not authorization by itself. | Proposed |
+
+The legacy `cloudData` field serializes the complete decrypted `CloudChat` for
+later upload reconstruction. V2 must not copy a raw decrypted record into
+Cloud Sync metadata or use `cloudData` as its semantic snapshot. Unknown fields
+belong behind the protected raw-envelope reference.
+
+## Message envelope mapping
+
+| Cloud source | Canonical target | Classification | Absent/default and merge rule | Status |
+|---|---|---:|---|---|
+| `guid` | `Message.guid`, logical HMAC | `T1/C1`, HMAC `D0` | Required, non-empty, immutable. Conflicting immutable content for one GUID quarantines. | Proposed |
+| `chat_id` | `Message.chat` through chat alias map | plaintext stays native/transient, HMAC `D0` | Missing parent chat defers. Do not split an arbitrary semicolon string by fixed index without grammar validation. | Proposed |
+| `sender` | `Message.handle` and `handleId` | `T1/C1`, HMAC or digest `D0` | Empty is valid for from-me records. Otherwise normalize recognized handle syntax. | Proposed |
+| `time` | `Message.dateCreated`, snapshot created time | `C1/D0` | Required Apple-epoch nanoseconds. Validate conversion and range. Immutable after first valid event. | Proposed |
+| `utm` | server/update ordering evidence | `D0` | Optional unencrypted timestamp. It does not replace CloudKit system modification time without fixtures. | Fixture required |
+| `msgType` | semantic classification | `D0` safe enum | Type 2 is an associated record in current upload code, but final classification also requires decoded proto fields. | Proposed |
+| `eCode` | `Message.error` | `C1` | Present unencrypted integer. Do not turn an unknown code into a transport failure. | Proposed |
+| `destination_caller_id` | validation against chat sending handle | `T1` | Do not mutate `Chat.usingHandle` from a single message without corroboration. | Blocked for write |
+| `flags.IS_FROM_ME` | `Message.isFromMe` | `C1/D0` | Typed bit. Cross-check empty sender and account handles, but flags remain authoritative only after fixtures. | Proposed |
+| `flags.IS_DELIVERED`, `IS_READ` | delivery/read booleans corroborating proto timestamps | `C1/D0` | A bit without a timestamp must not invent a date. | Proposed as validation |
+| `flags.HAS_DD_RESULTS` | `Message.hasDdResults` | `C1` | Apply known bit. | Proposed |
+| `flags.IS_FORWARD` | `Message.hasBeenForwarded` only if it represents the same semantic concept | `C1` | Current field is also used for local SMS forwarding. Do not conflate without fixture. | Blocked |
+| `flags.WAS_DELIVERED_QUIETLY` | `Message.wasDeliveredQuietly` | `C1` | Apply known bit. | Proposed |
+| `flags.DID_NOTIFY_RECIPIENT` | `Message.didNotifyRecipient` | `C1` | Apply known bit. | Proposed |
+| other known and unknown flags | typed future fields or protected raw | `D0` or protected `N0` | Never truncate the protected representation merely because current Dart lacks a target. | Protected only |
+| `service` | message/chat service validation | `T1/C1` | V2 accepts validated iMessage. SMS is outside scope and must not be silently imported. | Proposed |
+| `msgProto3.unk2`, `unk3` | no safe canonical target | protected `N0` | Preserve protected. | Blocked |
+
+### `MessageProto`
+
+| Proto field | Canonical target | Classification | Absent/default and merge rule | Status |
+|---|---|---:|---|---|
+| `unk1` | no safe canonical target | protected `N0` | Upload code uses 1, meaning is not established. | Blocked |
+| `subject` | `Message.subject` | `T1/C1`, digest `D0` | Absent preserves on an update. Explicit empty is a value. | Proposed |
+| `text` | `Message.text` | `T1/C1`, digest `D0` | Use as plain-text representation. Do not discard a valid attributed body. | Proposed |
+| `attributedBody` | `Message.attributedBody` | `T1/C1`, digest `D0` | Decode natively into validated strings, ranges, and attributes. Invalid ranges quarantine the content mutation. | Proposed |
+| attributed run message part | `Attributes.messagePart` | `T1/C1`, part hash `D0` | Non-negative integer; stable per message part. | Proposed |
+| attributed run attachment GUID | `Attributes.attachmentGuid` and deferred attachment link | `T1/C1`, attachment HMAC `D0` | Normalize with the safe attachment parser. Missing attachment defers the link, not the message body. | Proposed |
+| mention | `Attributes.mention` | `T1/C1` | Validate range and handle-like value. Never log. | Proposed |
+| audio transcript | `Attributes.audioTranscript` | `T1/C1`, digest `D0` | User content, never sync metadata. | Proposed |
+| text effect and formatting | corresponding `Attributes` fields | `C1` | Unknown effects stay protected. | Proposed for known values |
+| sticker data | `Attributes.stickerData` | `T1/C1`, digest `D0` | Validate numeric ranges and required strings. | Fixture required |
+| `balloonBundleId` | `Message.balloonBundleId` | `T1/C1` | An allowlisted identifier may be stored. Unknown bundle is not an error. | Proposed |
+| `payloadData` | decoded `Message.payloadData` for allowlisted extensions | `T1/C1`, digest `D0` | Decode outside the ObjectBox transaction. Unknown or failed payload stays protected without logging bytes or content. URL balloon support is currently incomplete. | Partial, fixture required |
+| `messageSummaryInfo` | `Message.messageSummaryInfo` | `T1/C1`, edit/retraction digests `D0` | Apply only through the edit contract below. | Proposed after fixtures |
+| `effect` | `Message.expressiveSendStyleId` | `T1/C1` | Allowlisted effect ID, absent preserves. | Proposed |
+| `dateRead` | `Message.dateRead`, snapshot read time | `C1/D0` | Zero means no date on create. Merge by monotonic maximum. Never clear a newer date. | Proposed |
+| `dateDelivered` | `Message.dateDelivered`, snapshot delivered time | `C1/D0` | Same monotonic rule as read time. | Proposed |
+| `unk10`, `unk11`, `unk14` | no safe canonical target | protected `N0` | Preserve protected. | Blocked |
+| `associatedMessageType` | sticker/reaction typed association | `C1/D0` | Validate exact ranges and reaction enum bounds before indexing. Unknown value preserves protected and does not create a reaction. | Proposed |
+| `associatedMessageGuid` | `associatedMessageGuid`, `associatedMessagePart`, parent hash | `T1/C1`, HMAC `D0` | Parse exact `p:` grammar. Never store the wrapper as the canonical GUID. | Proposed, current legacy bug |
+| `associatedMessageRangeLocation/Length` | association validation evidence | `C1/D0` | Validate against the parent part when available. Do not derive the part from the child body. | Proposed |
+
+### `MessageProto2`, replies
+
+| Proto field | Canonical target | Classification | Rule | Status |
+|---|---|---:|---|---|
+| `reply` | `threadOriginatorGuid`, `threadOriginatorPart`, parent hash | `T1/C1`, HMAC `D0` | Parse exact `r:` contract. Missing parent defers association only. | Proposed after grammar fixtures |
+
+### `MessageProto4`
+
+| Proto field | Canonical target | Classification | Rule | Status |
+|---|---|---:|---|---|
+| `associated_message_emoji` | `Message.associatedMessageEmoji` | `T1/C1` | Apply only with a valid associated-message type and parent. | Proposed |
+| `service` | validation only | `T1` | Must agree with supported message service when present. | Proposed |
+| `schedule_type`, `schedule_state` | no verified canonical mapping | protected `N0` | Scheduled-message behavior is out of V2 scope. | Blocked |
+| `groupId` | chat alias validation | `T1`, HMAC `D0` | May corroborate `chat_id`; it must not silently reparent a message. | Fixture required |
+| `sent_or_received_off_grid` | no verified canonical target | protected `N0` | Preserve protected until satellite/off-grid semantics are tested. | Blocked |
+
+## Edit and retraction mapping
+
+`MessageSummaryInfo` is mutable semantic state on the original message. It is
+not a replacement message and must not overwrite immutable original content.
+
+| Summary field | Canonical target | Rule | Status |
+|---|---|---|---|
+| `ec[part][]` | `editedContent[part]` | Part key must be a validated decimal part. Decode every `MessageEdit.t` as attributed content. Order by validated edit date, retain duplicates idempotently by digest. | Proposed |
+| `MessageEdit.d` | `EditedContent.date`, edit-part modified time | Validate finite Apple timestamp representation. Never substitute local now. | Proposed |
+| `MessageEdit.bcg` | no safe target | Preserve protected. Meaning is unverified. | Blocked |
+| `ep` | `editedParts` | Deduplicate validated non-negative parts. A listed part without usable edit content is a conflict or deferred subfield. | Proposed |
+| `otr[part].lo/le` | `originalTextRange[part]` | Validate non-negative range and bounds against original content when available. | Proposed |
+| `rp` | `retractedParts` | Retraction applies to the listed part only. It does not tombstone the whole message. Merge with server-authoritative newer summary state. | Proposed |
+| `ams`, `ampt`, `amc`, `amb`, `amd` | no verified target | Preserve protected. | Blocked |
+| `ust`, `hbr`, `oui`, `osn`, `euh` | no verified target | Preserve protected. `euh` contains handles and must never enter diagnostics. | Blocked |
+
+The snapshot stores only per-part HMAC, revision/date, and content digest.
+Edited plaintext belongs only in the canonical `Message.messageSummaryInfo`.
+
+When an edit and retraction mention the same part, a sanitized Apple fixture
+must establish precedence. Until then, preserve the raw envelope and quarantine
+that part-level mutation rather than choosing by local arrival order.
+
+## Attachment and media mapping
+
+| Cloud source | Canonical target | Classification | Absent/default and merge rule | Status |
+|---|---|---:|---|---|
+| `cm.aguid` | `Attachment.guid`, logical HMAC, owner hash/part | `T1/C1`, HMAC `D0` | Required, non-empty. Parse owned form safely. | Proposed |
+| `cm.mimet` | `Attachment.mimeType` | `T1/C1` | Validate bounded MIME syntax. Absent preserves. | Proposed |
+| `cm.t` | `Attachment.uti` | `T1/C1` | Bounded identifier. Absent preserves. | Proposed |
+| `cm.tn` | `Attachment.transferName` | `T1/C1` | Treat as display name, sanitize path separators for local materialization. | Proposed |
+| `cm.tb` | `Attachment.totalBytes` | `C1/D0` | Non-negative maps directly. Negative means unknown and must not become zero. | Proposed |
+| `cm.ig` | `Attachment.isOutgoing` | `C1` | Apply present boolean. | Proposed |
+| `cm.sdt`, `cm.cdt` | snapshot timing evidence | `D0` | Negative values are valid wire values. Canonical `Attachment` currently lacks date fields. | Snapshot only |
+| `cm.st` | no current canonical transfer-state field | protected `N0` or safe enum `D0` | Do not infer file availability from this value alone. | Blocked for entity |
+| `cm.is` | sticker association validation | `C1/D0` | Current `Attachment` has no sticker field. Corroborate parent message only after fixtures. | Fixture required |
+| `cm.ha` | no current canonical target | protected `N0` | Hidden attachment behavior is unverified. | Blocked |
+| `cm.fn`, `cm.pathc` | no local filesystem authority | `T1`, protected raw | Never materialize to the supplied Apple path. It may be retained only as transient compatibility data. | Blocked for path |
+| `cm.vers` | schema compatibility evidence | `D0` | Unknown version should preserve protected record and block unsafe media apply. | Proposed |
+| `cm.mdh` | weak source checksum hint | `D0` | It is only an observed MD5 prefix. Do not use as sole integrity proof or identity. | Validation hint only |
+| `cm.aui.pgens` | no current canonical target | protected `N0` | Preview generation meaning is not established. | Blocked |
+| `cm.ui.file-size`, UTI, MIME, name | validation against outer metadata | `T1/D0` | Normalize `NumOrString` without panics. Mismatch is diagnostic-safe conflict metadata. | Proposed |
+| `cm.ui.inline-attachment`, `message-part` | native inline media source and part validation | native `N0`, part `D0` | Materialize bounded bytes outside transaction and verify owner/part. | Proposed after fixtures |
+| `cm.ui.mmcs-*`, `decryption-key` | native MMCS downloader only | `N0` | Never cross FRB or enter canonical metadata. | Native only |
+| `lqa: Asset` | verified protected media source | native `N0`, protected ref `D0` | Download, authenticate, and atomically stage outside ObjectBox transaction. | Proposed after media adapter |
+| verified final file | canonical attachment path/file state | `C1` | Rename staged file atomically, then link file and entity in the canonical transaction or recovery journal. | Proposed |
+
+The current attachment mapper stores the raw CloudKit record ID in
+`Attachment.metadata["cloud"]`. V2 must use the protected record map instead.
+The current upload helper also invents a macOS path ending in `test.png` and
+uses current time for several fields. Those guessed upload defaults do not
+define incoming canonical semantics.
+
+## Proposed redacted FRB DTO contract
+
+The following is contract pseudocode. It intentionally does not reuse the
+generated raw CloudKit models.
+
+```text
+CloudCanonicalMutationDto
+ scopeFingerprint: String // D0
+ generation: u64 // D0
+ changeId: String // D0
+ kind: CloudEntityKindDto // D0
+ mutationKind: Upsert | Tombstone // D0
+ serverRecordIdHash: String // D0
+ logicalEntityKeyHash: String // D0
+ parentLogicalKeyHash: String? // D0
+ aliasKeyHashes: List // D0
+ etagHash: String? // D0
+ serverCreatedAtMillis: i64? // D0
+ serverModifiedAtMillis: i64? // D0
+ protectedRawEnvelopeReference: String // D0
+ snapshot: CloudCanonicalSnapshotDto? // D0, content-free
+ payload: CloudCanonicalPayloadDto? // T1, upsert only
+ tombstone: CloudCanonicalTombstoneDto? // D0, tombstone only
+```
+
+Concrete payload variants:
+
+```text
+CloudCanonicalChatDto
+ guid, chatIdentifier, groupId, originalGroupId, service
+ style
+ participantHandles
+ displayName + displayNameState
+ lastAddressedHandle + state
+ groupVersion + state
+ lastSeenMessageGuid + state
+ groupPhotoGuid + state
+ verifiedGroupPhotoLocalReference + state
+
+CloudCanonicalMessageDto
+ guid
+ chatAliasKeyHash
+ senderHandle
+ createdAt
+ error
+ service
+ subject + state
+ text + state
+ attributedBodies + state
+ balloonBundleId + state
+ decodedExtensionPayload + state
+ effect + state
+ readAt + state
+ deliveredAt + state
+ knownFlags
+ association: none | sticker | reactionAdd | reactionRemove
+ parentGuid, parentPart, parentKeyHash
+ associatedRangeLocation, associatedRangeLength
+ replyParentGuid, replyPart, replyParentKeyHash
+ edits, retractedParts
+ associatedEmoji + state
+
+CloudCanonicalAttachmentDto
+ canonicalGuid
+ ownerMessageKeyHash
+ ownerPart
+ uti + state
+ mimeType + state
+ transferName + state
+ totalBytes + state
+ isOutgoing + state
+ verifiedLocalFileReference + state
+
+CloudCanonicalGroupPhotoDto
+ chatKeyHash
+ photoKeyHash
+ photoGuid
+ verifiedLocalFileReference
+```
+
+Contract requirements:
+
+- DTOs implement a fixed redacted `Debug` string and no content-bearing
+ `Display`.
+- DTOs have no JSON, plist, map, analytics, or persistence conversion.
+- Raw record IDs, account IDs, PCS data, MMCS data, `Asset`, and raw CloudKit
+ objects are absent from the DTO types.
+- Payload fields are immutable for the duration of the Dart call.
+- Dart consumes the payload immediately. It may copy values only into existing
+ canonical `C1` entities.
+- Errors expose only an allowlisted category, retry hint, and safe code.
+- Native code clears temporary plaintext byte buffers where practical after
+ bridge transfer and after protected re-encoding.
+- The bridge schema is versioned. Unsupported schema versions defer or
+ quarantine without a partial write.
+
+## Canonical apply transaction
+
+Network, PCS, protobuf/plist decoding, content hashing, MMCS download, and file
+materialization happen before the ObjectBox transaction.
+
+The synchronous canonical transaction must:
+
+1. revalidate scope, generation, and coordinator lease;
+2. check the applied `changeId`;
+3. resolve primary and alias HMAC identities;
+4. verify required parent entities or write a content-free deferred dependency;
+5. read the existing content-free semantic snapshot;
+6. run deterministic merge policy;
+7. write the transient DTO into the existing canonical `Chat`, `Message`,
+ `Handle`, or `Attachment` entities;
+8. write the merged `D0` snapshot and record-map entry;
+9. mark the inbox event applied;
+10. commit all ObjectBox changes atomically.
+
+No `await`, network call, native call, file read, hash calculation, media decode,
+or UI notification is allowed inside that transaction.
+
+The transaction must respect local user locks such as `lockChatName` and
+`lockChatIcon`. A local UI preference is not CloudKit merge state.
+
+## Sanitized fixture matrix
+
+Every fixture must use synthetic GUIDs, handles, names, bodies, filenames,
+record names, keys, and URLs. Test output must assert that none of those
+sentinels appear in logs, snapshots, record maps, exceptions, or `toString`.
+
+| Fixture | Required assertion |
+|---|---|
+| minimal direct chat | Primary chat and service/chat-identifier alias hashes resolve; no display-name/participant identity fallback. |
+| group chat create with `pv` | Participants, group name, and aliases apply once; replay is a no-op. |
+| higher group `pv` | Authoritative mutable group fields update; immutable identity does not. |
+| equal `pv`, same digest | No-op. |
+| equal `pv`, different digest | Content-free conflict, no guessed winner. |
+| missing `pv` on existing group | Identity mapping may update, group-mutating fields preserve. |
+| group photo asset and matching GUIDs | Verified protected file reference reaches canonical photo apply; secrets remain native. |
+| mismatched root/property photo GUIDs | Photo submutation quarantines without corrupting chat. |
+| explicit group photo clear | Clears only with proven presence encoding, authoritative version, and unlocked local icon. |
+| normal text message | Attributed and plain text map, replay does not duplicate. |
+| message before chat | Defers, then applies exactly once after alias resolution. |
+| duplicate message with same immutable digest | No-op plus metadata merge. |
+| duplicate GUID with different immutable body | Quarantine immutable-content conflict. |
+| reaction add `p:0/` | Canonical parent GUID is bare GUID, part is 0, parent hash resolves. |
+| reaction removal in 3000 range | Reduction removes matching reaction without deleting parent. |
+| reaction before parent | Durable content-free dependency, successful replay after parent. |
+| malformed `p:` wrapper | Quarantine without array bounds exception or plaintext log. |
+| unknown reaction type or out-of-range enum index | Preserve protected, no reaction row. |
+| sticker with parent and attachment | Remains associated message, not reaction; attachment link resolves. |
+| reply `r:0:` | Separate reply parent and part map. |
+| malformed reply | Association is deferred/quarantined without dropping base message. |
+| edited part with `ec`, `ep`, and `otr` | Attributed edit, range, timestamp, and digest map idempotently. |
+| multi-edit replay in different page grouping | Deterministic edit order and no duplicate revision. |
+| retracted part in `rp` | Only listed part is retracted. |
+| edit and retract same part | No guessed precedence until fixture-defined policy. |
+| URL balloon | Base message remains usable; unsupported extension stays protected. |
+| unknown extension payload | No payload bytes or content in warning/error text. |
+| attachment `at_0_` | Parser retains entire suffix and correct owner. |
+| standalone attachment GUID | Stable attachment identity with unresolved owner, no guessed message. |
+| MMCS attachment | Key, signature, URL, and owner never cross FRB; verified file does. |
+| inline attachment | Bounded bytes materialize and link to validated part. |
+| negative attachment dates and size | Decode does not panic; size becomes unknown, date preserved as wire evidence. |
+| `NumOrString` numeric/string/bool variants | Valid variants normalize safely; malformed value quarantines field or record per criticality. |
+| omitted optional field versus explicit empty | Preserve versus value semantics remain distinct. |
+| omitted `change_type` with valid record | Classified as upsert by validated record shape. |
+| tombstone with server time | Prior record map resolves authoritative delete. |
+| tombstone without server time | `deletedAt` remains null; no local time is invented. |
+| tombstone without record map | Durable `tombstoneMappingMissing`; no entity guess or delete. |
+| PCS unavailable | Retryable, protected inbox retained. |
+| unknown native/upstream error | Retryable safe failure, not malformed quarantine. |
+| process death after entity write attempt | Entity, snapshot, map, and replay marker all roll back or all commit. |
+| account switch during decode | Scope revalidation rejects mutation before canonical write. |
+
+## Current blockers
+
+### Canonical ObjectBox adapter boundary
+
+`ObjectBoxCanonicalSemanticEntityAdapter` now provides the synchronous,
+default-off boundary used by a future semantic gateway composition. It is not a
+general-purpose importer and must not be enabled by configuration alone.
+
+The adapter requires all of the following for any transaction:
+
+1. an exact active `CloudSyncScope` and rebootstrap generation supplied from
+ already-loaded process state;
+2. a synchronous, scope- and generation-bound resolver from a verified native
+ transient DTO HMAC identity to a canonical plaintext GUID;
+3. an explicit semantic-apply enablement flag; and
+4. no network, filesystem, platform-keystore, hash, async, or UI work while
+ the ObjectBox transaction is open.
+
+Today it can only update a non-null display name on an already-existing,
+unlocked `Chat`. It never creates chats from participant/display-name hints,
+never writes a raw CloudKit ID or protected reference into a canonical entity,
+and refuses message, attachment, reaction, group-photo, profile, and
+tombstone mutations. This narrow implementation is intentional: the current
+payloads do not carry the proven canonical GUIDs, field-presence bitmap,
+direction, timestamps, relationship data, or materialized-media state required
+for safe writes. The identity resolver is a seam for the future native DTO, not
+a hash-reversal mechanism or a durable plaintext mapping table.
+
+The next adapter expansion requires the typed native DTO and the fixture gates
+below. Until then, a caller receives a typed safe failure and the enclosing
+transaction rolls back atomically.
+
+1. `rust/src/cloud_sync_semantic_decoder.rs` returns only an identity projection.
+ It does not produce the rich canonical DTO or field-presence bitmap.
+2. The generated/defaulted Rust record models can collapse missing required
+ fields into empty or zero defaults. V2 must validate raw field presence
+ before typed construction.
+3. The existing Dart semantic payloads contain only a small subset of message,
+ chat, attachment, and reaction data.
+4. ~~`CloudSemanticTombstone.deletedAt` is required in Dart even though a valid
+ server tombstone can omit its timestamp. It must become nullable.~~
+ **Closed.** `deletedAt` is `DateTime?` and covered by Rust
+ `tombstone_supports_present_or_missing_server_time` plus Dart cases in
+ `cloud_inbox_applier_test.dart` and `cloud_sync_release_validation_test.dart`.
+5. Dart currently treats `CloudFailureCategory.unknown` as non-retryable. An
+ unclassified native/upstream failure must remain retryable unless the native
+ boundary positively identifies malformed permanent data.
+
+ **Correction (2026-08-06): do not fix this by flipping the enum.** The
+ bounded-retry behavior this asks for already exists, assembled from three
+ places: `CloudInboxApplier` only *returns* a quarantine without persisting
+ it, and `CloudSyncEngine` re-promotes an `unknown` quarantine to retryable
+ while `attemptCount + 1 < maximumUnknownAttempts`. Because
+ `cloud_sync_engine.dart` gates on `error.category.isRetryable` first, making
+ `unknown` unconditionally retryable would bypass `maximumUnknownAttempts`
+ and turn a bounded retry into an unbounded one.
+
+ The real remaining defect is narrower and belongs with the three-way
+ outcome work: Rust classifies an unrecognized `PushError` as
+ `RetryableUpstreamFailure`, but `CloudSyncProtectedFailureCategory` has no
+ such member, so `native_protected_cloud_sync_transport.dart` collapses it to
+ `CloudFailureCategory.unknown`. A positively-retryable native failure is
+ therefore capped at `maximumUnknownAttempts` and then quarantined. Closing
+ it means carrying a distinct retryable-native category across the bridge,
+ which requires a binding regeneration.
+6. Current legacy message download stores `p:/` directly in
+ `associatedMessageGuid` and derives the part from the child attributed body.
+ This breaks canonical reaction-parent lookup. The V2 Rust converter is
+ correct; `lib/database/io/message.dart` still does both.
+7. ~~Current reaction type indexing can throw if Apple supplies an unknown value
+ inside the broad numeric range.~~ **Closed for the legacy download path.**
+ `ReactionTypes.fromAssociatedMessageType` bounds the lookup and returns null
+ for an unnamed type, so the message still syncs without a reaction row.
+ Covered by `test/helpers/reaction_helpers_test.dart`, which sweeps the whole
+ 2000-3999 range. The V2 Rust converter already used narrow ranges.
+8. ~~Current attachment owner parsing uses fixed underscore indexes and can
+ truncate identifiers.~~ **Closed.** `lib/utils/attachment_guid_utils.dart`
+ parses the prefix, one decimal part, and the whole remaining suffix, and is
+ now the single implementation behind all five former copies plus
+ `Attachment.applyFromCloud`. Covered by
+ `test/utils/attachment_guid_utils_test.dart`.
+9. Current attachment mapping stores a raw CloudKit record ID in canonical
+ attachment metadata rather than using the protected record map.
+10. Current chat fallback identity uses display name and participant sets.
+ Deterministic V2 reconciliation must not use it.
+11. Current chat apply stores a serialized decrypted `CloudChat` in
+ `cloudData`. V2 has no reviewed protected replacement for lossless
+ round-trip upload.
+12. Root versus property group-photo GUID precedence and explicit-clear
+ encoding are not fixture-proven.
+13. No V2 media adapter currently turns native `Asset` or MMCS state into an
+ authenticated protected local file reference.
+14. URL balloons, unknown extension payloads, scheduled/off-grid fields, legacy
+ group aliases, and several summary properties have no safe canonical
+ mapping.
+15. The ObjectBox semantic gateway that atomically writes canonical entities,
+ snapshots, mappings, dependency rows, and replay markers is not implemented.
+16. Sanitized real-device fixtures for parent grammar, group versions, clear
+ semantics, edits, retractions, and media have not passed on Android,
+ Windows ARM64, and Windows x64.
+
+## Implementation gates
+
+Implement in this order:
+
+1. Freeze sanitized fixtures and required-field presence rules.
+2. Add private native canonical DTOs and tests without exposing FRB symbols.
+3. Review redaction and secret-lane tests, then expose only the typed DTO
+ facade through FRB and regenerate bindings once.
+4. Expand Dart transient payloads and implement the synchronous ObjectBox
+ canonical gateway.
+5. Run replay, rollback, account-switch, parent-ordering, media-integrity, and
+ cross-architecture fixture suites in read-only shadow comparison.
+6. Enable semantic apply only behind a kill switch and staged validation plan.
+
+No save, delete, tombstone apply, or production composition should be enabled
+merely because the field mapping compiles.
diff --git a/docs/CLOUD_SYNC_V2_FIELD_OWNERSHIP.md b/docs/CLOUD_SYNC_V2_FIELD_OWNERSHIP.md
new file mode 100644
index 0000000000..e81ab25446
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_FIELD_OWNERSHIP.md
@@ -0,0 +1,200 @@
+---
+type: specification
+title: OpenBubbles Cloud Sync V2 Field Ownership
+description: Which fields the server owns, which the device owns, and the merge rule each class carries, so that semantic apply cannot silently overwrite local state.
+resource: openbubbles-app
+tags: [cloudkit, sync, merge, schema, phase2]
+timestamp: 2026-08-06
+---
+
+# Cloud Sync V2 field ownership
+
+## Why this exists before the adapter
+
+Semantic apply is the first thing that writes CloudKit-derived data into the
+local message tables. Without a written classification, "the server wins" is
+implemented field by field from memory, and the first time it is wrong the
+symptom is a user's local state being silently overwritten. That is discovered
+from a bug report, not from a test.
+
+This document is the input to that adapter. It is deliberately derived from
+what the existing `applyFromCloud` path actually writes, not from what the
+schema could theoretically carry.
+
+## The three classes
+
+**Server-immutable.** Content that cannot legitimately change after the message
+exists. First valid canonical event wins. A later record carrying different
+content is a conflict and is quarantined for diagnosis, never applied over the
+existing value.
+
+**Server-mutable.** State Apple continues to update. The server is the
+authority, but a record may arrive carrying older state than we already hold,
+because CloudKit gives no intra-batch ordering guarantee. Every write in this
+class must go through a monotonic comparison rather than direct assignment.
+
+**Device-owned.** Never written by projection under any circumstance. Not
+"usually not" — the adapter must have no code path that assigns them.
+
+## Message
+
+### Server-immutable
+
+`guid`, `dateCreated`, `isFromMe`, `handleId`, `handle`, `text`, `subject`,
+`attributedBody`, `payloadData`, `hasApplePayloadData`, `balloonBundleId`,
+`expressiveSendStyleId`, `threadOriginatorGuid`, `threadOriginatorPart`,
+`associatedMessageGuid`, `associatedMessagePart`, `associatedMessageType`,
+`associatedMessageEmoji`, `chat`.
+
+Notes that matter for the adapter:
+
+- `associatedMessageGuid` holds the **parsed** parent GUID, never Apple's
+ `p:/` wrapper. `associatedMessagePart` is null when Apple sent a
+ bare GUID, which is its partless form. Both come from
+ `CloudAssociatedMessageParentReference`.
+- `associatedMessageType` is null for a reaction type this build has no name
+ for. Null means "not a reaction row", not "unknown reaction".
+- `text` and `attributedBody` are immutable **only for the original message**.
+ Edits do not mutate them; they arrive as edit history, which is
+ server-mutable below.
+
+### Server-mutable
+
+| Field | Merge rule |
+| --- | --- |
+| `dateRead` | Monotonic maximum. Never move backwards. |
+| `dateDelivered` | Monotonic maximum. |
+| `dateDeleted` | Set once by a server-confirmed tombstone. Never cleared by a later record. |
+| `error` | Latest server value. |
+| `hasAttachments` | Recomputed from attachment links, not copied. |
+| `hasReactions` | Recomputed from child reaction rows, not copied. |
+| edit history (`messageSummaryInfo`) | Union by Apple edit metadata. Never replaced wholesale, and an empty collection is not a clear instruction. |
+| retracted parts | Union. A retraction is not undone by a later record that omits it. |
+
+`Message.save()` already preserves the monotonic rule for `dateDelivered` and
+`dateRead`; the adapter must not bypass it by assigning directly.
+
+### Device-owned
+
+`id`, `isBookmarked`, `hasBeenForwarded`, `stagingGuid`, `verificationFailed`,
+`bigEmoji`, `datePlayed`, `country`, `hasDdResults`.
+
+`ckRecordId` and `ckSyncState` are owned by the **legacy** CloudKit path.
+Cloud Sync V2 must not write either. Doing so would make the two paths fight
+over the same columns, and the record map exists precisely so V2 does not need
+them.
+
+#### Divergence from upstream on conversion failure
+
+This branch changed how the legacy upload path sets `ckSyncState` when
+`Message.toCloud()` throws, and a reviewer will notice, so the reasoning is
+recorded here rather than left to a diff.
+
+Upstream marks the message synced regardless of outcome:
+
+```dart
+} catch (e, s) {
+ Logger.warn("Failure to convert to cloud", error: e, trace: s);
+ continue;
+} finally {
+ message.ckSyncState = true;
+}
+```
+
+A message that fails to convert is therefore recorded as crawled and is never
+retried. It silently never reaches CloudKit. This branch drops the `finally`,
+leaves `ckSyncState` false, releases any record id that was minted for the
+failed attempt, and counts the message as retryable.
+
+The trade is deliberate: upstream terminates but loses the message, and this
+branch keeps the message but re-attempts it on every future sync. The
+re-attempt is bounded within a pass. `CloudMessageUploadBatchResult.madeProgress`
+is false when nothing converted, and the driver in `rustpush_service.dart`
+breaks out and logs how many messages stayed queued, so a wholly unconvertible
+backlog cannot spin.
+
+What is still missing is persistence. Nothing records that a specific message
+has failed conversion across sessions, so a permanently unconvertible message
+is invisible unless someone reads the logs and correlates by hand. A pass that
+converts some messages and not others never trips the `madeProgress` break, so
+the stuck ones ride along indefinitely without being surfaced. Closing that
+properly needs a durable per-message attempt count, which is an ObjectBox schema
+change and is deliberately not being made ahead of the first live run. This
+affects the upload direction only; the read-only sampler does not exercise it.
+
+## Chat
+
+**Server-immutable:** `guid`, `chatIdentifier`, `isGroup`, `style`.
+
+**Server-mutable:** `displayName`, `participants` and the `handles` relation,
+`groupVersion` (higher wins), `lastReadMessageGuid` (monotonic against local
+read position), `photoAttachmentGuid`.
+
+**Device-owned:** `id`, `isPinned`, `isMuted`, `muteType`, `muteArgs`,
+`isArchived`, `hasUnreadMessage`, `textFieldText`, `textFieldAttachments`,
+`customAvatarPath`, `pinIndex`, `autoSendReadReceipts`,
+`autoSendTypingIndicators`.
+
+Two locks already exist and are load-bearing: `lockChatName` and
+`lockChatIcon`. When either is set the user has overridden that value
+deliberately, and projection must skip the corresponding server-mutable field
+even though the server owns it. This is the one place where a device-owned
+decision outranks server authority.
+
+## Attachment
+
+**Server-immutable:** `guid`, `uti`, `mimeType`, `transferName`, `totalBytes`,
+`isOutgoing`, `message` relation, owner part.
+
+**Server-mutable:** none currently identified. Attachment metadata does not
+change after upload in the shapes we handle.
+
+**Device-owned:** `id`, `bytes`, `sourcePath`, local file placement, and
+`metadata["cloud"]`, which the canonical mapping already says should move to
+the protected record map rather than being written into entity metadata.
+
+## Rules the adapter must follow
+
+1. **No direct assignment to a server-mutable field.** Every one goes through a
+ comparison helper. `cloud_merge_policy.dart` already has the monotonic
+ helper and the `modifiedAt` and `retractedAt` comparisons; extend it rather
+ than assigning in the adapter.
+2. **No assignment to a device-owned field, ever.** Not conditionally, not with
+ a null check.
+3. **A conflicting server-immutable value is a quarantine**, not an overwrite.
+ The record is preserved for diagnosis and the local row is left alone.
+4. **Respect `lockChatName` and `lockChatIcon`** before writing the fields they
+ guard.
+5. **Recompute rather than copy** `hasAttachments` and `hasReactions`. They are
+ derived, and copying them from a record makes them disagree with the rows
+ they summarise.
+
+## Why this removes the need for a per-row sequence number
+
+The checkpoint, the inbox status, and the projected rows already commit inside
+one ObjectBox write transaction, so a crash cannot apply a row without also
+recording that it was applied. Exactly-once projection follows from that
+transaction, not from a version column.
+
+What the transaction does not prevent is a record carrying **older** state
+overwriting newer state, since CloudKit gives no intra-batch ordering
+guarantee. That is an ordering problem confined to the server-mutable set
+above, and the monotonic rules there address it directly.
+
+A per-row watermark would additionally require deciding what a single sequence
+means for a row written from more than one zone. This classification dissolves
+that question: a message body only ever comes from the message zone and an
+attachment link only from the attachment zone, so field ownership already
+partitions by zone.
+
+Revisit if a second writer appears. Once Phase 3 uploads land, local mutations
+compete with server state and "server wins" stops being sufficient on its own.
+
+## Open items
+
+- The device-owned lists were read from the current entity definitions. They
+ must be re-checked whenever a field is added, and that check belongs in the
+ schema-freeze review.
+- `Chat.lastReadMessageGuid` needs a decision on whether the local read
+ position may ever move backwards to match the server. It is currently listed
+ as monotonic against the local position, which is the conservative reading.
diff --git a/docs/CLOUD_SYNC_V2_LIVE_VALIDATION.md b/docs/CLOUD_SYNC_V2_LIVE_VALIDATION.md
new file mode 100644
index 0000000000..aef67dc2b1
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_LIVE_VALIDATION.md
@@ -0,0 +1,375 @@
+---
+type: validation_plan
+title: OpenBubbles Cloud Sync V2 Live Validation
+description: Safety-gated two-account validation runbook for Pixel Android, Windows ARM64, Windows x64, and a Mac truth source.
+resource: openbubbles-app
+tags: [android, pixel, windows, arm64, x64, cloudkit, sync, testing, security]
+timestamp: 2026-08-22
+---
+
+# OpenBubbles Cloud Sync V2 live validation
+
+## Current readiness
+
+The bounded Android read-only shadow and semantic-pull canaries now have
+production composition paths, explicit developer-only manual triggers,
+fail-closed preflight, protected persistence, and redacted reports. They remain
+separately compile-gated and are not connected to the legacy `Messages in
+iCloud (BETA)` switch. That legacy switch must remain off throughout these
+canaries.
+
+Offline evidence on 2026-08-22 at `75e779b2d`: 533 Flutter tests, 130 main
+Rust tests, and 43 standalone protector tests passed. An independent final
+audit found no P0-P2 release blocker for the bounded read-only semantic canary,
+and its 84 focused tests passed. The exact Beta sampler job, Windows x64,
+Windows ARM64, bridge, and fail-closed media-provenance jobs are green. The
+remaining Alpha packaging steps are not prerequisites for installing the
+separate Beta canary.
+
+This is readiness for the **first controlled live canary**, not evidence that
+CloudKit V2 works with Apple's production service. No real Apple record has yet
+been decoded or projected by V2. Remote saves, remote deletes, local
+tombstones, automatic triggers, and full-history sync remain disabled.
+
+### Blockers to the first live fetch
+
+| Priority | Blocker | State | Required closure |
+| --- | --- | --- | --- |
+| 1 | Authoritative CI and APK provenance | Closed for Beta canary | Exact `75e779b2d` Beta job passed; downloaded artifact package, APK v2 signature, SHA-256, ARM64 ABI, and required native libraries were verified |
+| 2 | Account and app-data isolation | Open on device | Prove Beta has a distinct package, UID, and data directory; use only the Mac-activated test account and never restore Alpha data into Beta |
+| 3 | Live PCS/keychain authorization | Open | The fail-closed preflight must pass on the test account without resetting a clique, zone, token, or trust relationship |
+| 4 | Account-switch disposal race | Closed offline | Keep the idempotent cancel-and-quiesce tests green; reset must abort before client disposal if the bounded 50-second quiescence wait expires |
+| 5 | Raw-page resource bounds | Closed offline | Keep the 32 MiB page admission and one-page/50-change-per-zone semantic limits green before every device build |
+| 6 | Native package architecture and bridge parity | Closed for Beta canary | ARM64 Android libraries plus Windows x64, Windows ARM64, and bridge jobs passed from exact commit `75e779b2d` |
+| 7 | Live Apple record shapes and ordering | Open | Run shadow first, then the bounded semantic pull, then an immediate replay; stop safely on any deferred, quarantined, retried, skipped, duplicate, or remote-write count |
+| 8 | Remote mutation isolation | Closed for read-only canary | Confirm saves, deletes, tombstones, notification hints, profiles, and automatic triggers remain disabled and the outbox count stays zero |
+
+## Decision
+
+Use a separate, Mac-activated Apple Account as the Cloud Sync V2 test account.
+Keep the user's real account as a message counterparty and do not enable
+experimental CloudKit writes against it.
+
+A different account is useful for producing IDS traffic, but it does not by
+itself prove CloudKit reconciliation. The same test account must run on at
+least two isolated clients, initially Pixel beta and one isolated Windows
+profile. The Mac signed into that test account is the Apple-side truth source.
+Even then, a message appearing on both clients is not proof of Cloud Sync:
+both clients may have independently received the same IDS push. A passing
+semantic-sync test requires explicit `source=cloud` provenance.
+
+## Non-negotiable guardrails
+
+- IDS remains the live send and receive path. CloudKit failure must not delay
+ delivery, local persistence, notifications, or UI updates.
+- The first live phase is manual, read-only shadow sync. The semantic pull is a
+ distinct second canary and may project bounded CloudKit records locally.
+- Keep the existing `Messages in iCloud (BETA)` switch off. It is the legacy
+ mutating engine, not the V2 shadow sampler.
+- Required shadow flags are:
+ - `readOnlyFetch: true`
+ - `semanticApply: false`
+ - `saves: false`
+ - `deletions: false`
+ - `profiles: false`
+ - `notificationHints: false`
+ - `automaticTriggersEnabled: false`
+- Required semantic-canary flags are:
+ - `readOnlyFetch: true`
+ - `semanticApply: true`
+ - `saves: false`
+ - `deletions: false`
+ - `profiles: false`
+ - `notificationHints: false`
+ - `automaticTriggersEnabled: false`
+- The Beta artifact must be compiled with both
+ `OPENBUBBLES_CLOUD_SYNC_V2_SAMPLER=true` and
+ `OPENBUBBLES_CLOUD_SYNC_V2_SEMANTIC_PULL=true`.
+- Never automatically reset an iCloud Keychain clique, delete a CloudKit zone,
+ clear a token after an unknown failure, or discard a pending shadow journal.
+- Never copy a database, checkpoint, protected install secret, or Apple account
+ state between Account A and Account B.
+- Never run Windows ARM64 and Windows x64 processes against the same ObjectBox
+ profile concurrently.
+- Do not place Apple credentials, device passcodes, handles, message content,
+ DSIDs, tokens, PCS keys, or raw server record IDs in test notes or logs.
+
+## Test topology
+
+| Role | Account | Client | Purpose |
+| --- | --- | --- | --- |
+| Counterparty | Account A, real | Existing Pixel OpenBubbles alpha or Apple client | Sends and receives controlled synthetic messages only |
+| System under test | Account B, test | Pixel OpenBubbles beta | IDS baseline and Cloud Sync V2 shadow client |
+| System under test | Account B, test | Isolated Windows profile | Same-account cross-client reconciliation |
+| Truth source | Account B, test | Mac with Messages in iCloud | Confirms Apple-visible message and mutation state |
+| Architecture parity | Account B, test | Windows ARM64 then x64, sequentially | Confirms identical normalized output and protected-state compatibility |
+
+Use a separate Windows user profile for Account B during the first live test.
+That is a stronger isolation boundary than changing environment variables or
+renaming an executable. Architecture-switch testing can later use a synthetic
+copied profile, followed by a backed-up Account B profile, but never Account A
+first.
+
+The current Gradle flavors use distinct Android package IDs:
+
+- Alpha: `com.bluebubbles.messaging.alpha`
+- Beta: `com.bluebubbles.messaging.beta`
+
+Verify each built APK's package, signature, UID, native ABI, and private data
+directory before installing it. Set up Beta fresh for Account B and never
+restore Alpha's backup into it. If that isolation cannot be proven on the
+actual artifacts, use a second Android device rather than an app-cloning tool.
+
+### Verified Beta canary artifact
+
+- Source commit: `75e779b2d86dc3964662a5118e9a58b0d1ffdff1`
+- GitHub Actions run/job: `32583069161` / `Beta Sampler APK`
+- Artifact: `Beta Debug APK (Cloud Sync sampler)`
+- Local file: `C:\Codex\OpenBubblesReview\artifacts\cloud-sync-v2-canary-75e779b2d-run-32583069161\app-beta-debug.apk`
+- Package: `com.bluebubbles.messaging.beta`
+- Version: `1.15.0` (`20002227`)
+- SHA-256: `79F94A0E6456F5EE43BD6164527959709FFBD8712687049AA02BC6DD5B818CBA`
+- APK signature: v2 verified, one Android Debug signer; certificate SHA-256
+ `0c06a6d3d619476917521e75e5c56bd6af81390161217a99419efe48e0577d1c`
+- Required ARM64 libraries present:
+ `libflutter.so`, `libobjectbox-jni.so`, and
+ `librust_lib_bluebubbles.so`
+
+The package/UID/data-directory isolation check remains a live-device gate. Do
+not infer it solely from the distinct package name.
+
+## Readiness gates
+
+### Gate 0: offline foundation
+
+- All focused Dart Cloud Sync tests pass, including ObjectBox durability tests.
+- Focused analyzer reports no issues.
+- Native protector harness passes on Windows ARM64 and x64.
+- Windows production composition uses protected per-install key material rather
+ than the legacy static desktop software-encryption key.
+- Native `Retry-After` reaches Dart and is durably honored across restarts.
+- Repeated continuation tokens produce a typed, bounded no-progress failure,
+ not a generic local-storage error.
+- Generated Flutter Rust Bridge bindings expose the bounded raw-page fetch and
+ protection calls.
+- APK and Windows packages contain the correct native architecture libraries.
+- Read-only shadow runtime rejects every non-shadow feature configuration.
+- A raw-page byte cap is enforced before protection or persistence.
+- A dedicated developer-only V2 shadow sampler passes its fail-closed
+ preflight. It is not wired to the legacy Cloud Sync setting.
+
+### Gate 1: account and storage isolation
+
+- Account B has its own Android app data and Windows profile.
+- Account fingerprints differ without logging either raw identifier.
+- Each checkpoint is scoped by account fingerprint, container, database, zone,
+ stream, and schema version.
+- Switching accounts cannot open the previous account's checkpoint, inbox,
+ outbox, record map, lease, or protected values.
+
+### Gate 2: IDS baseline
+
+With Cloud Sync V2 dormant, Account A and Account B exchange controlled text
+and media in both directions. Capture delivery latency and confirm that no
+message is missing or duplicated. Do not proceed if IDS is already unreliable.
+
+### Gate 3: read-only shadow
+
+Run one manual shadow pass on Account B. It may fetch and journal encrypted raw
+changes, checkpoints, and redacted run metadata. It must not:
+
+- change the existing message, chat, or attachment tables;
+- save or delete any Apple record;
+- reset a trust relationship or CloudKit zone;
+- download full attachment bodies;
+- schedule a second run automatically.
+
+Run the same manual pass again. The second pass must be bounded and
+deterministic, with no duplicate logical changes and no token regression.
+
+### Gate 3B: bounded semantic pull
+
+Proceed only after both shadow passes are clean. Run one manual semantic pull
+in this exact zone order: chats, messages, attachments. Each zone is bounded to
+one page and 50 changes. This canary may project chats, messages, reactions,
+and attachment metadata into the isolated Beta ObjectBox profile. It must not
+download media bodies or apply profiles, display clears, group photos, or
+tombstones.
+
+The UI may report `Cloud Sync V2 Complete` only when all three zones report
+`completed`, no zone is skipped, deferred, quarantined, or retried counts are
+zero, and the remote-write/outbox tripwire remains zero. Any other outcome is
+`Cloud Sync V2 Stopped Safely`; preserve the redacted report and do not repeat
+until the cause is understood.
+
+Immediately run the semantic pull once more in the same account session. The
+replay must create zero duplicate logical records, retain monotonic state, and
+perform zero remote saves or deletes. Confirm the active Apple account scope is
+unchanged immediately before each local transaction.
+
+### Gate 4: same-account cross-client reconciliation
+
+Keep the Windows Account B client offline. Generate controlled events between
+Accounts A and B, then reconnect Windows and run one manual shadow pass. The
+bounded semantic canary can now decode and locally project selected chat,
+message, reaction, and attachment-metadata lanes, but this has not been proven
+against either platform's live Apple data. Raw encrypted bytes and
+platform-specific protection envelopes may differ.
+
+Before cross-client comparison, add an ephemeral test-run HMAC key used only to
+derive comparable event fingerprints from stable Apple record identity. The
+current per-install account fingerprint cannot correlate Pixel and Windows
+events. Never export the raw record identity or HMAC key.
+
+For cross-client semantic comparisons, first add a developer-only,
+auto-expiring **cloud-only destination mode** on Account B that pauses semantic
+IDS ingestion while leaving manual V2 fetch available. Sends remain disabled.
+Without explicit `source=cloud` provenance, visibility on two clients is only
+an IDS test and must not be reported as Cloud Sync proof.
+
+### Gate 5: outbound canary
+
+CloudKit saves remain disabled until the read-only gates pass and outbound
+write activation receives a separate review. When approved, enable writes only
+for Account B and progress in this order:
+
+1. one text message;
+2. one reaction;
+3. one edit;
+4. one retraction;
+5. one small image;
+6. one larger video.
+
+CloudKit deletions remain separately disabled.
+
+## Controlled test matrix
+
+Use markers such as `OB-CS2-T01--` so evidence can be correlated
+without storing personal conversation content.
+
+| ID | Event | Required result |
+| --- | --- | --- |
+| T01 | Account A sends text to B | IDS delivery is immediate; Phase 1 later contains one protected raw event fingerprint; Phase 2 records `source=cloud` |
+| T02 | Account B sends text to A | IDS delivery succeeds independently of CloudKit |
+| T03 | A sends image and video to B | Metadata is bounded; no eager full-media load in the shadow phase |
+| T04 | A reacts to a message | Phase 2: reaction is linked to one parent or safely deferred |
+| T05 | A edits a message twice | Phase 2: revisions are deterministic and no stale text replaces a newer revision |
+| T06 | A retracts a message | Phase 2: tombstone is preserved and the original is not resurrected |
+| T07 | A marks conversation read | Phase 2: read state moves monotonically and cannot move backward |
+| T08 | Windows B offline, then reconnects | One bounded catch-up pass, no duplicates, no IDS delay |
+| T09 | Repeat the same page and token | Idempotent journal result and no checkpoint regression |
+| T10 | Deterministic transport repeats the same non-final token | Typed no-progress failure, bounded retry, no hot loop |
+| T11 | Malformed or undecryptable record | Quarantined with redacted reason; later records are retained |
+| T12 | Process stops around journal/checkpoint commit | Restart produces either the complete transaction or no transaction |
+| T13 | Network changes Wi-Fi to cellular and back | IDS remains healthy; no automatic shadow storm |
+| T14 | Windows ARM64 to x64 to ARM64 | Sequential access only, identical normalized result, protected state still opens |
+| T15 | Account switch on test profile | Previous account namespace is inaccessible; no state bleed |
+
+Advanced Data Protection and Messages-key rotation are separate later tests.
+Do not combine them with the first functional run.
+Repeated tokens, forced server failures, reaction-before-parent ordering, and
+crash boundaries belong in deterministic fault-injection tests; do not try to
+provoke them against Apple's production services.
+
+## Evidence and diagnostics
+
+Every run should record only:
+
+- test ID and build commit;
+- platform, architecture, and client label;
+- phase, trigger, and explicit event source (`ids` or `cloud`);
+- bounded fetched, journaled, quarantined, and rejected counts;
+- duration and typed failure category;
+- checkpoint generation and a keyed or protected diagnostic fingerprint;
+- duplicate logical-GUID count;
+- process memory and CPU summary;
+- whether IDS delivery remained healthy.
+
+For cross-client correlation, derive an event fingerprint with a temporary
+test-run HMAC key. The key must be memory-only, scoped to one run, and destroyed
+after evidence comparison. Do not reuse the per-install account fingerprint
+for this purpose.
+
+Logs must not include message text, handles, Apple account identifiers, DSIDs,
+tokens, PCS material, device passcodes, record names, raw etags, or attachment
+paths. Exported diagnostics must be reviewed before sharing publicly.
+
+## Pass criteria
+
+- Zero lost logical message GUIDs.
+- Zero duplicate logical messages after replay.
+- Zero Account A or Account B state bleed.
+- Zero CloudKit writes or deletions during the shadow phase.
+- Zero automatic clique or zone resets.
+- No checkpoint advance past a rejected or unjournaled page.
+- Bounded pages, records, bytes, wall time, and retries.
+- No measurable IDS send or receive regression.
+- Deterministic normalized results across Pixel, Windows ARM64, and Windows x64.
+- No unbounded attachment memory or eager media download.
+
+## Stop conditions
+
+Stop immediately and preserve diagnostics if any of these occur:
+
+- missing or duplicate message;
+- local message table changes during shadow mode;
+- repeated continuation token without a typed stop;
+- sustained CPU, heat, or memory growth after cancellation;
+- account fingerprint or protected-state mismatch;
+- automatic sync trigger despite the dormant setting;
+- any Apple save, delete, reset, or zone mutation call;
+- any secret or personal content in logs.
+
+## Fastest execution schedule
+
+### First 30 minutes
+
+Do not begin this section until the authoritative PR-head workflows pass and
+the exact downloaded Beta APK passes package, signature, ABI, and native-library
+verification.
+
+1. Confirm Account B, Mac activation, Messages in iCloud, and iCloud Keychain.
+2. Confirm isolated Android package and Windows user profile.
+3. Verify Alpha and Beta package IDs, UIDs, and data directories are distinct.
+4. Run `tooling/cloud_sync/verify_foundation.ps1`.
+5. Back up the Account B test profiles.
+6. Confirm the legacy sync switch is off and V2 has no automatic trigger.
+
+### Next 90 minutes
+
+1. Run the IDS baseline.
+2. Run one manual read-only shadow pass on Pixel.
+3. Replay the shadow pass and inspect its redacted report.
+4. Run one bounded semantic pull and inspect all strict pass criteria.
+5. Replay the semantic pull and verify zero duplicates and zero remote writes.
+6. Repeat the shadow phase on isolated Windows only after Pixel is clean.
+7. Run T01 through T09 and compare redacted counters.
+
+### Soak
+
+Run offline/reconnect, process restart, network change, large-history, and
+architecture-switch cases for at least 24 hours before considering outbound
+CloudKit writes.
+
+## Inputs needed when the operator returns
+
+- A Mac-activated test Apple Account with Messages in iCloud enabled.
+- Access to its 2FA and trusted-device verification, entered locally.
+- A second Android device or proof that Alpha and Beta package/data
+ directories are isolated. The preferred same-device test uses the verified
+ Alpha and Beta flavor packages listed above.
+- Permission to create or use a separate local Windows user for Account B.
+- The Pixel and Mac connected when live validation begins.
+
+Do not send Apple credentials or device passcodes through chat.
+
+## Primary references
+
+- [Apple Platform Security: iMessage security overview](https://support.apple.com/guide/security/imessage-security-overview-secd9764312f/web)
+- [Apple Platform Security: how iMessage sends and receives messages](https://support.apple.com/guide/security/how-imessage-sends-and-receives-messages-sec70e68c949/web)
+- [Apple: Messages in iCloud](https://support.apple.com/guide/icloud/what-you-can-do-with-icloud-and-messages-mma17ed475f7/icloud)
+- [Apple CloudKit synchronization state](https://developer.apple.com/documentation/cloudkit/cksyncengine-5sie5/state-swift.class)
+- [OpenBubbles large-history sync stall](https://github.com/OpenBubbles/openbubbles-app/issues/168)
+- [OpenBubbles large-history performance issue](https://github.com/OpenBubbles/openbubbles-app/issues/194)
+- [OpenBubbles current iCloud sync failures](https://github.com/OpenBubbles/openbubbles-app/issues/222)
+- [OpenBubbles rustpush](https://github.com/OpenBubbles/rustpush)
diff --git a/docs/CLOUD_SYNC_V2_MANUAL_SAMPLER.md b/docs/CLOUD_SYNC_V2_MANUAL_SAMPLER.md
new file mode 100644
index 0000000000..f378bd5e4e
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_MANUAL_SAMPLER.md
@@ -0,0 +1,231 @@
+---
+type: implementation_plan
+title: Cloud Sync V2 Developer Shadow Sampler
+description: Implementation-ready design for the first fail-closed, one-shot, read-only CloudKit validation entry point.
+resource: openbubbles-app
+tags: [cloud-sync, cloudkit, developer-tools, security, validation]
+timestamp: 2026-08-01
+---
+
+# Cloud Sync V2 developer shadow sampler
+
+## Decision
+
+Build a one-shot sampler available only in explicitly enabled developer builds.
+It is created after a confirmation, fetches at most one bounded page from each
+Messages zone, writes only protected V2 journal/checkpoint metadata, exports an
+allowlisted report, and disposes immediately.
+
+Do not create a persistent runtime or connect startup, network, IDS, or
+background callbacks. Do not reuse the legacy `Messages in iCloud (BETA)`
+setting.
+
+Estimated implementation and offline validation: 10 to 16 engineering hours.
+Controlled Account B sampling adds 1 to 2 hours, followed by a 24 to 72-hour
+soak.
+
+## Existing seams
+
+Reuse:
+
+- `CloudSyncShadowRuntime`
+- `CloudSyncEngine`
+- `RustCloudSyncTransport`
+- `RustCloudSyncProtector`
+- `ObjectBoxCloudSyncStore`
+- the existing V2 ObjectBox checkpoint, inbox, lease, outbox, record-map, and
+ run-history boxes
+- the active `cloudMessagesClient`
+- the active application-document directory
+
+The UI belongs behind the active developer controls in
+`troubleshoot_panel.dart`. The similarly named
+`developer_mode_panel.dart_` file is not compiled.
+
+## Hard-coded composition
+
+Use only:
+
+```text
+container: com.apple.messages.cloud
+database: private
+zones:
+ chatManateeZone
+ messageManateeZone
+ attachmentManateeZone
+```
+
+Sampler limits:
+
+- one page per zone;
+- at most 50 changes per page;
+- 8 MiB pending journal budget per scope;
+- 512 pending entries per scope;
+- 24-hour pending age ceiling;
+- 32 MiB raw transport page admission;
+- automatic triggers off;
+- read-only fetch on;
+- semantic apply, saves, deletions, profiles, and notification hints off.
+
+Use one shared protector and ObjectBox store. Construct one read-only engine per
+zone and run them sequentially through `CloudSyncShadowRuntime`.
+
+## Active-account binding
+
+Do not use a cached settings value as the account identity. Add a narrow native
+call that receives the active Cloud Messages client, reads its DSID internally,
+derives the existing per-install HMAC account fingerprint, and returns only the
+fingerprint.
+
+Capture the active push-state object, Cloud Messages client object, and native
+fingerprint. Recheck all three immediately before and after every fetch. If any
+identity changes, discard the fetched page without journaling and return the
+allowlisted `account_changed` failure.
+
+The raw DSID must never enter Dart or diagnostics.
+
+### Dormant production adapter
+
+`CloudSyncProductionSamplerAdapter` is the only production composition seam.
+Constructing it performs no network request and schedules no callback. It
+creates the ObjectBox store, Rust protector, and Rust raw-read transport only
+inside the explicitly confirmed manual sampler.
+
+`cloud_sync_capture_auth_snapshot` accepts the active opaque Cloud Messages
+client and private storage directory. Rust reads the client's DSID, derives the
+per-install account fingerprint and an opaque client-generation tag, then
+returns only those redacted HMAC values. Dart never receives the DSID, Apple
+Account address, token, or key. The same opaque client object is retained in
+the immutable snapshot and supplied to the raw-read transport.
+
+The provider checks object identity after the native capture. The sampler then
+checks the native generation, fingerprint, and client identity before each
+zone and on both sides of every fetch. A replacement race therefore fails
+before the fetched page can reach ObjectBox.
+
+## Fail-closed preflight
+
+Refuse the run unless:
+
+- the compile-time sampler flag is enabled;
+- the platform is Android or Windows;
+- execution is on the UI isolate;
+- RustPush setup and ObjectBox initialization are complete;
+- the private storage directory exists;
+- an active Cloud Messages client exists;
+- logout and legacy cloud sync are inactive;
+- no foreground or background legacy sync is active;
+- no other sampler or coordinator lease owns the three scopes;
+- all V2 outboxes for those scopes are empty;
+- a local protector sentinel round trip succeeds;
+- the account fingerprint remains unchanged;
+- current journal use is inside the sampler budget.
+
+Preflight must not check or reset the clique, refresh authorization or PCS,
+erase tokens, delete zones, or make a network request. The first network call
+occurs only after the confirmation and successful local preflight.
+
+## Write tripwires
+
+Use three independent barriers:
+
+1. `RustCloudSyncTransport` refuses V2 push, allocation, conflict-write, and
+ delete operations.
+2. A shadow-only store façade delegates checkpoint, journal, lease,
+ pull-result, and run-record methods, and rejects semantic/outbox/record-map
+ mutation.
+3. A rejecting inbox applier throws if semantic apply is attempted.
+
+Also add a session-only `cloudV2ShadowRunActive` interlock to legacy CloudKit
+save/delete/upload entry points. An attempted legacy write during a sampler
+must fail locally with `cloud_sync_shadow_write_tripwire`.
+
+Move `recoverExpiredOutboxLeases()` under the `saves` feature gate. A read-only
+sampler must never inspect or mutate outbox work.
+
+A successful report requires `applied`, `confirmed`, `deferred`, and `retried`
+to remain zero.
+
+## Lifecycle
+
+The one-shot controller follows:
+
+```text
+register active sampler
+try
+ run local preflight
+ create runtime
+ run one manual pass
+ validate write tripwires
+ produce redacted report
+finally
+ await runtime disposal and full quiescence
+ unregister active sampler
+```
+
+Logout and account replacement must cancel and await the active sampler before
+clearing state or disposing the Cloud Messages client.
+
+## Report contract
+
+Export only:
+
+- schema version and random local run ID;
+- UTC timestamp;
+- platform, architecture, and build commit;
+- manual read-only mode and disabled automatic-trigger state;
+- at most an eight-character fingerprint prefix;
+- legacy-sync state;
+- configured page/change limits;
+- tripwire state and outbox counts before/after;
+- per-zone status, fetched/journaled/rejected counts, conservative bytes,
+ elapsed time, and allowlisted failure/skip/block category.
+
+Never export Apple IDs, DSIDs, full fingerprints, handles, participants,
+message bodies, filenames, raw or hashed Apple record identifiers, etags,
+batch/change IDs, continuation tokens, protected values, ciphertext, keys, or
+server bodies.
+
+Phase 1 does not prove message semantics. It proves bounded CloudKit access,
+durable protected journaling, checkpoint behavior, isolation, and replay
+idempotence.
+
+## Planned files
+
+New:
+
+- `cloud_sync_dev_gate.dart`
+- `cloud_sync_shadow_store.dart`
+- `cloud_sync_manual_shadow_sampler.dart`
+- `cloud_sync_shadow_report.dart`
+- `cloud_sync_v2_shadow_panel.dart`
+
+Modify:
+
+- `cloud_sync_engine.dart`
+- `rust_cloud_sync_transport.dart`
+- `cloud_sync.dart`
+- `rust/src/api/api.rs` and generated bindings
+- `rustpush_service.dart`
+- `troubleshoot_panel.dart`
+
+No ObjectBox schema change is required.
+
+## Required tests
+
+- sampler is absent when the compile flag is false;
+- construction performs zero network calls;
+- unsupported platform, non-UI isolate, missing/stale account, legacy-sync,
+ active lease, active sampler, and nonempty outbox all fail closed;
+- exact scopes, flags, page count, change count, and budgets cannot be relaxed
+ by UI input;
+- shadow store, transport, and applier reject every mutation route;
+- saves disabled means outbox recovery is never called;
+- account change after fetch discards the page before journaling;
+- success, error, cancellation, concurrent run, route close, logout, and
+ account switch all await quiescence;
+- protector failure leaves checkpoint and journal unchanged;
+- report serialization is allowlisted and passes a forbidden-value scan;
+- static scan confirms sampler files contain no save/delete/upload bridge call;
+- Android fake-binding behavior and Windows ARM64/x64 fixtures normalize
+ identically.
diff --git a/docs/CLOUD_SYNC_V2_NATIVE_PROTECTED_FETCH.md b/docs/CLOUD_SYNC_V2_NATIVE_PROTECTED_FETCH.md
new file mode 100644
index 0000000000..cede0b5f1a
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_NATIVE_PROTECTED_FETCH.md
@@ -0,0 +1,277 @@
+---
+type: design
+title: Cloud Sync V2 Native Protected Fetch Boundary
+description: Private default-off CloudKit page fetch, protection, and two-phase local adoption contract.
+resource: OpenBubbles
+tags:
+ - cloud-sync-v2
+ - cloudkit
+ - privacy
+ - rust
+ - recovery
+timestamp: 2026-08-02
+---
+
+# Cloud Sync V2 Native Protected Fetch Boundary
+
+## Status
+
+This is a private, default-off Rust boundary with a generated Flutter Rust
+Bridge wrapper and a concrete read-only Dart transport. The protected transport
+is deliberately absent from production runtime composition and does not replace
+the dormant Dart raw transport yet.
+
+The boundary proves that raw CloudKit record names, etags, encrypted record
+envelopes, tombstone payloads, and continuation tokens can remain in native
+code. Its outward data is limited to typed operations, fixed safe codes, bounded
+lengths, keyed identifiers, payload digests, opaque protected-local references,
+and an opaque page lease. This is compile- and contract-tested foundation, not a
+production-ready or runtime-enabled sync path.
+
+## Canonical outward grammar
+
+- `change_id`: bare 43-character base64url HMAC-SHA256
+- `record_id_hash`: bare 43-character base64url HMAC-SHA256
+- `etag_hash`: optional bare 43-character base64url HMAC-SHA256
+- `payload_digest`: lowercase 64-character SHA-256 hex
+- protected record identity and raw-envelope references: `obcs2.ref.<43-character-token>`
+- page adoption lease: `obcs2.lease.<32-character-hex-token>`
+- protected native-store recovery identity: `obcs2.store.<43-character-token>`
+
+The HMAC domains for change, record, and etag identifiers are distinct. Raw record identity is stored only in the scope-bound `serverRecordId` protected value. The raw encrypted record or tombstone is stored only in the scope-bound `rawRecord` protected value. A continuation token is stored only in the scope-bound `checkpointToken` protected value.
+
+## Bounds
+
+- maximum 200 changes per page
+- maximum 8 MiB raw bytes per record
+- maximum 24 MiB admitted raw bytes per page
+- maximum 64 KiB continuation token
+- maximum 16 KiB combined record metadata
+- maximum 18 MiB per protected local file
+- maximum 36 MiB aggregate protected plaintext per page lease
+- maximum 48 MiB aggregate protected ciphertext per page lease
+- maximum 401 protected references total per page lease (two per change plus
+ one checkpoint)
+- maximum 128 KiB lease manifest
+- maximum 64 lease manifests processed per recovery pass
+- maximum 4,096 adopted lease identifiers supplied to one recovery call
+- maximum 131,072 ObjectBox-live protected references per maintenance snapshot
+- maximum 64 explicitly retired references per call
+- maximum 64 protected blobs examined per garbage-collection pass
+- maximum 4,096 active lease manifests examined as garbage-collection roots
+- 24-hour minimum orphan grace period across two scans
+- 40-second native fetch deadline
+
+An individually oversized record is represented as a quarantined change. Its protected envelope retains only its digest and length, not the oversized raw bytes. A page that exceeds aggregate admission bounds is rejected before protection.
+
+## Two-phase journal adoption
+
+Protection happens before ObjectBox journal admission, so the native store returns a page lease with the protected page.
+
+The required caller sequence is:
+
+1. Fetch and protect one page. Native code returns D0-safe page data and `page_lease_reference`.
+2. In one ObjectBox transaction, validate the page, write every journal row and protected reference, advance the protected checkpoint when appropriate, and durably record the same `page_lease_reference` as adopted.
+3. After the transaction commits, enumerate a complete ObjectBox liveness
+ snapshot. Compute the exact intersection between references in this page and
+ references adopted by newly inserted inbox rows plus the newly committed
+ checkpoint. Pass that retained subset to
+ `cloud_sync_commit_protected_page_lease`.
+ - Native verifies that every retained reference belongs to the lease and its
+ ciphertext digest still matches.
+ - Native deletes exact-digest manifest entries omitted from the retained
+ subset. This removes duplicate-page blobs immediately.
+ - Native durably writes a committed receipt, then removes the active
+ manifest. Repeating the commit is accepted only with the same exact
+ retained subset.
+4. If validation or the ObjectBox transaction rejects the page, call
+ `cloud_sync_rollback_protected_page_lease`. This removes only files created
+ by that lease whose ciphertext digest still matches the manifest.
+5. Delete the ObjectBox adoption marker only after native commit succeeds, then
+ acknowledge the committed receipt. Receipt acknowledgement is idempotent.
+ A release or acknowledgement failure invalidates the process recovery cache,
+ so the next fetch performs bounded cleanup without requiring a restart.
+6. At startup, before any fetch begins, load durable adopted lease identifiers
+ and a complete ObjectBox liveness snapshot, then pass both to
+ `cloud_sync_recover_abandoned_page_leases`.
+ - An adopted active lease retains only its manifest entries present in the
+ complete ObjectBox liveness set.
+ - An adopted committed receipt is verified against the liveness set and
+ reported for exact marker release.
+ - An unadopted lease is rolled back.
+ - An unadopted committed receipt is removed without deleting its blobs.
+ - Recovery is bounded to 64 manifests per pass and can be called repeatedly.
+
+This closes the crash windows before and after the ObjectBox transaction and
+native commit. Recovery and collection reject an incomplete liveness snapshot.
+The durable adopted-lease row can be deleted only after native recovery or
+commit confirms finalization. A crash before old-checkpoint retirement leaks
+the old capability safely; retirement never precedes the ObjectBox checkpoint
+replacement transaction.
+
+The private native lifecycle signatures are:
+
+```text
+cloud_sync_commit_protected_page_lease(
+ storage_directory,
+ page_lease_reference,
+ retained_references[exact manifest subset]
+)
+ -> Result<(), fixed safe failure>
+
+cloud_sync_acknowledge_committed_page_lease(
+ storage_directory,
+ page_lease_reference
+) -> Result<(), fixed safe failure>
+
+cloud_sync_rollback_protected_page_lease(storage_directory, page_lease_reference)
+ -> Result<(), fixed safe failure>
+
+cloud_sync_recover_abandoned_page_leases(
+ storage_directory,
+ adopted_lease_references[0..=4096],
+ live_references[0..=131072],
+ live_reference_enumeration_complete
+) -> Result<{
+ finalized_adopted_lease_references[],
+ absent_adopted_lease_references[],
+ rolled_back_count,
+ removed_temporary_file_count,
+ has_more
+}, fixed safe failure>
+
+cloud_sync_retire_protected_references(
+ storage_directory,
+ references[0..=64]
+) -> Result
+
+cloud_sync_collect_protected_garbage(
+ storage_directory,
+ live_references[0..=131072],
+ live_reference_enumeration_complete
+) -> Result<{
+ scanned_count,
+ first_observed_count,
+ deleted_count,
+ preserved_live_count,
+ preserved_active_lease_count,
+ has_more
+}, fixed safe failure>
+```
+
+Commit and rollback accept only `obcs2.lease.<32 lowercase hex characters>`.
+They do not accept a page object or any raw record identity. The generated D0
+FRB adapter exposes only these reference-based calls, not the private page
+helper overloads.
+
+The exact finalized-adopted list lets Dart delete only the corresponding ObjectBox adoption rows after recovery. A count alone is insufficient because each recovery pass handles at most 64 manifests.
+
+When `has_more` is true, Dart must keep the full remaining ObjectBox adoption set and call recovery again. It may delete only adoption rows named in `finalized_adopted_lease_references`. This repeats until `has_more` is false. A crash between passes is safe because both native finalization and adoption-row deletion are idempotent.
+
+When `has_more` is false, native recovery has scanned every remaining manifest. It then returns any supplied adoption references with no manifest in `absent_adopted_lease_references`. These are the crash-after-native-commit, before-ObjectBox-marker-delete case and are also safe for Dart to retire. Dart may delete only the union of the exact finalized and absent reference lists from that result.
+
+Commit is idempotent only for the exact retained subset recorded in its durable
+receipt. Rollback and receipt acknowledgement are idempotent for an
+already-finalized valid lease reference. This permits safe retry after a
+process interruption or a directory-fsync error without accepting a broadened
+retained set.
+
+Startup recovery is keyed process-wide by the native-issued
+`obcs2.store.<43-character-token>` identity. Separate Dart wrapper instances
+for the same native store therefore share one recovery barrier without exposing
+the storage path. A failed commit, rollback, adoption-marker release, or
+receipt acknowledgement invalidates that successful process recovery so the
+next fetch re-enters bounded native recovery.
+
+Lease-specific blob tokens prevent one page from owning a pre-existing blob. Rollback also verifies the ciphertext SHA-256 recorded by the lease before deletion. A file that was replaced or did not originate from the lease is preserved.
+
+The store fsyncs the lease manifest before protected blobs become visible, fsyncs every protected blob before rename, and fsyncs the containing directory after manifest creation, blob rename, rollback, and lease commit.
+
+Lease manifests and in-progress files live in dedicated `.leases` and `.temporary` subdirectories. Startup recovery therefore remains bounded even when the store contains years of adopted protected blobs. It removes at most 64 abandoned temporary files and processes at most 64 lease manifests per pass. Either backlog sets `has_more`.
+
+## Liveness, retirement, and bounded garbage collection
+
+ObjectBox is the mark authority. Its complete snapshot includes native
+references from every inbox row, including pending, applied, and quarantined
+terminal rows, plus outbox rows, record maps, attachment materializations, and
+every protected checkpoint. Applied inbox rows are intentionally never
+collectible until a separately reviewed compaction policy removes those rows.
+Enumeration runs in one ObjectBox read transaction and pages row materialization
+in batches of 1,024 to bound transient entity memory.
+
+Native adoption, rollback, retirement, and collection share one store-operation
+mutex. Active lease manifests are additional roots, so a page cannot be
+collected between native protection and ObjectBox adoption. A liveness snapshot
+can become stale after its read transaction; the active-manifest root plus a
+two-scan, 24-hour grace period makes that race leak-first instead of
+delete-first.
+
+One collection pass:
+
+1. rejects an incomplete or malformed liveness snapshot;
+2. reads at most 4,096 active manifests as temporary roots;
+3. examines at most 64 sorted protected files after a durable cursor;
+4. clears prior orphan marks for ObjectBox-live or active-lease references;
+5. records first observation for an unreferenced blob;
+6. deletes only a still-unreferenced blob observed again after at least 24
+ hours, after recomputing and verifying its reference token from the stored
+ bytes.
+
+Collection is private and default-off. The caller must invoke bounded passes;
+no production scheduler is enabled by this gate.
+
+## Retry and checkpoint behavior
+
+Network, throttling, server, authorization, PCS, conflict, malformed response,
+continuation-no-progress, and local-storage failures map to fixed enums.
+Retry-after seconds survive CloudKit and HTTP 429 mapping and are clamped to
+seven days before crossing the bridge.
+
+Checkpoint plaintext binds format version, generation, stream, and the raw continuation token. Platform protection additionally binds account fingerprint, container, database, zone, stream kind, schema version, and purpose. A reference protected under another account, zone, stream, schema, or purpose is rejected before a CloudKit request.
+
+## Remaining production blockers
+
+- Integrate the protected transport into a default-off manual shadow composition
+ and prove the real journal, checkpoint, semantic-store, and restart path. The
+ current adapter maps the protected record-identity reference into
+ `CloudFetchedChange.encryptedServerRecordId`, but it is intentionally not
+ selected by production composition.
+- Serialize recovery and maintenance against every protected fetch sharing the
+ same native-store identity. Recovery is correct only when no fetch is in
+ flight, and a complete ObjectBox liveness snapshot must remain fenced from
+ concurrent protected-reference adoption until its native maintenance call
+ completes.
+- Add platform reopen tests for Android and Windows protected storage.
+- Add process-kill tests around manifest fsync, each blob rename, ObjectBox commit, native lease commit, and bounded recovery.
+- Add real platform endurance tests for periodic maintenance, ObjectBox
+ enumeration near the 131,072-reference fail-closed bound, and interrupted
+ garbage-collection cursor/candidate updates.
+- Complete the concrete native semantic decoder and integrate the canonical
+ converter only after journal durability.
+- Validate native binary packaging and protected-store identity continuity on
+ Pixel Android, Windows x64, and Windows ARM64.
+- Remove or permanently disable the dormant Dart raw transport only after the protected path passes shadow comparison and endurance testing.
+
+## Verification completed for this gate
+
+- FRB 2.3.0 bindings were generated from the Rust API and the generated Rust
+ bridge passed `cargo check --lib` in the Linux ARM64 toolchain.
+- Focused Dart tests cover safe page mapping, tombstone shape, malformed
+ capabilities, read-only enforcement, exact retained subsets, duplicate-page
+ deletion, checkpoint-retirement ordering, lease commit/rollback/recovery,
+ shared native-store recovery identity, same-process release/ack retry,
+ fail-closed incomplete liveness, and strict 43-character account
+ fingerprints.
+- Rust tests cover exact empty/mixed retention, manifest/receipt crash windows,
+ reopen/idempotence, active-lease roots, incomplete liveness, 24-hour
+ two-scan collection, and bounded progress beyond 64 blobs.
+- ObjectBox liveness capture explicitly retains applied terminal inbox
+ references and materializes rows in 1,024-row pages inside one read
+ transaction.
+- Rust and Dart source-contract tests reject raw identifiers, etags, tokens,
+ payloads, credentials, plaintext, and paths from the protected DTO surface.
+- The protected transport remains absent from every production Dart
+ composition.
+
+No production enablement, commit, push, device installation, live CloudKit
+exchange, or platform process-kill validation is part of this gate.
diff --git a/docs/CLOUD_SYNC_V2_OPEN_SOURCE_REVIEW.md b/docs/CLOUD_SYNC_V2_OPEN_SOURCE_REVIEW.md
new file mode 100644
index 0000000000..3d14642863
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_OPEN_SOURCE_REVIEW.md
@@ -0,0 +1,122 @@
+---
+type: research_note
+title: Cloud Sync V2 Open-Source Pattern Review
+description: License-aware review of synchronization projects whose proven patterns can strengthen OpenBubbles Cloud Sync V2.
+resource: openbubbles-app
+tags: [cloud-sync, cloudkit, reliability, open-source, licensing, testing]
+timestamp: 2026-08-01
+---
+
+# Cloud Sync V2 open-source pattern review
+
+## Recommendation
+
+Reimplement four mature patterns in OpenBubbles' own architecture:
+
+1. a durable dependency-aware outbox;
+2. explicit, account-scoped checkpoints;
+3. journal-first page commits with poison-record quarantine;
+4. content-addressed, atomic media storage.
+
+These directly address message loss, duplicate replay, stuck retries, and large
+attachment recovery. Do not add a CRDT engine or replace IDS with CloudKit.
+Those changes add substantial complexity without improving the current
+read-only foundation.
+
+No third-party source code was copied during this review.
+
+## Best references
+
+| Project | Pattern to learn | OpenBubbles application | License posture |
+| --- | --- | --- | --- |
+| [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) | Durable per-room send queues, restart rehydration, dependent work such as upload-before-send, and explicit wedged items | Model each pending CloudKit save as durable work with prerequisites, retry eligibility, terminal quarantine, and restart recovery | Apache-2.0; compatible reference, but reimplement to fit the existing engine |
+| [Apache PouchDB](https://github.com/apache/pouchdb) | Replication-specific checkpoints and conservative recovery from divergent endpoints | Scope tokens by account, container, database, zone, stream, and schema; advance only in the same durable transaction as the complete page journal | Apache-2.0; compatible reference |
+| [Mozilla Application Services](https://github.com/mozilla/application-services) | Independent sync engines coordinated by shared authentication, scheduling, backoff, and telemetry | Keep messages, attachments, profiles, and future state engines isolated while sharing one coordinator and retry policy | Mixed/file-specific licensing; use architectural concepts only unless each source file is reviewed |
+| [Chatmail Core](https://github.com/chatmail/core) | Restart-safe message processing, deduplication, bounded failure handling, and blob lifecycle patterns | Quarantine malformed records without losing later work; write media to a temporary file, verify it, then atomically rename into content-addressed storage | MPL-2.0 at repository root; reimplement concepts or isolate any modified MPL-covered file |
+| [flutter_secure_storage](https://github.com/juliansteenbakker/flutter_secure_storage) | Platform-backed secret storage and migration concerns | Use as a behavior checklist for Android Keystore and Windows protected storage, not as a mandatory dependency | BSD-3-Clause |
+| [Automerge](https://github.com/automerge/automerge) | Conflict-free merging of app-owned collaborative data | Consider only for future drafts or app-owned settings; Apple message history has server semantics and should not become a generic CRDT | MIT |
+| [Signal Desktop](https://github.com/signalapp/Signal-Desktop) | Desktop queue, media, database, and recovery ideas | Architecture-only comparison; do not copy implementation into the Apache-2.0 app without an explicit relicensing decision | AGPL-3.0; direct reuse is not acceptable under the current app license |
+| [OpenBubbles rustpush](https://github.com/OpenBubbles/rustpush) | Existing IDS and private CloudKit protocol boundary | Keep protocol-specific behavior behind the transport interface and document derivative-work boundaries | SSPL-1.0; handle separately from the Apache-2.0 Flutter app |
+
+## Concrete design translations
+
+### 1. Durable outbox state machine
+
+Each outbound logical operation should persist:
+
+- stable operation ID and account-scoped destination;
+- prerequisites, such as attachment upload completion;
+- attempt count, next eligible time, and server `Retry-After`;
+- idempotency identity;
+- `pending`, `inFlight`, `retryable`, `wedged`, `confirmed`, or `cancelled`
+ state;
+- a redacted failure category.
+
+Process one account and stream through a single coordinator. A process crash
+must return stale `inFlight` work to a safe replay state. User-visible failure
+must not silently discard the operation.
+
+### 2. Checkpoint contract
+
+A checkpoint is valid only for one:
+
+`account fingerprint + container + database + zone + stream + schema version`
+
+The page journal, quarantined-record decisions, and replacement continuation
+token must commit atomically. A record that was neither journaled nor durably
+quarantined blocks token advancement. Repeating a completed page must create no
+new logical work.
+
+### 3. Poison-record handling
+
+Malformed, undecryptable, oversized, or unsupported records should enter a
+bounded quarantine with:
+
+- keyed diagnostic fingerprint;
+- safe failure category;
+- first and last observed timestamps;
+- bounded attempt count;
+- source page/run identity without raw Apple identifiers.
+
+Quarantine is evidence, not deletion. A later decoder or key recovery can
+replay it. Repeated poison records must not create an infinite retry loop.
+
+### 4. Media pipeline
+
+For downloaded attachments:
+
+1. stream into a bounded temporary file;
+2. enforce declared and observed byte limits;
+3. compute a content hash while streaming;
+4. verify integrity and expected media type;
+5. atomically rename into content-addressed storage;
+6. persist the database reference only after the file is durable;
+7. reclaim unreferenced temporary files on startup.
+
+This avoids holding full media in memory and makes retry, deduplication, and
+cross-client evidence straightforward.
+
+## What to build first
+
+The highest-return sequence is:
+
+1. propagate and persist `Retry-After`;
+2. add a raw-page byte cap before protection;
+3. add fail-closed V2 composition and a manual shadow sampler;
+4. add redacted provenance and ephemeral cross-client event fingerprints;
+5. add deterministic crash points around journal/checkpoint commit;
+6. only then implement semantic apply and the durable outbox.
+
+CRDT merging, broad background scheduling, CloudKit deletions, and profile
+synchronization should remain deferred. They would expand the failure surface
+before the read-only transport and recovery invariants are live-proven.
+
+## License rule for implementation
+
+For every borrowed implementation idea, record the project, exact source URL,
+license, whether code or only a concept was used, and the OpenBubbles file that
+implements it. Those entries live in the
+[provenance ledger](CLOUD_SYNC_V2_PROVENANCE_LEDGER.md); this section states the
+rule, that document records the evidence. Apache-2.0, BSD, and MIT material may still require notices and
+attribution. MPL code needs file-level handling. AGPL and SSPL code must not be
+copied into the Apache-2.0 application without a deliberate licensing decision.
diff --git a/docs/CLOUD_SYNC_V2_PATH_TO_PRODUCTION.md b/docs/CLOUD_SYNC_V2_PATH_TO_PRODUCTION.md
new file mode 100644
index 0000000000..15ea8ca987
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_PATH_TO_PRODUCTION.md
@@ -0,0 +1,305 @@
+---
+type: roadmap
+title: OpenBubbles Cloud Sync V2 Path to Production
+description: Dependency-ordered sequence from the current verified state to a production rollout, separating code work from work that needs live Apple access, hardware, or a licensing decision.
+resource: openbubbles-app
+tags: [cloudkit, sync, rollout, validation, android, windows, arm64, x64]
+timestamp: 2026-08-22
+---
+
+# Cloud Sync V2 path to production
+
+## Purpose
+
+The rollout plan in [Cloud Sync V2](CLOUD_SYNC_V2.md) describes phases. The
+gate documents describe conditions. Neither says, in order, what is actually
+left and which parts cannot be finished by writing code. This does.
+
+Every claim below is either measured or labelled as an estimate. Where a
+prior document is stale, this one says so rather than repeating it.
+
+## Where this actually stands
+
+Latest local evidence was refreshed on 2026-08-22. The current CI rerun is
+pending after removing an unrelated protobuf rename that broke the committed
+FRB mirror; Windows x64 and ARM64 already pass on the same change set:
+
+| Evidence | Result |
+| --- | --- |
+| Dart suite, ARM64 host | 533 tests pass |
+| Cloud Sync suite, ARM64 host | 388 tests pass |
+| Legacy ObjectBox upgrade probe, ARM64 host | copied pre-V2 database opens; source SHA-256 remains unchanged |
+| Cloud Sync suite, x64 host | 296 pass on the prior cross-platform run |
+| Cloud Sync suite in CI on Linux | passes, first time it has ever run there |
+| `cloud_sync_protector_harness`, ARM64 | 43 tests pass |
+| Kotlin FaceTime unit tests, Alpha variant | 14 tests pass |
+| Windows x64 CI lane | passes |
+| Windows ARM64 CI lane | passes |
+| Release Rust libraries | correct PE ARM64, PE x64, ELF64 AArch64 |
+| `cargo test` on the main crate | 130 tests pass |
+
+What that evidence does **not** cover: no live CloudKit fetch has ever run, no
+record has been decoded from a real Apple account, and nothing has been written
+to a message table by the V2 path. The production code now supports that first
+bounded semantic canary, but code-path coverage is not live-account evidence.
+
+## The honest shape of the remaining work
+
+Three categories, and only the first is ours to finish by typing.
+
+1. **Code and tests.** The bounded semantic canary is now composed behind its
+ own compile-time and Developer Mode gates. It projects supported chats,
+ messages, reactions, and attachment metadata into ObjectBox while remote
+ saves, remote deletes, local tombstones, automatic triggers, and unbounded
+ traversal remain structurally disabled. The remaining code work is
+ hardening found by review and the later write path, not the first read-only
+ semantic projection.
+2. **Live validation.** Cannot be done offline at any effort level. Needs a
+ Mac-activated Apple account with real history, a trusted device for PCS key
+ access, and soak time measured in days.
+3. **Blocked by something other than engineering.** ANGLE from pinned source
+ for the Windows desktop package, a signing keystore for release Android
+ builds, and the licensing question on redistributing libmpv/FFmpeg.
+
+## Sequence
+
+Dependency-ordered. Each step assumes the ones above it.
+
+### Stage 1 — close what CI can prove
+
+Nothing here needs a device or an Apple account.
+
+1. ~~**Land the protocol corrections** from the prior-art review.~~ **Done,
+ with one correction worth recording.** The reaction parent now accepts the
+ bare-GUID partless form and the `bp:` bubble/tapback spelling, both parsers
+ agree on the part spelling, and the legacy CloudKit download path parses the
+ parent instead of storing the wrapper.
+
+ Two of the four items in the original review were already correct in the
+ tree: `filt`/`sqry`/`ste` were already `i64`, and the `MessageSummaryInfo`
+ nesting was already right down to `bcg` inside `MessageEdit`.
+
+ The fourth was wrong, and the error is instructive. The review said
+ `bp`/`bpdi` are IDS wire keys rather than record fields. That is true of the
+ **field names** in the IDS payload, and says nothing about the `bp:`
+ **prefix** inside `associatedMessageGuid`, which is a real shape that this
+ app's own `Message.fromMap` has always stripped. Acting on the first
+ statement as though it covered the second briefly made both parsers reject a
+ valid parent. Field names and identifier prefixes are different things even
+ when they share letters.
+2. ~~**Decide the discovery zone set.**~~ **Done for the raw sampler.** The
+ bounded read-only sampler now inspects the original three Manatee zones plus
+ `messageUpdateZone`, `recoverableMessageDeleteZone`,
+ `scheduledMessageZone`, and `chat1ManateeZone`. Stable native stream tags 4
+ through 7 preserve checkpoint separation. The four auxiliary streams are
+ deliberately rejected before semantic decode, and the existing production
+ record-count path remains limited to its original three zones.
+3. **Resolve the `EMPTY_LIST` question.** The CloudKit wire format has a
+ distinct type for "present but empty" and our tri-state depends on it. Prior
+ art collapses it with absent and therefore cannot answer whether Apple emits
+ it. This is a question for the first live fetch, but the transport must be
+ able to *record* the distinction before that run, or the run cannot answer it.
+
+4. ~~**Decide what unblocks a stalled applied floor.**~~ **Done: advance
+ through terminal quarantine while retaining the journal evidence.**
+ `_advanceContiguousApplied` now treats both `applied` and `quarantined` as
+ terminal. Pending and retryable rows still block the floor.
+
+ The consequence chain is worth stating plainly. One malformed record blocks
+ the floor, the pending journal keeps growing, the journal budget eventually
+ refuses further fetches, and sync stops. If the stall outlasts the CloudKit
+ change-token lifetime the token expires and the cost is a full re-bootstrap
+ of the entire zone, which is far worse than the single record that caused it.
+ That lifetime is undocumented, so no safe stall duration can be assumed.
+
+ The retained quarantine row preserves the protected source reference,
+ failure category, and replay evidence for a later decoder or targeted
+ recovery. It is not silently deleted. The tradeoff is explicit: CloudKit
+ will not automatically re-offer that change after the terminal floor moves,
+ so a future fetch-by-record-name repair path remains useful. This is safer
+ than allowing one malformed record to stop an entire zone indefinitely when
+ Apple's change-token lifetime is undocumented.
+
+Additional safety closures in this stage:
+
+- Dependency-deferred inbox rows now have a bounded terminal path, but only
+ after both eight attempts and three days by default. Ordinary retryable
+ network, server, and storage failures are not captured by that terminal rule.
+- The outbox leases only the earliest nonterminal mutation for each logical
+ entity, so a newer delete cannot overtake an older leased or paused save.
+- Push-only runs can revive an all-paused authorization or PCS outbox after a
+ successful subsystem refresh. Failed refreshes retain the paused row and a
+ durable six-hour retry delay prevents a trigger-driven refresh storm.
+- The current ObjectBox model successfully opens a copied legacy database and
+ leaves the source hash unchanged. That source contained no canonical message
+ rows, so a non-empty real-history migration probe is still required before
+ rollout.
+
+Exit: CI green on all lanes with the corrections landed.
+
+### Stage 2 — first live read-only run
+
+The prerequisites here are the reason this stage is not schedulable purely by us.
+
+Operator inputs, none of which the app can supply: a Mac-activated Apple
+account with Messages in iCloud enabled and real message history; iCloud
+Keychain on with the device admitted to the clique; a trusted device passcode,
+because PCS keys are only available to trusted devices and under Advanced Data
+Protection there is no server-side fallback at all; and local 2FA entry.
+
+5. **Install the sampler build** on a device whose profile is separate from any
+ real account, and verify package identity, UID, native ABI, and data
+ directory before trusting isolation.
+6. **Run the shadow sampler first.** This fetches and protects one bounded page
+ without canonical message-table writes. Do not move to semantic projection
+ if any zone fails its transport, journal, checkpoint, account, or write
+ tripwire checks.
+7. **Read the shadow result carefully.** A zone that returns `completed` with
+ `fetched: 0` and no failure category is a success, but an empty zone and a
+ never-populated zone are indistinguishable in the report. Confirm upload
+ from the Mac first or this stage proves only that transport works.
+8. **Run one semantic pull canary, then one immediate replay in the same
+ session.** It processes chats, messages, then attachments, at one page and
+ 50 changes per zone. A pass requires all three exact zones to complete, zero
+ deferred, quarantined, or retried records, and unchanged empty outbox
+ tripwires. The replay must be in-session: the journal budget rejects pending
+ entries older than 24 hours, so a next-day replay can be legitimately
+ blocked and look like a failure.
+
+Exit: bounded fetch, protected journaling, checkpoint behaviour, canonical
+projection, and replay idempotence demonstrated against a real account. It
+still does not prove full-history coverage, legacy coexistence, or writes.
+
+### Stage 3 — semantic apply
+
+The largest block of code work, and the first point at which the local message
+database is written by this path.
+
+9. ~~**Widen the bridge once.**~~ **Done.** Rich transient payloads now carry
+ validated canonical identities for chats, messages, reactions, and
+ attachment metadata without exposing raw CloudKit identifiers or records.
+
+ Carry the `EMPTY_LIST` observation across at the same time.
+ `CloudRawRecordPresence` already records which fields arrived with that wire
+ type, but nothing can read it from Dart, so the evidence is gathered and
+ discarded. It is held back rather than regenerated for one diagnostic field.
+10. ~~**Implement the Dart semantic decoder boundary.**~~ **Done and composed
+ only by the manual semantic canary.**
+ `RustCloudSemanticDecoder` validates the complete scope, generation,
+ protected source capability, native session before and after decode,
+ one-of result shape, mutation kind, entity kind, logical hashes, field
+ presence, and timestamps. Native failures map only to typed safe categories.
+ Partial messages and unproven tombstone identities defer instead of
+ inventing content or deletion targets. Fifteen focused tests cover all
+ current payload lanes, every native failure code, source/session mismatches,
+ mixed dispositions, partial messages, reactions and removals, explicit
+ clears, edit revisions, tombstones, and auxiliary-zone rejection. The
+ separately compile-gated canary now enables bounded local semantic apply;
+ remote mutations and automatic execution remain disabled.
+11. ~~**Expand the production entity adapter.**~~ **Done for the current canary
+ lanes.** Supported chat, message, reaction, and attachment metadata records
+ map onto the real ObjectBox entities. Display-name clears, profiles, group
+ photos, media bytes, and tombstones remain gated or unsupported rather than
+ guessed.
+12. ~~**Respect the transaction boundary.**~~ **Done.** Canonical mutation,
+ merge snapshot, identity mapping, replay outcome, inbox terminal state, and
+ checkpoint floor share one ObjectBox transaction. A full native account
+ identity recheck occurs immediately before that write.
+13. **Prove no duplicate rows.** `Message.guid` is unique, and the legacy
+ CloudKit path still ships. A live coexistence test is the only way to show
+ V2 does not create duplicates alongside it.
+
+Exit: deterministic identical projection across replay, restart, ARM64, and
+x64, with zero lost and zero duplicated logical message GUIDs.
+
+### Stage 4 — writes
+
+14. **Enforce one writer per account.** V2 becomes the only CloudKit writer.
+ The legacy path may remain available for read/restore during migration, but
+ its uploads and duplicate-record deletion must be structurally unavailable
+ whenever V2 owns the profile. Today the two paths have separate record maps,
+ and legacy duplicate cleanup can otherwise delete a valid V2-owned record.
+15. **Complete durable record and request identity.** Store the proven CloudKit
+ predecessor change tag/CAS value, record type, owner, generation, server
+ ordering, deletion fence, HTTP request UUID, and per-operation UUID. Add
+ reverse uniqueness for server record identity. Do not treat a different
+ opaque ETag hash as proof that an observation is newer, and do not conflate
+ the request UUID, operation UUID, record ETag, or change-feed ETag. Prepare
+ authentication first, then assign and persist the request and operation
+ UUIDs in the same transaction that marks submission started. The native
+ writer must consume those exact identities without refreshing or replaying.
+16. **Validate every push result exactly.** Reject duplicate, missing, and
+ unknown operation IDs before durable confirmation, and bind every result to
+ action, logical identity, payload digest, server identity, and expected
+ change tag. Chat saves are atomic in Apple's iOS 26 implementation; message
+ and attachment saves are non-atomic and require per-record outcomes.
+17. **Fence network workers through completion.** An expired outbox lease may
+ never confirm a late result. Add lease generation/expiry CAS, bounded push
+ duration or heartbeat renewal, coordinator renewal during network work, and
+ attempt-plus-age retry/dead-letter policy. The direct expired-lease result
+ race is now fixed in both ObjectBox and the in-memory reference store; the
+ remaining generation and heartbeat work stays open.
+18. **Couple local mutation and outbox admission.** One ObjectBox transaction
+ must perform the canonical local mutation, allocate its monotonic revision,
+ insert/coalesce the outbox operation, update any proven mapping, and
+ revalidate account plus generation. No production caller has this API yet.
+19. **Retain durable tombstone causality.** A delete needs an existing validated
+ mapping, local/outbox revision checks, a durable deletion fence, and tests
+ proving an older save cannot resurrect it. Incoming chat-record deletion
+ must not erase a local conversation merely because CloudKit reported it;
+ Apple's current importer deliberately leaves that action to IDS.
+20. **Build a dedicated V2 Rust writer.** It must expose zone-specific PCS
+ protection, explicit save policy and atomicity, per-record save/delete
+ results, returned server change tags, conflict classification, and
+ read-after-ambiguous-result reconciliation. The current private protobuf is
+ missing fixture-verified request/response ETag and conflict/protection
+ fields visible in Apple's private client declarations. Obtain serialized
+ fixtures and establish their wire numbers before implementing them; never
+ infer wire numbers from header property order. A missing record after an
+ ambiguous delete and `REQUEST_ALREADY_PROCESSED` without authoritative
+ per-record results both remain unresolved. Do not expose the legacy
+ wrappers as though they satisfy this contract.
+
+Exit: lost-response, duplicate/missing/unknown-result, change-tag conflict,
+lease-expiry, crash-boundary, account-switch, reset-with-outbox,
+save/delete/save, tombstone-replay, and legacy/V2 coexistence tests all pass.
+Only then may a disposable test account run a one-record outbound canary.
+
+### Stage 5 — rollout
+
+21. Android wake-cost suite under Doze, Battery Saver, locked screen, and 24-hour
+ idle, with paired sync-off and sync-on runs. No credible battery claim can
+ be made before this, and none is made now.
+22. Staged rollout with the alarm thresholds already specified in the
+ production-readiness notes wired as rollback signals.
+
+## What is blocked on something other than code
+
+| Item | Blocker | Consequence if unresolved |
+| --- | --- | --- |
+| Windows desktop package | ANGLE built from pinned official source; the bundled media package deliberately refuses the unlicensed third-party ARM64 bundle | No runnable Windows build; the Rust bridge itself already builds for both architectures |
+| Android release signing | Keystore and `android/key.properties` absent | Debug and profile packages only |
+| Public redistribution | libmpv/FFmpeg transitive licence inventory incomplete | Cannot ship publicly regardless of engineering state |
+| `rustpush` crate-level test execution on the Windows host | Smart App Control blocks newly rebuilt test executables | The dedicated CI gate now runs these tests; do not disable the local policy |
+| Cross-client provenance | Per-install fingerprints cannot prove the same event reached two clients | Two clients showing the same message proves nothing without explicit cloud provenance |
+
+## Standing constraints
+
+These are invariants, not preferences.
+
+- IDS delivery and CloudKit state stay separate. A CloudKit failure must never
+ delay a send, a receive, local persistence, or the UI.
+- Never automatically reset an iCloud Keychain clique, delete a zone, clear a
+ token after an unknown failure, or discard a pending journal.
+- Never switch an account in place on an existing local profile.
+- rustpush is SSPL-licensed. Protocol facts learned from it are facts; its code,
+ derive macros, and generated protobuf definitions are expression and must not
+ be absorbed into the Apache-2.0 layer. Keep a provenance ledger entry per
+ borrowed idea.
+
+## What would change this plan
+
+Finding that `messageUpdateZone` and `recoverableMessageDeleteZone` carry edits
+and recoverable deletes would move zone coverage ahead of semantic apply,
+because building the adapter against an incomplete zone set would need redoing.
+That question is answerable in the first live run and should be asked then.
diff --git a/docs/CLOUD_SYNC_V2_PRODUCTION_READINESS_RESEARCH.md b/docs/CLOUD_SYNC_V2_PRODUCTION_READINESS_RESEARCH.md
new file mode 100644
index 0000000000..d30632ccb3
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_PRODUCTION_READINESS_RESEARCH.md
@@ -0,0 +1,1925 @@
+---
+type: research_note
+title: Cloud Sync V2 Production Readiness Research
+description: Current primary-source and upstream-issue evidence for production gates on Android, Windows x64, and Windows ARM64.
+resource: openbubbles-app
+tags: [cloud-sync, cloudkit, android, windows, objectbox, rustpush, production-readiness]
+timestamp: 2026-08-01
+---
+
+# Cloud Sync V2 production readiness research
+
+## Decision
+
+Keep Cloud Sync V2 in read-only shadow mode. Do not enable semantic pull, saves,
+or tombstones for general users until the release gates below pass on a real Pixel,
+Windows x64, and Windows ARM64. The architecture in `CLOUD_SYNC_V2.md` remains
+sound, but current upstream reports prove that authentication/PCS, partial history,
+large-history responsiveness, and two-phase attachments are not yet production
+risks that can be treated as hypothetical.
+
+This note is source research, not an assertion that the private Apple Messages
+CloudKit service supports a particular client behavior. No Apple access was made,
+and no third-party code was copied.
+
+## Primary-source findings that change the implementation gate
+
+### CKSyncEngine is a model, not a usable transport replacement
+
+Apple documents that `CKSyncEngine` must be initialized with its last state
+serialization, and that an app must durably persist every state update together
+with the local changes to which it applies. It batches record changes, has a
+250-record request maximum, emits per-record failures, uses push subscriptions as
+sync hints, monitors account changes, and can cancel outstanding operations.
+It handles transient network and throttling failures, but the application still
+owns conflict handling, zone recreation, and local persistence.
+
+OpenBubbles cannot substitute this API for the private Messages container. Apply
+the same invariants to the Rust transport: atomically couple journal/checkpoint,
+make deletions tombstones, retain a pending operation after a missing or failed
+per-record response, and treat any push as a coalesced *hint* to fetch rather
+than proof of data. On account switch or sign-out, stop the scoped coordinator,
+retire its lease, and never reuse its checkpoint or queue for a different account.
+
+Sources: [Apple CKSyncEngine](https://developer.apple.com/documentation/cloudkit/cksyncengine-5sie5),
+[WWDC23 Sync to iCloud with CKSyncEngine](https://developer.apple.com/videos/play/wwdc2023/10188/),
+and [Apple's MIT sample](https://github.com/apple/sample-cloudkit-sync-engine).
+
+### Android must not poll for near-real-time reconciliation
+
+Android Doze suspends network access and defers JobScheduler work, including
+WorkManager. The platform recommends FCM rather than a persistent client
+connection when it is available. Normal-priority notifications may wait for a
+maintenance window. High priority is only for user-visible, time-sensitive
+notifications; it receives a short processing window, after which an expedited
+WorkManager job may continue necessary work. WorkManager is restart/reboot
+durable, but normal workers are not real-time and have a ten-minute execution
+limit.
+
+For the private APNs/IDS path, do not request a battery-optimization exemption
+merely to improve CloudKit freshness. A received IDS/APNs event, foreground
+resume, manual sync, or detected local gap should enqueue one named, account-
+scoped sync request. Coalesce events for 15 seconds, give an interactive manual
+request a 30-second foreground budget, and let ordinary reconciliation use a
+network-constrained WorkManager job. Use unmetered network for attachment
+prefetch and user-selected media only; metadata and user-tapped media may use a
+connected network. Persist the lease before scheduling, and release it only after
+the ObjectBox transaction marks the page outcome.
+
+Android Keystore operations cross into a system process and Android explicitly
+warns of performance trade-offs. It should unwrap a per-install master once when
+the worker starts, protect it in memory only for that bounded run, and encrypt
+small journal values locally. It must not Keystore-wrap every record, attachment
+chunk, retry, or UI frame.
+
+Sources: [Doze and App Standby](https://developer.android.com/training/monitoring-device-state/doze-standby),
+[WorkManager scheduling](https://developer.android.com/develop/background-work/background-tasks/persistent),
+[battery optimization guidance](https://developer.android.com/develop/background-work/background-tasks/optimize-battery),
+[FCM priority](https://firebase.google.com/docs/cloud-messaging/android-message-priority),
+and [Android Keystore](https://developer.android.com/privacy-and-security/keystore).
+
+### Windows secrets and migrations need one user-scoped, architecture-neutral contract
+
+Default DPAPI protection is decryptable by the same Windows user on the same
+machine. `CRYPTPROTECT_LOCAL_MACHINE` instead permits every local user and is not
+appropriate for Apple identity material. DPAPI is an OS service, not a portable
+backup format; a copied Windows profile or different user must fail closed and
+enter an explicit recovery flow.
+
+Use the same current-user DPAPI blob and format for x64 and ARM64. Protect one
+random master key at profile initialization and use that master for normal
+authenticated encryption, rather than calling DPAPI for every secret use. For a
+migration: take an exclusive, user-qualified lock; parse/authenticate the whole
+legacy file; write and flush a same-directory temporary V2; reopen and verify it;
+then invoke `ReplaceFileW` with a backup. `ReplaceFile` may still fail at several
+steps, so startup must examine original, replacement, and backup candidates by
+format version and authenticated content before declaring a corrupt empty store.
+Do not use TxF as the recovery strategy.
+
+Sources: [CryptProtectData](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata),
+[DPAPI scope guidance](https://learn.microsoft.com/en-us/windows/win32/seccrypto/example-c-program-using-cryptprotectdata),
+and [ReplaceFileFromAppW failure semantics](https://learn.microsoft.com/en-us/windows/win32/api/fileapifromapp/nf-fileapifromapp-replacefilefromappw).
+
+### ObjectBox needs page-level writes, not message-level durability calls
+
+ObjectBox states that commits require a filesystem sync and can cost milliseconds;
+many implicit puts turn a large history into a write-amplification and UI-jank
+problem. Its explicit transactions are ACID and are the right boundary for
+`inbox rows + semantic writes + contiguous applied position`, not for network,
+PCS decryption, or attachment downloading. Keep external Apple IDs as stable
+unique properties, not ObjectBox IDs. Preserve the ObjectBox model JSON and UIDs
+as source-controlled migration state across all three platforms.
+
+For media, stream into a bounded temporary file, hash while streaming, validate,
+flush, atomically place it under a content hash, then transactionally create the
+attachment reference. A message with text and an attachment is expressly a
+two-phase delivery shape upstream, so attachment updates must be idempotent and
+requeue a missing local blob after the metadata replacement commits.
+
+Sources: [ObjectBox transactions](https://docs.objectbox.io/transactions),
+[ObjectBox IDs](https://docs.objectbox.io/advanced/object-ids), and
+[ObjectBox model IDs and UIDs](https://docs.objectbox.io/advanced/meta-model-ids-and-uids).
+
+## Current OpenBubbles evidence
+
+| Evidence | Production implication |
+| --- | --- |
+| [#222](https://github.com/OpenBubbles/openbubbles-app/issues/222), updated 2026-07-22: `AnyhowException (Bad message)` after reinstall/sync | Never make local-state wipe the recovery path. Preserve a redacted failure envelope, checkpoint, and raw protected record reference for diagnosis. |
+| [#186](https://github.com/OpenBubbles/openbubbles-app/issues/186): PCS share-key failure | Missing PCS/clique material is a paused, typed, non-destructive state, not a retry loop or reset condition. |
+| [#212](https://github.com/OpenBubbles/openbubbles-app/issues/212): only partial history and empty chats | A completion UI must distinguish fetched pages, semantically applied records, quarantined records, and declared time-window exclusions. |
+| [#194](https://github.com/OpenBubbles/openbubbles-app/issues/194): eight-year history causes performance issues | Enforce bounded raw-page, protected-journal, semantic-apply, and UI-yield budgets. Time-window sync is a product choice, never an invisible data-loss mechanism. |
+| [#207](https://github.com/OpenBubbles/openbubbles-app/issues/207): text-plus-attachment second phase persists metadata but does not start download | Make attachment metadata updates enqueue idempotent blob work; expose pending/retry state rather than a dead MIME placeholder. |
+| [#169](https://github.com/OpenBubbles/openbubbles-app/issues/169): Apple response schema lacks `trustedPhoneNumbers` | Treat login/profile responses as versioned, optional-field protocol data; decode failures must be recoverable diagnostics, never an unhandled setup crash. |
+| [#226](https://github.com/OpenBubbles/openbubbles-app/pull/226): acknowledged push after failed handling could permanently drop an incoming message | Retain the ordering invariant: commit semantic message state before acknowledgement; a failure leaves the event retryable. |
+| [#231](https://github.com/OpenBubbles/openbubbles-app/pull/231), draft: remaining long-duration and Windows validation | Its Android cache limits are useful guardrails, but x64 installation/launch and ARM64 remain explicit readiness gaps. |
+
+`rustpush` is presently reported by GitHub as license `Other`; keep it as a
+protocol boundary and do not copy source into the Apache-2.0 Flutter layer without
+an explicit license decision. Apple's sample is MIT. ObjectBox documentation and
+Android/Windows documentation are design references, not code sources.
+
+## Direct rustpush transport evidence and V2 adapter requirements
+
+The current [`cloud_messages.rs`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs)
+implements three private zones: `chatManateeZone`, `messageManateeZone`, and
+`attachmentManateeZone`. The record decoder defines encrypted
+[`chatEncryptedv2`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L232-L233),
+[`MessageEncryptedV3`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L314-L315),
+and [`attachment`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L473-L474)
+records. `CloudAttachment` contains metadata and CloudKit assets, but the
+fetch path requests `NO_ASSETS`; it is therefore metadata reconciliation, not
+proof that a local attachment blob is present.
+
+The generic fetch path forwards an opaque continuation token to CloudKit and
+returns the next token plus an in-memory map of `record ID -> decoded record or
+None`. A missing `change.record` becomes `None` (a deletion/tombstone signal).
+However, a record whose type differs from the requested type is silently skipped,
+and a `PCSRecordKeyMissing` clears the cached zone configuration then aborts the
+entire call. The code also dereferences several server fields with `unwrap`.
+See [`sync_records`, lines 503-549](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L503-L549).
+
+V2 must not expose that lossy map as its checkpoint boundary. The Rust bridge
+should return an ordered, bounded raw change envelope for *every* change with:
+record-ID hash, record type, deletion marker, raw protected payload reference,
+server status, and next-token candidate. Dart can then commit the complete page
+to the account-scoped journal before invoking a strict decoder. A missing PCS
+record key becomes a per-record quarantined/paused outcome where possible. If the
+private protocol makes it impossible to continue past the missing key, preserve
+the pre-page token and report the whole page as blocked. Do not silently advance
+to the returned token.
+
+The current save path batches 256 operations and records individual save
+outcomes, which is useful for V2's explicit-confirmation rule. The delete path
+instead retries whole 256-record batches three times at a fixed five-second
+delay and returns only a batch-level result. See
+[`save_records`, lines 552-582](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L552-L582)
+and [`delete_records`, lines 584-599](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L584-L599).
+Before enabling tombstones, change the V2 adapter contract, not necessarily the
+legacy path: retain each delete as a durable outbox item until a specific server
+confirmation is recorded; retry only classified transient failures; and preserve
+any server-provided retry hint. Fixed retries are unacceptable for an automatic
+mobile/desktop scheduler.
+
+The upstream implementation includes a `reset()` routine that deletes the three
+active zones plus several additional private zones. It is not a recovery action
+for V2. An automatic call would be destructive and cannot repair an account
+switch, failed PCS fetch, or continuation issue. See
+[`reset`, lines 620-652](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L620-L652).
+
+One current issue supplies a Windows ARM64 production failure mode that belongs
+in the scheduler tests: a backward NTP correction after Modern Standby can panic
+the IDS identity cache and drive a CloudKit retry loop at approximately 13
+errors/minute with a pegged core. [rustpush #29](https://github.com/OpenBubbles/rustpush/issues/29)
+has the reproduction and evidence. All sync backoff and lease expiry must use a
+monotonic elapsed clock in-process. Persisted eligibility time must tolerate a
+wall-clock rollback by imposing a bounded restart delay, not repeatedly becoming
+immediately eligible.
+
+## Exact platform constraints and recovery additions
+
+For normal metadata reconciliation, require `NetworkType.CONNECTED` plus
+`BatteryNotLow` and `StorageNotLow`. For automatic attachment prefetch, require
+`NetworkType.UNMETERED`, `BatteryNotLow`, and `StorageNotLow`; add `RequiresCharging`
+only for non-user-visible catch-up/backfill. Multiple WorkManager constraints are
+conjunctive, and a worker stops and retries if one becomes unmet mid-run. Do not
+set `DeviceIdle` on an interaction-triggered sync because it intentionally waits
+for inactivity. Sources: [Android work-request constraints](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work)
+and [Constraints.Builder](https://developer.android.com/reference/kotlin/androidx/work/Constraints.Builder).
+
+DPAPI's documented same-user/same-machine scope means a protected blob should
+be architecture-neutral in representation, but this is an interoperability
+requirement to prove, not a Windows guarantee specific to OpenBubbles. The
+release test must copy one synthetic profile between an x64 and ARM64 build
+under the same user, protect/unprotect fixture secrets in both directions, then
+repeat after a partial migration. A different Windows user or machine must fail
+closed. `ReplaceFileW` requires replacement, target, and optional backup to be
+on the same volume, and documents intermediate failure outcomes, so startup
+recovery must authenticate and choose among original, replacement, and backup;
+it must never pick a file merely because it exists. Source:
+[ReplaceFileW](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew).
+
+## Additional focused tests
+
+1. A fixture page containing a tombstone, an unsupported record type, a malformed
+ encrypted record, and a PCS-key-missing record must either journal all four
+ outcomes or leave the old checkpoint unchanged.
+2. Replay a raw page twice on Pixel, Windows x64, and Windows ARM64. Compare a
+ normalized export of logical IDs, revisions, tombstone state, attachment
+ content hashes, and quarantine category. ObjectBox internal IDs, timestamps,
+ filesystem paths, and protected bytes are deliberately excluded.
+3. Deliver attachment metadata twice, then complete or cancel the blob stream in
+ each order. The message must have one attachment reference and at most one
+ final content-addressed file. A restart between file placement and database
+ reference must be repairable by startup reconciliation.
+4. Inject a backward wall-clock adjustment during exponential backoff and after
+ a Windows Modern Standby resume. Assert no panic, no CPU spin, one lease, and
+ no more than the configured retry attempt before its monotonic delay expires.
+
+## Decoder, scheduler, and media details from this follow-up pass
+
+### Treat the private schema as partial knowledge, not a complete contract
+
+Direct inspection shows useful but deliberately uncertain field semantics in
+rustpush. A chat records a stable chat identifier, group identifier, service,
+participants, read timestamp, GUID, optional display name, and optional group
+photo; its `style` comment identifies 45 as normal and 43 as group. See
+[`CloudChat`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L231-L269).
+A message exposes unencrypted `utm`, `msgType`, and `eCode`, while its chat ID,
+sender, Apple-epoch nanosecond time, GUID, service, flags, and compressed
+protobuf payloads are encrypted. See
+[`CloudMessage`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L313-L340).
+Attachment metadata includes MIME type, declared total bytes, transfer state,
+attachment GUID, filename, UTI, created date, and a truncated MD5 field, while
+the record also has an `lqa` asset. See
+[`AttachmentMeta` and `CloudAttachment`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs#L424-L477).
+
+The comments explicitly call some fields unknown and state that dates can be
+negative. V2 must therefore not treat `style`, `state`, transfer state, display
+name, timestamps, filename, or truncated MD5 as an authoritative conflict key
+or integrity proof. Use record identity plus the explicit logical GUIDs where
+present; store unrecognized fields in the protected raw envelope for a later
+decoder instead of normalizing them away. Validate actual blob byte count and a
+full locally computed digest after download. Never derive a filesystem path from
+the cloud filename or UTI.
+
+The local private protocol defines each change as `identifier`, `etag`,
+`recordType`, integer `type`, and optional `record`, and returns both a server
+continuation token and a client change token. That makes a no-record change a
+tombstone observation tied to the immutable identifier and etag, not a reason to
+discard the record identity. It also carries `changedShares`, archived-record,
+delta, obligation, and zone-attribute fields that the current path does not
+model. See
+[`RetrieveChangesResponse`](https://github.com/OpenBubbles/rustpush/blob/master/cloudkit-proto/src/cloudkit.proto#L772-L800).
+V2 raw pages must preserve all of these known fields or explicitly record the
+unsupported field category before checkpoint advancement. A tombstone's durable
+identity is `account scope + zone + record identifier hash + etag`, not only a
+message GUID that may be unavailable after deletion.
+
+### Checkpoint and quarantine model
+
+The Apache CouchDB replication protocol provides a directly applicable durable
+checkpoint rule: record a checkpoint only after a batch has been uploaded and
+committed successfully, so recovery resumes at the last point of success. Its
+history/common-ancestry algorithm also demonstrates why a checkpoint needs a
+replication identity and session provenance, not merely a naked token. See
+[CouchDB replication protocol](https://docs.couchdb.org/en/stable/replication/protocol.html).
+
+For V2, add `checkpointGeneration`, `runID`, and `rawPageDigest` to the
+account-scoped checkpoint. The one ObjectBox transaction that admits a page must
+write: raw inbox rows or per-record quarantine rows, the page digest/generation,
+and the next token candidate. The separate semantic transaction advances the
+applied generation only through the contiguous resolved prefix. If current
+protocol limitations force a whole-page PCS failure, store a scoped
+`blockedPCS` generation with the old fetch token, not the returned token. A
+manual credential/key recovery may clear only that scoped block and replay.
+
+### Android process coordination and unique-work policy
+
+`WorkManager.enqueueUniqueWork` guarantees only one named *work chain*, not a
+single owner of ObjectBox across a foreground Flutter engine, background Flutter
+engine, or an already-running worker. Use a unique name derived from an
+account-scope hash and `KEEP` for a pure debounce trigger. Do not use `REPLACE`:
+it cancels running work, and cancellation/constraint loss invokes `onStopped`.
+The worker must make cleanup/cancellation idempotent and leave its durable lease
+recoverable. `APPEND` is also wrong for recurring hints because a failed or
+cancelled prerequisite propagates that status; `APPEND_OR_REPLACE` is useful only
+when intentionally building a durable follow-up chain. Sources:
+[Manage work](https://developer.android.com/develop/background-work/background-tasks/persistent/how-to/manage-work),
+[ExistingWorkPolicy](https://developer.android.com/reference/androidx/work/ExistingWorkPolicy),
+and [WorkManager](https://developer.android.com/reference/kotlin/androidx/work/WorkManager).
+
+A Dart `RandomAccessFile` lock is not sufficient as the only coordinator: the
+Dart API documents platform-specific semantics, including that multiple isolates
+in one Linux/macOS process can obtain an exclusive advisory lock; on Windows the
+lock is associated with the acquiring file handle. Use the ObjectBox lease row as
+the authority, with a fencing generation checked in every write transaction.
+Optionally add a best-effort native/file lock to reduce contention, but never let
+it decide correctness. Source: [Dart RandomAccessFile.lock](https://api.dart.dev/dart-io/RandomAccessFile/lock.html).
+
+### Attachment placement and time contract
+
+The directly relevant `atomic-blob-store` project documents an appropriate
+media-file contract: size-limited streaming write, content-addressed key,
+complete-blob validation on read, explicit quarantine, and the possibility that
+an atomic-commit error is ambiguous and must be resolved by reloading the
+canonical location. Its license is reported by GitHub as `Other`, so use this as
+an architectural reference only, not copied code. Source:
+[atomic-blob-store](https://github.com/thehouseisonfire/atomic-blob-store).
+
+Implement `attachment_download` as a durable state machine:
+`metadataReady -> tempStreaming -> contentVerified -> filePlaced -> referenced`.
+The temp filename must be generated in the final directory, opened exclusively,
+and never exposed to the UI. Stream with a hard byte limit and a full SHA-256 or
+BLAKE3 digest; validate expected length/type; flush and close; atomically place
+under the digest; reopen and validate on an ambiguous placement error; then add
+the ObjectBox reference. At startup, reconcile final files with references and
+only remove an old, application-owned temp file after confirming that it is not
+the sole recovery evidence for an in-flight journal row.
+
+Use a monotonic clock only for in-process lease expiry, cancellation deadlines,
+and retry delays. Rust documents `Instant` as opaque and monotonic but not
+persistable or guaranteed to span suspension consistently, so it cannot be the
+on-disk schedule. Android's `elapsedRealtime` is monotonic and includes deep
+sleep; Windows QPC is monotonic and independent of external time. On restart,
+compare a persisted wall deadline defensively: if it is implausibly far in the
+future after clock rollback, cap the wait to a small conservative delay, retain
+the attempt count, and record `clockSkew`; do not make the work immediately due
+or reset its budget. Sources: [Rust Instant](https://doc.rust-lang.org/std/time/struct.Instant.html),
+[Android SystemClock](https://developer.android.google.cn/reference/android/os/SystemClock),
+and [Windows QPC guidance](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps).
+
+## New regression cases
+
+1. Decode every known field from the three record types, plus negative dates,
+ unknown enum values, absent optional fields, an unknown record type, and a
+ tombstone with only identifier/etag/type. Assert lossless protected-envelope
+ journaling and no panic.
+2. Race a foreground engine, an APNs background engine, and a WorkManager worker
+ against one account scope. Verify unique-work coalesces hints while the
+ ObjectBox fencing lease admits exactly one semantic/page writer.
+3. Cancel an active unique work request and drop the network constraint mid-page.
+ Verify `onStopped` leaves no in-flight lease permanent, no token movement,
+ and no partially referenced attachment.
+4. Kill during each attachment state and simulate a final-placement ambiguity.
+ Restart must reach one of: verified final blob plus reference, verified final
+ blob awaiting reference, or a retained recoverable temp/journal row. It must
+ never display a completed attachment whose digest was not verified.
+
+## Budget proposal and acceptance tests
+
+These are client policies, deliberately not claimed Apple service limits.
+
+| Area | Budget | Gate |
+| --- | --- | --- |
+| Metadata fetch/admission | 256 records and 4 MiB decoded metadata per page; reject atomically above either bound | Exact limit, one-over, repeated continuation, malformed record, and crash before/after page journal commit. |
+| Semantic apply | 100 records or 100 ms per ObjectBox transaction, then yield; no network/PCS work inside transaction | 8-year sanitized fixture: no duplicate GUID, no skipped contiguous checkpoint, UI remains responsive. |
+| Automatic work | One coordinator/account/scope; 15 s trigger debounce; full-jitter persisted backoff, honor `Retry-After` | 100 push/reconnect hints, process death, clock change, and metered/unmetered transitions produce one active lease and no retry storm. |
+| Android wake cost | No polling; normal background work for reconciliation; expedited work only immediately after a user-visible high-priority event | Doze, Battery Saver, locked screen, and 24-hour idle run: no exemption prompt, no persistent socket kept only for CloudKit. |
+| Media | 1 concurrent automatic transfer, 2 user-tapped transfers; 8 MiB RAM buffer ceiling per transfer; streamed hash/dedupe | Text-plus-media two-phase event, duplicate attachment, cancellation midstream, disk full, resume, and orphan-temp cleanup. |
+| Windows migration | x64 -> ARM64 -> x64 under the same Windows user; no plaintext secret file; crash points at every migration step | Original/temp/backup recovery permutations, second process lock contention, wrong user, and corrupted authenticated blob fail closed. |
+
+## Ship criteria
+
+Enable Phase 2 semantic pull only after all three platforms pass the same
+fixture/replay corpus, Android passes the Doze and locked-phone suite, and both
+Windows architectures pass profile migration plus installed-artifact smoke tests.
+Enable writes only after partial save/delete responses, zone loss, retry-after,
+PCS unavailable, account switch, and offline two-client convergence have no
+message loss, duplicate logical GUID, account bleed, or implicit destructive
+recovery. Profiles remain a separate opt-in because their transport/auth failure
+must never block Messages reconciliation.
+
+## Blocker-focused evidence update: 2026-08-01
+
+### FRB 2.3.0 bindings: one canonical generator, multiple compile consumers
+
+This tree pins `flutter_rust_bridge` 2.3.0 in both Rust and Dart, and both
+generated roots report codegen version 2.3.0. FRB performs a runtime codegen
+version sanity check, so a locally newer generator is not a harmless formatting
+change. The upstream 2.3.0 CI generated examples on Windows, macOS, and Ubuntu
+and failed when regeneration changed committed output. This is evidence that
+generation is intended to be deterministic across hosts, not evidence that an
+ARM64 Windows host must generate ARM64-specific Dart bindings. Source:
+[FRB 2.3.0 generation CI](https://github.com/fzyzcjy/flutter_rust_bridge/blob/v2.3.0/.github/workflows/ci.yaml#L170-L221).
+
+Make Ubuntu the only binding-authority job. Regenerate with the exact released
+generator, then fail on any committed-output drift:
+
+```bash
+cargo install flutter_rust_bridge_codegen --version 2.3.0 --locked
+flutter_rust_bridge_codegen --version
+flutter pub get
+flutter_rust_bridge_codegen generate
+git diff --exit-code -- \
+ lib/src/rust \
+ rust/src/frb_generated.rs \
+ rust/src/frb_generated.io.rs \
+ rust/src/frb_generated.web.rs
+```
+
+`flutter pub get` is deliberately separate because FRB's `generate` command
+does not perform the integration command's package setup. The Windows x64 and
+Windows ARM64 jobs should consume the committed output without regenerating it,
+then compile and run an ABI/load smoke test. A Windows-only generated diff is a
+generator bug to isolate, not output to commit conditionally. The drift check
+must include the namespace/API Dart files as well as the three top-level Rust
+files.
+
+### Vendored OpenSSL on `aarch64-pc-windows-msvc`
+
+The locked path is `openssl` 0.10.68, `openssl-sys` 0.9.104, and
+`openssl-src` `300.4.1+3.4.0`. That `openssl-src` release maps
+`aarch64-pc-windows-msvc` to OpenSSL Configure target `VC-WIN64-ARM`, then uses
+the MSVC and `nmake` branch. It does not use the Unix `clang` path for that
+target. Source:
+[openssl-src target selection](https://github.com/alexcrichton/openssl-src-rs/blob/300.4.1%2B3.4.0/src/lib.rs#L310-L313).
+
+Therefore an error where GNU `clang` receives flags such as `/O2`, `/Fd`, or
+other MSVC-style options is a contaminated or unsupported toolchain route. It
+is not fixed safely by suppressing the flags. A reported Rust ARM64 Windows
+failure was resolved by installing the Visual Studio ARM64 components plus
+Perl and making `nmake` available:
+[rust-openssl issue 2236](https://github.com/sfackler/rust-openssl/issues/2236).
+OpenSSL's `VC-WIN64-ARM` configuration was also reported as Windows-specific
+and unsuitable for Linux `clang-cl` cross compilation:
+[OpenSSL issue 12363](https://github.com/openssl/openssl/issues/12363).
+A later cross-configuration effort exists, but it is not present in this locked
+OpenSSL 3.4.0 source:
+[OpenSSL PR 28545](https://github.com/openssl/openssl/pull/28545).
+
+The preferred CI lane is a native Windows ARM64 runner initialized with the
+Visual Studio ARM64 build environment. Before Cargo, remove generic GNU
+compiler overrides from the current process and prove the required tools:
+
+```powershell
+'CC','CXX','CFLAGS','CXXFLAGS','AR','RANLIB','CROSS_COMPILE' |
+ ForEach-Object { Remove-Item "Env:$_" -ErrorAction SilentlyContinue }
+
+where.exe cl
+where.exe link
+where.exe lib
+where.exe nmake
+where.exe perl
+cargo build --locked --target aarch64-pc-windows-msvc
+```
+
+If vendoring remains unreliable, the supported escape hatch is a versioned,
+checksummed ARM64 OpenSSL artifact, not a Linux GNU-clang build. `openssl-sys`
+checks target-prefixed environment variables before generic ones, so the
+fallback can be scoped without changing x64:
+
+```powershell
+$env:AARCH64_PC_WINDOWS_MSVC_OPENSSL_NO_VENDOR = '1'
+$env:AARCH64_PC_WINDOWS_MSVC_OPENSSL_LIB_DIR = 'D:\deps\openssl-arm64\lib'
+$env:AARCH64_PC_WINDOWS_MSVC_OPENSSL_INCLUDE_DIR = 'D:\deps\openssl-arm64\include'
+$env:AARCH64_PC_WINDOWS_MSVC_OPENSSL_STATIC = '1'
+cargo build --locked --target aarch64-pc-windows-msvc
+```
+
+The job must inspect the `.lib` machine type as ARM64 and record the dependency
+hash before accepting this route. The prefix behavior and vendored opt-out are
+defined by the locked build script:
+[openssl-sys environment resolution](https://github.com/sfackler/rust-openssl/blob/openssl-v0.10.68/openssl-sys/build/main.rs#L41-L54).
+
+### CloudKit and PCS: safe two-account, read-only validation
+
+Apple's public automation boundary is important here. A CloudKit management
+token can manage schema but cannot access private or shared data. A user token
+can access private/shared data only after interactive authorization and is
+short-lived. `cktool` stores its credentials in the macOS Keychain and operates
+against a developer's own container. Sources:
+[Automating CloudKit Development](https://developer.apple.com/icloud/cloudkit/automating/)
+and [cktool](https://developer.apple.com/icloud/ck-tool/).
+
+OpenBubbles reads Apple's private `com.apple.messages.cloud` service container,
+not an OpenBubbles-owned development container. Consequently, CloudKit Console,
+schema reset, `cktool`, management tokens, and a development-environment clone
+are not safe or applicable ways to test this integration. Apple does recommend
+real-world tests on multiple devices with different iCloud accounts, and its
+sharing sample explicitly uses two devices logged into different accounts:
+[CloudKit testing guidance](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitQuickStart/TestingYourApp/TestingYourApp.html)
+and [CloudKit sharing sample](https://developer.apple.com/documentation/cloudkit/sharing-cloudkit-data-with-other-icloud-users).
+
+Use this read-only live protocol:
+
+1. Create two dedicated, consenting, non-personal Apple test accounts, each
+ containing only synthetic Messages text and media. Use one isolated device
+ or OS profile and one application data directory per account.
+2. Gate the build to fetch and raw-journal only:
+ `semanticApply=false`, `saves=false`, `deletes=false`, `profiles=false`, and
+ outbound notification/write paths disabled. Abort the run if instrumentation
+ observes any write-class request.
+3. Run cold fetch, continuation, retry, and replay for account A, then repeat
+ independently for B. Hash account identifiers in telemetry. Do not store
+ Apple credentials, user tokens, PCS material, or message contents in CI.
+4. Test switching only after the independent runs pass. Stop the old
+ coordinator, prove its lease is released, select the new account-scoped
+ store/checkpoint, and then start B. A row, token, media reference, or log
+ correlation crossing scopes is a hard failure.
+5. Never reset a trusted clique, delete a zone, modify records, or invoke
+ CloudKit developer tooling against the Messages service as a recovery step.
+
+PCS fault behavior should be fixture-driven first. At the current rustpush
+revision, `pcs_keys_for_record` can panic when both `protection_info` and
+`pcs_key` are absent, and returns `PCSRecordKeyMissing` when the requested key
+ID is absent from zone defaults:
+[rustpush PCS key path](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs).
+Add fixtures for both fields absent, unknown key ID, stale zone-key cache,
+corrupted wrapped key, and successful refresh. V2 must translate every case to
+a typed scoped result, retain the old checkpoint, and never panic or trigger a
+destructive clique action. The live two-account pass then validates only that
+these boundaries stay read-only under real authentication.
+
+### Android production telemetry: published redlines and local gates
+
+The exact public Android production thresholds are platform vitals, not a
+universal sync-specific mAh or CPU budget:
+
+| Signal | Published production threshold |
+| --- | --- |
+| Excessive partial wake lock | At least 2 cumulative background hours in a 24-hour session; the Android Vitals bad-behavior threshold is 5% of sessions over 28 days. |
+| Stuck partial wake lock | At least one background partial wake lock held for 1 hour in a 24-hour period. |
+| Excessive wakeups | Play Reporting classifies users with more than 10 wakeups per hour. |
+| User-perceived crash | 1.09% overall; 8% per-device bad-behavior threshold. |
+| User-perceived ANR | 0.47% overall; 8% per-device bad-behavior threshold. |
+| App launch target | Cold under 500 ms, warm under 200 ms, hot under 150 ms. |
+
+Sources:
+[excessive partial wake locks](https://developer.android.com/topic/performance/vitals/excessive-wakelock),
+[stuck partial wake locks](https://developer.android.com/topic/performance/vitals/stuck-wakelock),
+[Android Vitals core thresholds](https://developer.android.com/topic/performance/vitals),
+[Play excessive wakeup rate](https://developers.google.com/play/developer/reporting/reference/rest/v1alpha1/vitals.excessivewakeuprate),
+and [Android performance measurement](https://developer.android.com/topic/performance/measuring-performance).
+
+A current production sync implementation provides useful scheduling constants,
+but not a battery-consumption acceptance number. Firefox Android uses unique
+work with `ExistingWorkPolicy.KEEP`, requires a connected network, applies
+exponential backoff starting at 3 minutes, delays startup sync by 5 seconds to
+avoid database contention, and notes WorkManager's 15-minute minimum periodic
+interval:
+[Firefox WorkManagerSyncManager at commit fe8a71c](https://github.com/mozilla-mobile/firefox-android/blob/fe8a71cd70ad5674abe1824fe11dc78372b736c2/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt#L184-L266)
+and [its timing constants](https://github.com/mozilla-mobile/firefox-android/blob/fe8a71cd70ad5674abe1824fe11dc78372b736c2/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt#L546).
+
+Do not invent a cross-device `mAh/hour` limit. Battery hardware, radio state,
+message volume, and OEM scheduling make that number non-portable. Use paired
+sync-off/sync-on runs on the same reference devices and retain raw charge,
+worker-start, network-byte, CPU-time, wakeup, and wake-lock deltas.
+
+For rollout, adopt stricter OpenBubbles alarms, explicitly labeled as local
+policy rather than published Android limits:
+
+| Signal | Proposed OpenBubbles stop/alarm gate |
+| --- | --- |
+| Sync-attributed wakeups | No account exceeds 10/hour; any repeated periodic pattern while idle is a hard failure. |
+| Excessive partial wake-lock sessions | Alarm at 1%; stop staged rollout before the Play 5% redline. Cloud Sync V2 should acquire no manual partial wake lock. |
+| User-perceived ANR | Alarm at 0.2% overall or 4% for a device cohort; stop on a statistically credible regression versus control. |
+| User-perceived crash | Alarm at 0.5% overall or 4% for a device cohort; stop on a statistically credible regression versus control. |
+| Launch time | Stop if enabled-vs-disabled P95 regresses by more than 10% on the same device/build; separately drive toward Android's absolute launch targets. |
+| Battery energy | No universal absolute number. The enabled-run delta must remain inside the predeclared paired-test tolerance derived from control-run variance; publish the raw delta and interval. |
+
+These gates turn published external failure thresholds into earlier rollback
+signals while avoiding a fabricated battery precision that will not transfer
+between Pixel, Samsung, and emulator cohorts.
+
+## Notification, profiles, assets, and CI evidence update: 2026-08-01
+
+### Private Messages zone change notifications
+
+#### Verified facts
+
+Apple documents CloudKit subscriptions as per-user notification sources, not
+durable delivery logs. Changes in custom record zones can trigger push
+notifications, but notifications may be coalesced, may omit the originating
+device, and can be lost through APNs or network failure. Apple therefore
+requires clients to treat push as a hint and fetch changes using persistent,
+opaque server tokens:
+[CKDatabaseSubscription](https://developer.apple.com/documentation/CloudKit/CKDatabaseSubscription),
+[CKFetchRecordZoneChangesOperation](https://developer.apple.com/documentation/cloudkit/ckfetchrecordzonechangesoperation),
+and [Apple QA1917](https://developer.apple.com/library/archive/qa/qa1917/_index.html).
+Apple's private-database sample creates a `CKRecordZoneSubscription` with a
+content-available notification:
+[ViewModel.swift](https://github.com/apple/sample-cloudkit-privatedb-sync/blob/b30a0ccef9a2e22cc8d2dccf46819e0f9327ffcb/PrivateSync/ViewModel.swift).
+Apple's current CKSyncEngine sample also states that remote-notification testing
+requires a real device or Mac because simulators do not receive those pushes:
+[sample-cloudkit-sync-engine](https://github.com/apple/sample-cloudkit-sync-engine).
+
+Public rustpush master at commit
+[`70ec162`](https://github.com/OpenBubbles/rustpush/commit/70ec162c6838830194d55792c8b26e4d6681c816)
+already contains a generic `CloudKitNotifWatcher`. It parses APS CloudKit
+payloads, filters them by container, deduplicates zones, debounces for 10
+seconds, and returns changed record-zone identifiers:
+[cloudkit.rs lines 618-655](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs#L618-L655).
+The same client can request the container APS topic, create a database
+subscription, and register its token:
+[watch_notifs](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs#L1298-L1310)
+and [subscription/token registration](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs#L1632-L1665).
+The password-manager implementation proves that this generic path is wired for
+another container in rustpush today:
+[passwords.rs lines 1090-1175](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/passwords.rs#L1090-L1175).
+
+Messages uses the private database in container `com.apple.messages.cloud` with
+bundle identifier `com.apple.imagent`:
+[cloud_messages.rs lines 78-82](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L78-L82).
+However, the current `CloudMessagesClient` owns only a container client and
+keychain and does not create a subscription, register a token, or own a
+`CloudKitNotifWatcher`:
+[cloud_messages.rs lines 479-493](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L479-L493).
+The current IDS APS client requests only the Madrid and SMS topics, not the
+Messages CloudKit container topic:
+[aps_client.rs lines 116-130](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/aps_client.rs#L116-L130).
+
+#### Inference and production action
+
+Rustpush has a reusable CloudKit hint mechanism, but it does not currently
+expose a reliable Messages-zone hint source to OpenBubbles. IDS arrival can
+trigger an inexpensive reconciliation attempt, but it cannot prove that the
+private zone is current. It does not represent missed history, deletions,
+attachment materialization, or changes that occurred while the client was
+offline.
+
+Add a read-only experimental lane that requests the
+`com.apple.icloud-container.com.apple.imagent` APS topic, creates the Messages
+database subscription, registers its token, and maps returned zone identifiers
+to the V2 scheduler. Keep that lane behind an experiment flag because this is
+reverse-engineered use of an Apple private container. A push must only schedule
+a checkpoint fetch. It must never advance a change token itself. Startup,
+manual refresh, account reauthentication, and network recovery must still
+reconcile without a notification.
+
+### Shared profile, contact card, and avatar storage
+
+#### Verified facts
+
+Rustpush's current shared-profile implementation uses the public CloudKit
+database in container `com.apple.messages.profiles`, bundle
+`com.apple.imtransferagent`:
+[name_photo_sharing.rs lines 253-282](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/name_photo_sharing.rs#L253-L282).
+Its primary record type is `imsgNicknamePublicv2`, with name field `n`, avatar
+metadata `am`, and avatar asset `ad`. The optional companion `poster` record
+contains poster metadata `pr` and `wm` plus `lrwd` and `wd` assets:
+[record definitions](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/name_photo_sharing.rs#L25-L80).
+Inbound resolution fetches an exact pointer record and optional `-wp`
+companion, downloads their assets, and decrypts them using the key delivered in
+the IDS `ShareProfileMessage`:
+[get_record](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/name_photo_sharing.rs#L282-L335).
+
+The public OpenBubbles default branch at commit
+[`eed1b63`](https://github.com/OpenBubbles/openbubbles-app/commit/eed1b6332efbb17adbf5ebfa2263ad770169f75e)
+attaches `ShareProfileMessage` to a one-to-one IDS message, then projects the
+resolved name, raw avatar bytes, shared state, and optional poster path into an
+ObjectBox `Contact`:
+[rustpush_service.dart lines 2819-2906](https://github.com/OpenBubbles/openbubbles-app/blob/eed1b6332efbb17adbf5ebfa2263ad770169f75e/lib/services/rustpush/rustpush_service.dart#L2819-L2906)
+and [contact.dart lines 15-42](https://github.com/OpenBubbles/openbubbles-app/blob/eed1b6332efbb17adbf5ebfa2263ad770169f75e/lib/database/io/contact.dart#L15-L42).
+Its `savePoster()` and `saveTranscriptPoster()` paths call asynchronous
+`savePosterData(...)` without awaiting completion, so a returned path or
+preview can race file persistence:
+[rustpush_service.dart lines 2957-2988](https://github.com/OpenBubbles/openbubbles-app/blob/eed1b6332efbb17adbf5ebfa2263ad770169f75e/lib/services/rustpush/rustpush_service.dart#L2957-L2988).
+Open pull request
+[#227](https://github.com/OpenBubbles/openbubbles-app/pull/227) explicitly
+isolates malformed CloudKit profile failures from message delivery and adds a
+bounded retry, but it is not merged into the default branch. Open issue
+[#103](https://github.com/OpenBubbles/openbubbles-app/issues/103) records a
+Linux chat-avatar flow stuck at “Saving avatar.” That issue concerns chat/group
+avatar handling, not the shared-profile record schema, but it is still a
+relevant avatar-persistence regression.
+
+The rustpush write path has additional concrete hazards. It deletes the prior
+record before replacement is ready, requests poster asset uploads using the
+nickname record type, unwraps missing `lrwd` and `wd` upload responses, and may
+delete the currently queried record before retrying a failed save:
+[set_record lines 336-425](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/name_photo_sharing.rs#L336-L425).
+Its own-record query returns the first public-zone result without an explicit
+ordering or duplicate-reconciliation rule:
+[get_my_record lines 426-461](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/name_photo_sharing.rs#L426-L461).
+The mismatched upload record type is suspicious, but server-side failure from
+that mismatch is not proven by the available source.
+
+Reverse-engineered iOS 26.1 framework source independently corroborates the
+schema. It queries `imsgNicknamePublicv2` by creator, reads `ad`, `wd`, `lrwd`,
+and `wm`, and constructs both nickname and `poster` records:
+[IMTransferAgent.mm](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMTransferAgent.framework/IMTransferAgent/IMTransferAgent.mm#L3087-L3089).
+The decompiled nickname controller identifies container
+`com.apple.messages.profiles`:
+[IMTransferAgentNicknameController.mm](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMTransferAgent.framework/IMTransferAgent/IMTransferAgentNicknameController.mm#L635).
+Private SDK symbols also expose CloudKit record-key and decryption-key message
+dictionary entries:
+[IMDaemonCore.tbd](https://github.com/xybp888/iOS-SDKs/blob/1b92ff4a8928f582876e1d388d1381c6a0c59eb9/iPhoneOS26.1.sdk/System/Library/PrivateFrameworks/IMDaemonCore.framework/IMDaemonCore.tbd).
+These are useful reverse-engineered schema witnesses, not supported Apple API
+contracts.
+
+#### Inference and production action
+
+The shared-profile path is pointer-driven public-record retrieval, not a
+private-zone stream like Messages. Build a durable profile-pointer inbox from
+decoded IDS metadata rather than inventing a public-database checkpoint poll.
+Key each work item by account scope, sender, CloudKit record key, and
+decryption-key fingerprint. Retain the received pointer, journal the raw
+profile and asset metadata, fetch the exact record and optional poster, verify
+the selected assets, then transactionally update the contact projection.
+
+Keep device Contacts sync and own-profile publishing outside this stream.
+Replace `unwrap` and unbounded in-memory asset failure paths with typed,
+per-asset errors and bounded retry. Do not expose a poster path before its
+atomic file write completes. Preserve pointer history and deduplicate it
+because one sender may share the same profile with multiple handles. Treat
+own-profile writes as a separate, explicit feature until replacement can be
+made without delete-first data loss.
+
+### Attachment retrieval, resume, and integrity
+
+#### Verified facts
+
+The Messages `CloudAttachment` field named `md5` contains only the first eight
+bytes of MD5:
+[cloud_messages.rs line 466](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L466).
+The available CloudKit asset record contains stronger transport metadata:
+asset signature, reference signature, expected size, download token and URLs,
+expiration, protection information, and bundled request identifier:
+[cloudkit.proto lines 1009-1027](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/cloudkit-proto/src/cloudkit.proto#L1009-L1027).
+The generic proto also defines `sha256Signature`, but the current Messages
+asset path does not populate or consume that field. It is therefore not a
+proven expected digest for Messages attachments.
+
+`download_attachment` accepts whole-record identifiers and caller-provided
+write sinks, requests all assets, then delegates to `get_assets`:
+[cloud_messages.rs lines 694-706](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L694-L706).
+`get_assets` uses the full asset signature and protection information and
+streams through MMCS:
+[cloudkit.rs lines 2072-2100](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs#L2072-L2100).
+MMCS records a full file checksum plus per-chunk checksums, sizes, and offsets.
+It splits files into 5 MiB chunks and cryptographically checks V2 chunk IDs
+during decryption:
+[mmcs.rs](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/mmcs.rs).
+Those verification branches currently use assertions, so corrupted input can
+panic instead of returning a typed integrity failure.
+
+The public API exposes no durable offset or resume token. The current MMCS
+container opens an ordinary GET from the beginning and contains no Range,
+If-Range, or Content-Range request handling:
+[ensure_stream lines 927-980](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/mmcs.rs#L927-L980).
+Its offset bookkeeping consumes bytes within the active stream; it is not
+process-death resume. Apple's generic resumable-download guidance requires a
+saved ETag plus `Range` and `If-Range`, followed by validation of a `206`
+response and exact `Content-Range`:
+[Apple QA1761](https://developer.apple.com/library/archive/qa/qa1761/_index.html).
+That guidance defines a safe experiment, but it does not prove that MMCS
+endpoints support ranges.
+
+Open issue [#207](https://github.com/OpenBubbles/openbubbles-app/issues/207)
+reports that the text-plus-attachment two-phase path persists replacement
+metadata but does not start automatic download, while attachment-only messages
+do. This is a current queue-orchestration failure rather than evidence of an
+MMCS integrity defect.
+
+#### Inference and production action
+
+Persist the CloudKit asset signature, reference signature, expected size, MMCS
+file checksum, and chunk manifest before transfer. Compute an
+application-owned SHA-256 over final plaintext and use it as the content
+address. The truncated MD5 may remain a compatibility field but must not be
+the production integrity decision.
+
+Implement the first safe resume boundary at verified MMCS chunks. Persist only
+completed chunk identifiers, digests, sizes, and offsets, reauthorize after
+restart, and assemble only verified chunks. Never blindly append to a partial
+plaintext file. Convert all integrity assertions and missing-field unwraps to
+typed `integrityMismatch`, `manifestChanged`, or `authorizationExpired`
+failures before enabling production sync.
+
+Treat byte-range resume as a later capability probe. Require `Accept-Ranges`
+and ETag, send `If-Range`, accept only `206` with an exact `Content-Range`, and
+discard the partial object on any mismatch or full `200` response. The durable
+attachment queue must also model the issue #207 replacement event so metadata
+arrival wakes the same download state machine as attachment-only delivery.
+
+### Flutter Rust Bridge generation and native Windows CI
+
+#### Verified facts
+
+A current Flutter Rust Bridge project pins its generator, runs generation once
+on Linux, fails on generated-file drift, and builds with
+`--skip-frb-codegen` afterward:
+[Xybrid build-flutter.yml](https://github.com/xybrid-ai/xybrid/blob/6f664540b17b2ff5c1cc13dd59a28e82ef475959/.github/workflows/build-flutter.yml#L123-L160).
+Another current project checks both `git status --porcelain` and
+`git diff --exit-code` after exact-version generation:
+[NTS CI workflow](https://github.com/nick-llewellyn/nts/blob/236e78f803a8a7ce54a2136f911774a200256db6/.github/workflows/ci.yml)
+and [development contract](https://github.com/nick-llewellyn/nts/blob/236e78f803a8a7ce54a2136f911774a200256db6/DEVELOPMENT.md).
+Both checks are necessary because `git diff` alone misses newly generated,
+untracked files.
+
+GitHub now provides the standard `windows-11-arm` hosted runner to public and
+private repositories, alongside x64 Windows labels:
+[GitHub-hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
+and [private-repository availability announcement](https://github.blog/changelog/2026-01-29-arm64-standard-runners-are-now-available-in-private-repositories/).
+Current Flutter Rust Bridge documentation identifies
+`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` as the Windows targets.
+A native Windows ARM64 build pattern from XNNPACK confirms that
+`windows-11-arm` can use the native ARM64 MSVC compiler without a cross
+toolchain:
+[build-windows-arm64-native.cmd](https://github.com/google/XNNPACK/blob/c0e881d1947dc72787db45c380abed2a0e4e68c3/scripts/build-windows-arm64-native.cmd).
+
+#### Inference and production action
+
+Use one required `bindings` job on `ubuntu-24.04`. Install the exact project
+Flutter Rust Bridge version, currently 2.3.0, with `--locked`; generate once;
+then fail if either tracked generated content changed or a new generated file
+appeared. Scope both checks to the configured Dart and Rust output paths.
+Include `flutter_rust_bridge.yaml`, Cargo and pub manifests and locks, Rust API
+sources, mirrored types, and generated outputs in the workflow path filter.
+
+Make two native Windows jobs depend on `bindings`:
+
+| Architecture | Runner | Rust target |
+| --- | --- | --- |
+| x64 | `windows-2022` | `x86_64-pc-windows-msvc` |
+| ARM64 | `windows-11-arm` | `aarch64-pc-windows-msvc` |
+
+Neither Windows job may install or invoke the generator. Each should assert
+the runner architecture with
+`[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture`, record
+`rustc -vV`, run locked native compile and test steps, perform a PE
+architecture/load smoke test, and verify that generated paths remain clean
+after the build. If an ARM64 dependency is not yet portable, isolate only that
+specific compile step as experimental and retain its logs. Do not mark the
+whole architecture job successful while skipping native compilation.
+
+## Decoder contract evidence update: 2026-08-01
+
+### Fact: Messages stream identity is zone-scoped and type-specific
+
+The current public rustpush Messages client reads `chatManateeZone`,
+`messageManateeZone`, and `attachmentManateeZone`. Its encrypted record types
+are respectively `chatEncryptedv2`, `MessageEncryptedV3`, and `attachment`.
+The attachment record contains encrypted `cm` metadata plus the `lqa` asset;
+message records include both encrypted fields and a small set of explicitly
+unencrypted fields. These are reverse-engineered implementation schemas, not
+an Apple public contract.
+
+Source: [rustpush Messages schemas and zones at 70ec162](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L231-L314),
+[attachment schema](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L444-L475),
+and [zone methods](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L660-L730).
+
+**Action:** Retain zone plus expected record type in every protected V2 raw
+envelope. The semantic decoder may only project an upsert when the stream,
+record type, record identifier, and encrypted record agree. Do not infer an
+attachment's final content identity from its short `md5` metadata or from
+unencrypted message fields.
+
+### Fact: `change_type` cannot be the sole semantic discriminator
+
+The V2 transport currently preserves `change_type` as `Option`. Its page
+mapper treats `Some(1)` with a record as an upsert and `Some(2)` without one as
+a tombstone, but deliberately permits `None` when the record shape is otherwise
+well formed. The new `cloud_sync_semantic_decoder.rs`, in contrast, currently
+requires raw value `1` for an upsert and `2` for a tombstone. Therefore a
+transport-valid change with absent raw type would be rejected by the semantic
+layer even though the transport had enough information to classify it.
+
+Source: current V2 transport mapping in
+[`cloud_messages.rs`](../rustpush/src/imessage/cloud_messages.rs) lines
+`800-882`, and the decoder's raw-type checks in
+[`cloud_sync_semantic_decoder.rs`](../rust/src/cloud_sync_semantic_decoder.rs)
+lines `172-204` and `215-219`. Public rustpush also represents synced records
+as `Option` values, where absence is the deletion signal, in
+[the generic Messages sync contract](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L660-L688).
+
+**Action:** Change the semantic boundary to accept the transport's validated
+`CloudMessageRecordKind` (or a similarly closed `Upsert | Tombstone` enum),
+not a guessed raw integer. Preserve the optional raw type for redacted
+diagnostics. Add fixtures for `None` with a valid upsert, `None` with a valid
+tombstone, and contradictory record/type shapes. No checkpoint may advance
+until each is projected or durably quarantined.
+
+### Fact: PCS failures are wider than malformed record data
+
+The public Messages sync loop specifically treats `PCSRecordKeyMissing` as a
+key-material problem, clears the cached zone encryption configuration, and
+returns the error. Rustpush also exposes distinct `NotInClique`,
+`ShareKeyNotFound`, `MasterKeyNotFound`, and `NoRoutingKey` errors. Its older
+generic decode paths still contain `expect` calls around PCS lookup, so a native
+failure must not be reclassified as malformed payload merely because it reaches
+the V2 boundary.
+
+Source: [Messages PCS retry path](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L994-L1007),
+[PCS lookup](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs#L548-L568),
+[error definitions](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/error.rs#L200-L220),
+and [generic panic-prone decode path](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/icloud/cloudkit.rs#L220-L239).
+
+**Action:** Preserve a three-way V2 outcome: `blockedPCS` for known
+key/clique/routing states, `retryableNativeFailure` for transport or unexpected
+native errors, and `malformedProtectedRecord` only after deterministic envelope
+or schema validation fails. All three retain the protected raw envelope and
+keep the existing checkpoint. The decoder must never convert an unknown
+`PushError` into a discardable malformed record.
+
+### Fact: current upstream reports match the V2 loss and latency risks
+
+Open issue [#222](https://github.com/OpenBubbles/openbubbles-app/issues/222)
+reports a `Bad message` failure after reinstall, and open issue
+[#212](https://github.com/OpenBubbles/openbubbles-app/issues/212) reports
+partial history and empty conversations. Open issue
+[#141](https://github.com/OpenBubbles/openbubbles-app/issues/141) reports
+historical videos repeatedly failing while newly received videos work. These
+reports do not prove a single root cause, but they rule out treating raw decode,
+checkpoint, and attachment resumption as independent best-effort features.
+
+Source: [#222](https://github.com/OpenBubbles/openbubbles-app/issues/222),
+[#212](https://github.com/OpenBubbles/openbubbles-app/issues/212), and
+[#141](https://github.com/OpenBubbles/openbubbles-app/issues/141).
+
+**Action:** Production readiness requires fault-injection tests that interrupt
+after raw page persistence, during PCS refresh, during semantic projection, and
+during attachment transfer. Verify restart convergence against the same source
+history, with no page-token jump, duplicate logical record, or permanently
+stuck attachment job.
+
+### Fact: a Windows ARM64 sleep/wake report shows native wall-clock panic can
+wedge CloudKit retries
+
+Open rustpush issue [#29](https://github.com/OpenBubbles/rustpush/issues/29)
+documents a Windows ARM64 Modern Standby wake where a backward NTP correction
+triggers `SystemTime::duration_since(...).expect(...)` in identity-cache
+staleness logic, followed by repeated CloudKit sync failures, a frozen UI, and
+high CPU. This is an upstream report, not yet a reproduced V2 defect.
+
+Source: [rustpush issue #29](https://github.com/OpenBubbles/rustpush/issues/29)
+and the implicated [identity-cache implementation](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/ids/identity_manager.rs#L45-L65).
+
+**Action:** Add an ARM64 and x64 sleep/wake and backward-clock test gate around
+Cloud Sync V2's cancellation/retry supervisor. Native panics must become a
+single bounded failed run with diagnostics and backoff, never a tight restart
+loop. Keep persisted server timestamps separate from eligibility deadlines and
+continue using monotonic time for in-process lease and timeout decisions.
+
+## Canonical local-projection evidence update: 2026-08-01
+
+### Fact: the legacy CloudKit conversion path already carries most message
+semantics, but it mutates while converting
+
+`Message.applyFromCloud` decodes `MessageProto`, maps body and attachments,
+reconstructs `MessageSummaryInfo` edit history (`ec`, `ep`, `otr`) and
+retracted parts (`rp`), reads delivery/read timestamps, maps reaction ranges,
+and reads threaded-reply and emoji metadata. It then calls `save(chat: chat)`
+directly. Its matching upload method encodes the same edit/retraction/receipt
+fields, so this is the closest existing field-level mapping witness.
+
+Source: current legacy mapper in
+[`message.dart`](../lib/database/io/message.dart) lines `1021-1220`, including
+the [upload mapping](../lib/database/io/message.dart#L1021-L1106) and
+[download mapping](../lib/database/io/message.dart#L1107-L1220). The public
+Messages schema supplies `msgProto`, optional `msgProto2/3/4`, flags, GUID, and
+chat ID: [rustpush schema at 70ec162](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L314-L368).
+
+**Action:** Reuse the existing protobuf and attributed-body parsers as field
+decoders, but not `Message.applyFromCloud` as an unguarded V2 callback. The
+semantic adapter must first resolve its record-map idempotency key, then apply
+the complete message, its edit/retraction history, and its associated-message
+links in one bounded ObjectBox transaction. A completed journal entry must make
+a second pass a no-op rather than a second `save`.
+
+### Fact: legacy chat and attachment helpers have useful canonical identities,
+but are not transactional primitives
+
+`Chat.findFromCloud` resolves a cloud chat in priority order by `groupId`,
+`chatIdentifier`, then exact participant set, and otherwise creates a chat.
+`Chat.applyFromCloud` updates group version from `properties.pv`, last-read
+GUID, participants, display name, cloud payload, and group photo state, then
+writes to ObjectBox. `Attachment.applyFromCloud` maps a CloudKit attachment
+record into its local metadata and normalizes an Apple attachment GUID of the
+form `at__` into local `_`, then writes
+and optionally links it to the message.
+
+Source: [chat lookup and creation](../lib/database/io/chat.dart#L1047-L1088),
+[chat projection and persistence](../lib/database/io/chat.dart#L1167-L1208),
+and [attachment GUID conversion plus persistence](../lib/database/io/attachment.dart#L57-L77).
+The corresponding encrypted CloudKit records expose chat `group_id`,
+participants, `properties`, optional `group_photo`, and attachment `cm` plus
+`lqa`: [rustpush schemas](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L231-L297)
+and [attachment schema](https://github.com/OpenBubbles/rustpush/blob/70ec162c6838830194d55792c8b26e4d6681c816/src/imessage/cloud_messages.rs#L444-L475).
+
+**Action:** Treat the legacy lookup order and attachment GUID transform as
+compatibility rules to test, not as a ready-made V2 transaction. Split V2 into
+pure `resolveChat`, `projectChat`, and `projectAttachment` stages, then persist
+their record-map updates and local entities atomically. Do not let a record-map
+collision call legacy `findFromCloud`, `applyFromCloud`, or `backend.createChat`
+outside that transaction.
+
+### Fact: associated-message GUIDs need an explicit normalization contract
+
+The legacy upload path serializes a reaction target as
+`p:/`. The download path assigns the received
+`associatedMessageGuid` directly, while the local reaction lookup later queries
+by exact local `Message.guid`. The source therefore does not establish that
+CloudKit reaction targets arrive already normalized to the local GUID format.
+
+Source: [reaction target serialization](../lib/database/io/message.dart#L1055-L1062),
+[direct receive assignment](../lib/database/io/message.dart#L1180-L1191), and
+[associated-message lookup](../lib/database/io/message.dart#L1222-L1235).
+
+**Action:** Define one tested V2 parser for `p:/` that yields the
+logical parent GUID and part separately, while preserving the original raw value
+in the protected envelope. Include fixtures for reaction add/remove, sticker,
+thread reply, edit before parent arrival, unsend before parent arrival, and
+parent record deletion. Deferred associations must be durable and must resolve
+only when the parent record map is present.
+
+### Fact: the legacy loop mixes inbound recovery with outbound deletion and
+main-isolate writes
+
+The current legacy sync loops immediately delete local rows for tombstones,
+deduplicate record-ID conflicts by adding prior IDs to outbound deletion lists,
+persist continuation tokens page by page, and call the mutation helpers while
+decoding. It also performs the message loop on the Flutter main isolate and
+yields only after every 25 records. Those choices explain why the helpers cannot
+be reused as a crash-safe reconciliation engine without a transaction boundary.
+
+Source: [legacy chat, attachment, and message loops](../lib/services/rustpush/rustpush_service.dart#L3157-L3445),
+[local delete behavior](../lib/database/io/message.dart#L1253-L1271), and open
+[large-history issue #194](https://github.com/OpenBubbles/openbubbles-app/issues/194)
+plus [restart/hang issue #168](https://github.com/OpenBubbles/openbubbles-app/issues/168).
+
+**Action:** V2 semantic apply must never enqueue an outbound CloudKit delete
+while processing a remote upsert or tombstone. Retain tombstones and conflicting
+record mappings for review, make the token/journal/entity update atomic, and
+execute bounded batches outside the presentation-critical path. Require a
+large-history restart test that injects interruption before and after each
+commit, then verifies no duplicate entity, no outbound delete, and no
+re-download of an already committed page.
+
+### Fact: historical media and ARM64 resume failures are active upstream risk
+signals
+
+Open issue [#141](https://github.com/OpenBubbles/openbubbles-app/issues/141)
+reports historical iCloud videos repeatedly failing to download even though
+newly received video works. Open rustpush issue
+[#29](https://github.com/OpenBubbles/rustpush/issues/29) remains open and
+documents a Windows ARM64 sleep/wake backward-clock panic that can wedge
+CloudKit retry behavior. Neither report proves V2 behavior, but both require
+platform-specific interruption coverage before a production claim.
+
+Source: [OpenBubbles #141](https://github.com/OpenBubbles/openbubbles-app/issues/141)
+and [rustpush #29](https://github.com/OpenBubbles/rustpush/issues/29).
+
+**Action:** Add a test matrix with a historical-video fixture and a simulated
+ARM64 sleep/wake cancellation. The attachment state machine must leave a
+durable retryable state with its verified metadata after interruption; the
+supervisor must suppress tight retries after a native panic and require an
+explicit safe re-entry trigger.
+
+## Attachment resume and cross-box transaction evidence update: 2026-08-01
+
+### Fact: the current MMCS read path verifies V2 chunks, but has no persisted
+resume contract
+
+The MMCS preparation path splits data into `5,242,880` byte chunks and carries
+their IDs, sizes, and encryption metadata. During a V2 read, the decoder
+decrypts a chunk, hashes its plaintext, and checks the derived HMAC against the
+chunk ID. However, that integrity failure is currently an `assert_eq!`, not a
+typed recoverable error. The get container opens an ordinary fresh HTTP stream,
+tracks progress only in its in-memory `transfer_progress`, and does not expose a
+range, ETag, `If-Range`, checkpoint, or cancellation argument. The generic
+`FileContainer` writes each decoded chunk straight into its supplied writer.
+
+Source: [5 MiB preparation and chunk descriptors](../rustpush/src/icloud/mmcs.rs#L185-L310),
+[V2 decrypt and assertion](../rustpush/src/icloud/mmcs.rs#L933-L972),
+[fresh stream and volatile progress](../rustpush/src/icloud/mmcs.rs#L1207-L1325),
+and [get pipeline plus direct writer target](../rustpush/src/icloud/mmcs.rs#L1366-L1557).
+The same durability pattern is used by mature resumable downloaders: Rustup
+writes and verifies a `.partial` file before replacing the final file, while
+Helmor persists a `.part`, verifies SHA-256, and only then renames it
+([Rustup implementation](https://github.com/rust-lang/rustup/blob/a1676f4adf942c22c4a5ae58a8e30b8bb81a2029/src/dist/download.rs),
+[Helmor worker](https://github.com/dohooo/helmor/blob/main/src-tauri/src/downloads/worker.rs)).
+
+**Action:** Model MMCS authorization as an expiring transfer epoch, not as a
+resumable HTTP byte stream. Persist, before writing, an immutable job identity
+containing the CloudKit record/asset identity, expected logical size, ordered
+chunk IDs and sizes, the authorization epoch, and a versioned local-stage
+format. Persist only a verified contiguous chunk prefix, then on restart
+re-authorize and compare the returned manifest before reuse. If the manifest or
+record identity differs, abandon the stage as stale rather than appending.
+Replace each V2 assertion with a typed integrity failure that preserves the
+stage for diagnosis but never marks it complete. Do not promise byte-range
+resume until MMCS authorization responses and `transfer_mmcs_container` prove
+that range requests are supported.
+
+### Fact: Cloud attachment downloads currently stream directly into their
+final visible path
+
+For cloud attachments, `RustPushBackend.downloadAttachment` gives the Rust
+bridge `(attachment.path, cloudRecordID)`, so the MMCS writer owns the final
+attachment path during the transfer. `FileContainer.write` calls `write_all`
+without a staging or durable-sync step. The non-cloud `Attachment.writeToDisk`
+helper also creates and writes its final path directly.
+
+Source: [cloud download call site](../lib/services/rustpush/rustpush_service.dart#L554-L565),
+[MMCS file writer](../rustpush/src/icloud/mmcs.rs#L518-L565), and
+[direct local write helper](../lib/database/io/attachment.dart#L161-L165).
+The Dart `File.rename` API documents that a rename cannot cross file systems and
+may replace an existing destination, so a safe implementation must make the
+destination and staging-path rules explicit rather than treating rename as a
+general recovery mechanism ([Dart File API](https://api.dart.dev/stable/dart-io/File/rename.html)).
+
+**Action:** Never send a final attachment path to a new V2 transfer. Create a
+unique same-directory `.cloudsync..partial` file and stream only
+there. The state machine is `metadataReady -> transferPending -> streaming ->
+verified -> filePlaced -> referenced`; file creation, writes, hashing, flush,
+and rename stay outside ObjectBox transactions. After every verified contiguous
+chunk, write a compact atomic sidecar/checkpoint; after the final size and
+digest check, atomically place the file on the same volume, then perform the
+short database commit that exposes the attachment. Startup reconciliation must
+compare the final file, partial file, sidecar, and job state, and must never
+blindly delete a partial merely because the process previously stopped. Guard
+against an existing final destination before rename, because replacement would
+otherwise turn a duplicate retry into data loss.
+
+### Fact: cancellation is not presently a first-class MMCS download control
+
+`get_mmcs` accepts the configuration, authorization response, output writers,
+progress callback, and Ford flag. Its public signature contains no cancellation
+token, and the inner reader awaits response chunks until EOF or an error. The
+current native call surface can therefore report progress but cannot describe a
+safe pause point or distinguish an intentional stop from a transport failure.
+
+Source: [`get_mmcs` signature and transfer loop](../rustpush/src/icloud/mmcs.rs#L1366-L1377)
+and [response-chunk read loop](../rustpush/src/icloud/mmcs.rs#L1300-L1315).
+Open-source transfer engines that support recovery make this control explicit:
+they observe cancel between chunks, preserve partial state, and resume only
+after a protocol-supported checkpoint ([Helmor worker](https://github.com/dohooo/helmor/blob/main/src-tauri/src/downloads/worker.rs),
+[rusty-cat restart design](https://github.com/0barman/rusty-cat)).
+
+**Action:** Add a V2 native cancellation handle checked before each next chunk
+read, before each stage write, and before final placement. Cancellation must
+flush and retain the partial/checkpoint as `paused`, without publishing a final
+attachment or advancing the semantic replay record. Treat network errors as
+`retryable` only when the last durable checkpoint still validates; integrity,
+manifest, or final-placement conflicts need distinct terminal or operator-visible
+states. Keep transfer concurrency deliberately bounded, especially on mobile,
+until actual battery, memory, and historical-video measurements justify a higher
+limit.
+
+### Fact: ObjectBox can compose multiple boxes in one Store transaction, but
+the callback itself must be synchronous
+
+OpenBubbles already wraps `Store.runInTransaction` as
+`Database.runInTransaction`. Current ObjectBox Dart source rejects an async
+transaction callback, because it would leave the transaction boundary while the
+Future is outstanding. `runInTransactionAsync` instead opens an independent
+Store connection in a worker isolate and still executes the transaction body
+synchronously there. `Box.putMany` automatically wraps its own work in a
+transaction for that box, but it does not define an atomic boundary with writes
+to other boxes.
+
+Source: [OpenBubbles database wrapper](../lib/database/database.dart#L240-L243),
+[ObjectBox Store transaction implementation](https://github.com/objectbox/objectbox-dart/blob/main/objectbox/lib/src/native/store.dart),
+and [ObjectBox Box batch implementation](https://github.com/objectbox/objectbox-dart/blob/main/objectbox/lib/src/native/box.dart).
+
+**Action:** Every V2 state transition that changes a `Message`, `Chat`, or
+`Attachment` together with a Cloud Sync record map, replay journal, inbox row,
+or transfer-state row must execute in one short,
+`Database.runInTransaction(TxMode.write, ...)` callback. Resolve network,
+PCS/MMCS work, parsing, file I/O, hashing, and UI notifications before or after
+that callback, never with `await` inside it. Where projection work is genuinely
+expensive, use `runInTransactionAsync` with primitive immutable payloads, but
+retain the same all-box transaction and idempotency key. A per-box `putMany`
+call is not sufficient for exactly-once Cloud Sync replay.
+
+## Semantic reconciliation and rebootstrap evidence update: 2026-08-01
+
+### Fact: the three CloudKit streams have distinct canonical identities and
+relationships
+
+`chatEncryptedv2` carries a chat GUID, `chat_identifier`, `group_id`, service,
+participants, optional properties, group-photo fields, and a display name.
+`MessageEncryptedV3` carries message GUID, `chatID`, sender, timestamp,
+service, flags, four protobuf envelopes, and unencrypted message type/error
+fields. The `attachment` record carries encrypted attachment metadata (`cm`)
+and an asset (`lqa`); the metadata includes the attachment GUID, transfer state,
+filename/name, type, byte count, outgoing flag, dates, and optional MMCS or
+inline transfer metadata. The actual message-to-attachment relation is therefore
+not an ObjectBox foreign key supplied by CloudKit: it is reconstructed from the
+message body and attachment GUID normalization.
+
+Source: [chat schema](../rustpush/src/imessage/cloud_messages.rs#L231-L297),
+[message schema](../rustpush/src/imessage/cloud_messages.rs#L314-L368),
+and [attachment schema and metadata](../rustpush/src/imessage/cloud_messages.rs#L444-L475).
+The existing local projection performs attachment-guid normalization before
+persistence: [attachment mapping](../lib/database/io/attachment.dart#L57-L77).
+
+**Action:** Define V2 keys explicitly: `serverRecordId` is the immutable
+CloudKit-envelope key, `chatGuid`, `messageGuid`, and normalized
+`attachmentGuid` are logical entity keys, and a link is valid only after both
+logical entities exist. Retain a protected raw envelope and its schema version
+for each mapping. Apply chat, message, and attachment records independently,
+then resolve message attachment references from the canonical decoded message
+body in the same idempotent local-apply transaction. Never infer a link from a
+file path, filename, record arrival order, or a best-effort current chat.
+
+### Fact: `p:` identifies an associated-message target, while `bp` is a
+separate balloon-payload field
+
+The CloudKit upload mapper serializes an associated-message target as
+`p:/`. Its download counterpart presently assigns the whole raw
+value into `associatedMessageGuid`, while later local queries compare that field
+to a bare local `Message.guid`. This is a real normalization mismatch. By
+contrast, `bp` in the native incoming iMessage schema is `balloon_part` data,
+with `bpdi` as its MMCS descriptor; it is fed into the extension/balloon parser,
+not used as a reaction-parent identity.
+
+Source: [CloudKit reaction serialization](../lib/database/io/message.dart#L1055-L1062),
+[current direct assignment](../lib/database/io/message.dart#L1180-L1191),
+[local parent lookup](../lib/database/io/message.dart#L1222-L1235), and
+[raw `bp`/`bpdi` fields](../rustpush/src/imessage/rawmessages.rs#L388-L447) with
+[balloon parsing](../rustpush/src/imessage/messages.rs#L3613-L3645).
+
+**Action:** Parse `p:/` into a typed parent reference
+`{ parentGuid, parentPart, rawValue }` before any ObjectBox write, and persist a
+durable unresolved-association row when the parent is not yet present. Parse
+thread replies (`r::`) under a separate typed contract. Treat `bp`
+and `bpdi` exclusively as extension payload material, not as aliases for a
+reaction parent. The replay suite must cover parent-before-child,
+child-before-parent, several reactions to one parent, reaction removal, sticker,
+thread reply, edit, and unsend, all with duplicate delivery and restart between
+the child apply and parent resolution.
+
+### Fact: raw-page transport retains the information needed for replay, but
+the legacy sync loop loses that safety boundary
+
+The page API emits an ordered list rather than a map. Each entry includes the
+opaque record name/type, optional system fields, the encoded encrypted record
+for an upsert, or the original encoded change envelope for a tombstone. It
+classifies malformed metadata and unsupported record types without decoding
+them. The legacy loop instead converts received records to `HashMap`s, directly
+deletes a locally mapped entity on a null value, and asks CloudKit to delete
+prior record IDs when it sees a logical duplicate. The page fetcher itself uses
+`newest_first: false`, while the older generic path requests `newest_first:
+true`; neither order establishes a parent-before-child guarantee across streams.
+
+Source: [replay-capable page shape and classification](../rustpush/src/imessage/cloud_messages.rs#L738-L883),
+[page fetch path](../rustpush/src/imessage/cloud_messages.rs#L922-L946),
+[legacy direct delete and duplicate-delete behavior](../lib/services/rustpush/rustpush_service.dart#L3176-L3445),
+and [the two fetch-order settings](../rustpush/src/icloud/cloudkit.rs#L1312-L1322)
+plus [legacy request](../rustpush/src/imessage/cloud_messages.rs#L963-L972).
+
+**Action:** V2 must consume the ordered page envelope one event at a time and
+first insert it into a durable inbox keyed by `(account, zone, serverRecordId,
+serverVersion-or-envelopeDigest)`. In a single local transaction, apply an
+eligible envelope, update record map/tombstone/deferred-parent rows, mark the
+inbox row applied, and advance only that page's candidate token. A server
+tombstone may hide a local item only after its record map resolves it; a missing
+map is a durable `tombstoneMappingMissing` review/rebootstrap condition, not a
+guess or deletion. A logical duplicate must be diagnosed as a mapping conflict,
+not resolved by emitting an outbound delete during inbound replay.
+
+### Fact: CloudKit continuation tokens are opaque, per-zone checkpoints and
+can require a full reset
+
+Apple documents a record-zone change token as an opaque point in that zone's
+history, with `nil` meaning a fetch from the beginning; the platform also has a
+`changeTokenExpired` error. Rustpush already recognizes its protocol's
+`FullResetNeeded` result: the Find My client clears its token and materialized
+state, then fetches again. The message-stream page API returns a next token and
+completion status, but the existing iMessage sync loop persists tokens
+independently in preferences, outside the record mutations it just performed.
+
+Source: [Apple record-zone changes documentation](https://developer.apple.com/documentation/cloudkit/ckdatabase/recordzonechanges(inzonewith:since:desiredkeys:resultslimit:)),
+[Apple change-token error documentation](https://developer.apple.com/documentation/cloudkit/ckerror/code/changetokenexpired),
+[rustpush `FullResetNeeded` classifier](../rustpush/src/icloud/cloudkit.rs#L1530-L1537),
+[existing safe-reset call site](../rustpush/src/findmy.rs#L1019-L1039), and
+[legacy iMessage token writes](../lib/services/rustpush/rustpush_service.dart#L3250-L3457).
+
+**Action:** Store a token with an explicit namespace of account identity hash,
+private database, zone name, stream schema version, and rebootstrap generation.
+Commit it only with the successful local inbox/apply state for that page. On a
+recognized token-expired/full-reset response, atomically mark the zone
+`rebootstrapRequired`, stop all workers for that zone, retain raw journal and
+record-map evidence, invalidate the token, then refetch with `nil` into a new
+generation. Reconcile that generation by stable logical identity and
+server-version rules, not by clearing user-visible messages or files. Do not
+reuse a message-zone token for chats or attachments, and do not inspect or sort
+an opaque token.
+
+### Fact: exactly-once is achievable only for the local apply, not for the
+CloudKit fetch itself
+
+The replay-safe inbox pattern persists an event before handling it and commits
+the local effect together with its processed marker. This absorbs fetch retries
+and process crashes, while external transport remains at-least-once. Reference
+implementations also retain failed records for bounded retry and operator
+requeue rather than silently dropping them.
+
+Source: [transactional inbox/outbox reference](https://github.com/qwertyboy0325/handoff-semantics)
+and [event-ID replay protection and local transaction boundary](https://github.com/inbox4j/inbox4j).
+
+**Action:** State the production guarantee precisely: Cloud Sync V2 provides
+at-least-once CloudKit fetch with exactly-once *local database projection* for a
+retained inbox key. It cannot claim global exactly-once delivery or ordering
+across Apple zones. Add fault tests for crash before inbox insert, after inbox
+insert, after entity apply but before token commit, duplicate page, token reset,
+older upsert after newer upsert, tombstone before upsert, and conflicting server
+record IDs for the same logical GUID. Keep malformed, PCS-blocked, and mapping
+conflict rows out of normal retry loops and visible in redacted diagnostics.
+
+### Fact: a push or IDS event is a reconcile hint, never evidence that a
+CloudKit checkpoint is current
+
+Apple documents that CloudKit can coalesce notifications and prune their
+payloads. A client must treat a notification as an indication that a remote
+change might exist, then fetch from its saved change token. Rustpush's APNS
+transport reconnects over TCP 5223 with TCP 443 fallback, while the message
+CloudKit API separately fetches record pages from a continuation token. The
+current tree contains generic CloudKit subscription machinery used by the
+Passwords and keychain clients, but no equivalent subscription-creation call in
+the Messages sync path. An incoming IDS message, APNS reconnect, or
+network-change event therefore cannot prove that every message-zone change has
+been received, nor authorize a token advance.
+
+Source: [Apple Remote Records](https://developer.apple.com/documentation/cloudkit/remote-records),
+[Apple CKQueryNotification](https://developer.apple.com/documentation/cloudkit/ckquerynotification),
+[APNS transport ports](../rustpush/src/aps.rs#L1596-L1599),
+[message page fetch](../rustpush/src/imessage/cloud_messages.rs#L922-L946), and
+[non-Messages subscription call sites](../rustpush/src/passwords.rs#L1310-L1317).
+
+**Action:** Create one durable, account-scoped `reconcileRequested` latch.
+Foreground activation, network restoration, a native connection resume, an
+inbound IDS event, and any validated CloudKit notification may set that latch,
+but none may write a CloudKit token. A single per-account worker should debounce
+and coalesce those hints, drain each zone until its page indicates completion,
+then atomically commit its candidate token with the local projection. Keep a
+bounded, jittered recovery poll only as a missed-hint safety net, with no
+one-request-per-push behavior. Do not add or depend on subscriptions to Apple's
+Messages container without explicit protocol and account-safety validation.
+
+### Fact: name/photo sharing is a separate, two-stage public-CloudKit protocol,
+not message-history replication
+
+Apple states that a shared iMessage name/photo is an immutable encrypted public
+CloudKit record with a new record ID and key whenever the sender changes their
+profile. The record ID and key are carried in an encrypted iMessage payload; a
+recipient then fetches, authenticates, and optionally adopts the profile.
+OpenBubbles matches this separation: it attaches a profile only to eligible
+one-to-one sends, deduplicates its download by CloudKit record key, and retries
+transient profile failures independently so they cannot disrupt message
+delivery.
+
+Source: [Apple secure iMessage name and photo sharing](https://support.apple.com/guide/security/secure-imessage-name-and-photo-sharing-secea5f2e977/web),
+[profile-send eligibility](../lib/services/rustpush/rustpush_service.dart#L3806-L3831),
+and [independent profile retry and fetch](../lib/services/rustpush/rustpush_service.dart#L3842-L3972).
+
+**Action:** Keep profile processing outside the message-history inbox. Persist a
+separate `ProfileFetchJob` keyed by sender identity hash, CloudKit record ID,
+and record-key version, with immutable provenance of `shared-profile`. It may
+update a shared contact/avatar only after decrypt-and-authenticate success; it
+must never silently overwrite a local My Card, manually selected avatar, or
+conversation background. The release suite must distinguish no embedded profile
+payload, public-record fetch failure, cryptographic validation failure, and
+user-policy refusal. Test a normal message, an initial profile share, and a
+changed profile which must arrive under a new record reference.
+
+### Fact: account topology determines what a Cloud Sync test proves
+
+Apple's Messages in iCloud guidance is scoped to devices using the same Apple
+Account. A second account can validate peer send/receive, reactions, media, and
+profile sharing, but cannot by itself prove that one account's history converges
+across local OpenBubbles installations. Reusing a local state store while
+changing accounts also risks mixing opaque tokens and record maps that belong to
+different accounts.
+
+Source: [Apple Messages in iCloud setup](https://support.apple.com/guide/icloud/set-up-messages-mm0de0d4528d/icloud)
+and [opaque, segregated change-token guidance](https://developer.apple.com/documentation/cloudkit/ckserverchangetoken).
+
+**Action:** Use a disposable test Apple Account A for three clean,
+independently stored OpenBubbles profiles: Android, Windows x64, and Windows
+ARM64. This is the replication cohort. Use a separate disposable Account B on
+an Apple-native peer only to generate inbound/outbound traffic. Never switch an
+existing local test profile in place: an account mismatch must quarantine its
+tokens, journal, record map, and profile jobs, then require a new generation.
+Record each test's sender, receiver, attachment SHA-256, operation type, and
+timestamps so same-account convergence and cross-account transport failures are
+not conflated.
+
+### Fact: Android and Windows should coalesce recovery work around lifecycle
+events, not keep the device artificially awake for metadata sync
+
+Android documents WorkManager as persistent work that survives process death
+and reboot, supports unique work, constraints, and retry/backoff. It also warns
+that immediate execution is not guaranteed and recommends combining related
+work to reduce device wakeups. The existing setup currently recommends disabling
+battery optimization to preserve notifications. Windows App SDK lifecycle APIs
+provide power and system-state notifications, including suspend and resume.
+
+Source: [Android persistent task scheduling](https://developer.android.com/develop/background-work/background-tasks/persistent),
+[Android battery optimization guidance](https://developer.android.com/develop/background-work/background-tasks/optimize-battery),
+[Windows AppLifecycle sample](https://learn.microsoft.com/en-us/samples/microsoft/windowsappsdk-samples/applifecycle/),
+[existing Android notification recommendation](../lib/app/layouts/setup/pages/setup_checks/battery_optimization.dart#L14-L22),
+and [current network-change debounce](../lib/services/rustpush/rustpush_service.dart#L1555-L1595).
+
+**Action:** On Android, use one unique, network-constrained V2 reconcile worker
+for a hint or connectivity recovery, with bounded metadata work and documented
+backoff. Keep attachment prefetch/materialization as separately constrained
+work, such as unmetered or charging when it is not user-initiated. Do not claim
+background immediate delivery, and do not require a permanent wake lock for
+Cloud Sync metadata. On Windows, request a coalesced reconcile after a valid
+resume or network restoration while the desktop app can run; persist the dirty
+latch before suspend and accept that a sleeping PC cannot provide a live-sync
+guarantee. Both clients must expose the reason, last completed zone/token
+generation, retry class, and next eligible attempt in redacted diagnostics.
+
+### Fact: the release gate must force every crash boundary and stale-state path
+
+The current page interface can return a bounded ordered batch and a next token,
+while the implementation has explicit token-reset recognition elsewhere. Those
+properties permit deterministic fault injection without relying on an Apple
+account failure to occur naturally. A correctness test that only sends messages
+on a healthy network cannot detect a token committed ahead of its local
+projection, stale-account reuse, or a duplicate-hint storm.
+
+Source: [bounded page request and next-token result](../rustpush/src/icloud/cloudkit.rs#L1291-L1367),
+[page-envelope classification](../rustpush/src/imessage/cloud_messages.rs#L738-L883),
+and [existing reset detection](../rustpush/src/icloud/cloudkit.rs#L1530-L1537).
+
+**Action:** Add a scripted fault matrix to the live gate: duplicate and
+coalesced hints; offline before raw-inbox write; process kill after inbox write,
+after projection, and before token commit; token-expired/full-reset response;
+account identity change; malformed encrypted record; deferred reaction parent;
+profile public-record missing or invalid; and interrupted attachment download.
+For every case, require one of successful eventual convergence with one local
+projection, durable quarantine with an actionable code, or explicit
+rebootstrap-required state. The gate fails if any case silently drops a message,
+advances a token without its local apply, reuses another account's state, or
+keeps a high-frequency background wake loop alive.
+
+### Fact: Flutter and Rust have native Windows ARM64 targets, but the current
+application matrix is intentionally not a shippable ARM64 release
+
+Flutter's Windows build output is architecture-specific (`build/windows/x64` or
+`build/windows/arm64`). Rust officially supports both
+`x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc`, and MSVC can
+cross-compile between Windows architectures when the matching Visual Studio
+components are installed. Cargokit already passes its selected Rust target to
+Cargo and installs it through Rustup. The repository's CI correctly separates
+x64 from ARM64, bootstraps an ARM64 Dart SDK/Flutter engine on a native ARM64
+runner, and selects the matching Rust target. However, that ARM64 job is marked
+experimental and exits before build because locked native dependencies are not
+ARM64-compatible.
+
+Source: [Flutter Windows build architecture](https://docs.flutter.dev/release/breaking-changes/windows-build-architecture),
+[Rust Windows MSVC targets](https://doc.rust-lang.org/stable/rustc/platform-support/windows-msvc.html),
+[Cargokit target invocation](../rust_builder/cargokit/build_tool/lib/src/builder.dart#L116-L156),
+and [current CI architecture and block](../.github/workflows/windows-build.yml#L23-L109).
+
+**Action:** Treat x64 as the only release-capable Windows architecture until
+the ARM64 preflight passes. Keep independent `windows-x64` and `windows-arm64`
+jobs on native runners, using `x86_64-pc-windows-msvc` and
+`aarch64-pc-windows-msvc` respectively. Each job must emit an architecture
+manifest for every PE in the final directory, including the runner, Flutter
+engine, Rust bridge, ObjectBox, media DLLs, PDF renderer, WebView2 loader, and
+plugin DLLs. Reject a bundle if any machine type differs from its declared
+artifact architecture; do not use x64 emulation as evidence of an ARM64 release.
+
+### Fact: CMake platform selection must follow the Visual Studio target, not
+the host processor
+
+For Visual Studio generators, CMake's `-A` option selects the target platform
+and stores it in `CMAKE_GENERATOR_PLATFORM`. This repository already compensates
+for an ARM64 host generating an x64 target by normalizing
+`CMAKE_SYSTEM_PROCESSOR` to `AMD64`, because ObjectBox uses that value to select
+its native archive. The explicit install rules also add the target-architecture
+WebView2 loader and exclude a media package's debug-runtime DLLs. The existing
+normalization is deliberately x64-only; ARM64 must retain its requested target
+value so an ARM64-aware dependency chooses ARM64, rather than inheriting an
+emulated process architecture.
+
+Source: [CMake Visual Studio platform selection](https://cmake.org/cmake/help/latest/variable/CMAKE_GENERATOR_PLATFORM.html),
+[repository architecture normalization and packaging](../windows/CMakeLists.txt#L1-L19),
+and [WebView2/media install handling](../windows/CMakeLists.txt#L52-L76).
+
+**Action:** Require each CI configure/build log to record
+`CMAKE_GENERATOR_PLATFORM`, `CMAKE_VS_PLATFORM_NAME`,
+`CMAKE_SYSTEM_PROCESSOR`, the Rust target triple, and the resulting PE machine
+types. ARM64 enablement must use a clean build/cache directory, never a reused
+x64 Flutter, CMake, Cargo, or dependency cache. Keep the current x64 override
+only for a true x64 target on an ARM64 host, and add a targeted configure test
+that fails if an ARM64 job resolves an x64 ObjectBox, WebView2, PDFium, libmpv,
+ANGLE, or Rust bridge artifact.
+
+### Fact: ObjectBox Windows ARM64 is now available upstream, but the locked
+ObjectBox package in this repository still blocks it
+
+The current CI records that `objectbox_flutter_libs 4.0.3` selects ObjectBox C
+4.0.2 and that release has only x86/x64 Windows archives. Newer ObjectBox C
+5.3.2 release assets include `objectbox-windows-arm64.zip`, and the current
+ObjectBox Flutter Windows CMake source derives the archive from
+`CMAKE_SYSTEM_PROCESSOR`. This removes one external blocker only after a
+reviewed ObjectBox Flutter package upgrade; it does not prove that the current
+lockfile, generated bindings, database migration behavior, or release bundle
+will work unchanged.
+
+Source: [current repository ARM64 block](../.github/workflows/windows-build.yml#L58-L79),
+[ObjectBox C 5.3.2 Windows ARM64 asset](https://github.com/objectbox/objectbox-c/releases/tag/v5.3.2),
+and [current ObjectBox Flutter Windows CMake](https://github.com/objectbox/objectbox-dart/blob/main/flutter_libs/windows/CMakeLists.txt).
+
+**Action:** Split ObjectBox from the broad ARM64 effort. First upgrade it in a
+dedicated, reviewable branch, regenerate only required bindings, and run the
+existing ObjectBox/open-store/migration tests on both architectures. Inspect the
+final `objectbox.dll` machine type and open a copied production-shaped database
+on native ARM64 before removing only the ObjectBox blocker. Do not change the
+application model or Cloud Sync V2 schema merely to achieve architecture parity.
+
+### Fact: media and PDF native dependencies remain the higher-risk ARM64 gate
+
+The repository's locked `media_kit_libs_windows_video` package selects an
+x86_64 libmpv archive and x64 ANGLE bundle, while the locked `printing` package
+hard-codes x64 PDFium. The CI stops for those reasons. Upstream media-kit work
+shows an ARM64 libmpv/ANGLE path is being developed, but that work has had
+upstream-binary and runtime-validation dependencies. Therefore an ARM64 media
+compile, even if forced through CMake, cannot demonstrate safe video, audio,
+GPU, or PDF behavior.
+
+Source: [current CI dependency block](../.github/workflows/windows-build.yml#L66-L77),
+[locked media/PDF dependencies](../pubspec.yaml#L118-L123),
+[locked printing dependency](../pubspec.yaml#L132-L145), and
+[media-kit ARM64 work status](https://github.com/media-kit/media-kit/pull/1381).
+
+**Action:** Before enabling ARM64 packaging, require independently maintained,
+pinned ARM64-capable replacements for libmpv, ANGLE, and PDFium. Verify their
+license notices and checksums, then run native ARM64 smoke tests covering:
+MP4/H.264 and HEVC playback, audio-only messages, paused/fullscreen swipe
+transitions, thumbnail generation, a large image/PDF render, GPU fallback, and
+app exit/relaunch. The test artifact may be labeled `arm64-preview` only after
+all PE checks and those runtime tests pass; otherwise keep it absent rather than
+publishing a package that can launch but fails on received media.
+
+### Fact: the existing Windows CI produces hashable draft ZIPs, not a signed
+Windows release package
+
+The x64 workflow builds a release directory, compresses it, writes a SHA-256
+sidecar, and uploads both as a GitHub Actions artifact. It does not sign the
+executables/DLLs, create an MSIX, or produce provenance attestations. GitHub
+artifact attestations can establish build provenance, but are distinct from
+Windows publisher trust. Microsoft documents that an installable MSIX must be
+signed; self-signed certificates are appropriate only where each test user
+explicitly trusts the certificate, and the certificate subject must match the
+package publisher.
+
+Source: [current ZIP and SHA packaging](../.github/workflows/windows-build.yml#L221-L247),
+[GitHub artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations),
+and [Microsoft MSIX test signing](https://learn.microsoft.com/en-us/windows/msix/package/create-certificate-package-signing).
+
+**Action:** Keep unsigned, SHA-256-checked ZIPs as restricted draft-test
+artifacts only. For a public pre-release, create separate x64 and ARM64
+packages from the verified bundle, attach the architecture manifest, test log,
+SBOM/license inventory, SHA-256, and provenance attestation. Choose one
+distribution path before public release: signed portable bundle/installer, or
+signed MSIX with a publisher identity that matches the certificate. Sign the
+final package and each executable native payload after staging, timestamp it,
+and verify both signature and architecture in a clean Windows VM/native ARM64
+machine. Never place a PFX, password, Apple credential, or self-signed trust
+step in a public workflow or release artifact.
+
+### Fact: the ObjectBox ARM64 unblock is a coordinated SDK, generator, and
+runtime upgrade, not a one-line native-library substitution
+
+The repository locks `objectbox`, `objectbox_flutter_libs`, and
+`objectbox_generator` at 4.0.3, with an older `build_runner` resolution. The
+current 5.3.2 Flutter runtime package requires Dart 3.7, and the matching
+generator requires Dart 3.7, analyzer 8.1.1+, build 4+, and source_gen 4+.
+ObjectBox's 5.0/5.1 release notes require regenerated code after an upgrade;
+5.1 adds a required generated `GeneratorVersion` parameter, so retaining the
+4.0.3 `objectbox.g.dart` produces a compile error. The x64 CI still uses
+Flutter 3.24, which is below the Flutter 3.29/Dart 3.7 baseline documented by
+ObjectBox for its newer packages.
+
+Source: [locked package set](../pubspec.lock#L2276-L2299),
+[current ObjectBox Flutter package constraints](https://pub.dev/packages/objectbox_flutter_libs/versions/5.3.2),
+[ObjectBox 5.0-5.3 release notes](https://pub.dev/packages/objectbox/changelog),
+and [current x64 Flutter CI version](../.github/workflows/windows-build.yml#L23-L40).
+
+**Action:** Make this a dedicated dependency branch with exact, aligned
+versions of `objectbox`, `objectbox_flutter_libs`, and `objectbox_generator`
+at 5.3.2, rather than allowing one package to float. Raise the shared Dart and
+Flutter toolchain to a validated Dart 3.7/Flutter 3.29-or-newer baseline before
+resolving packages, and deliberately update the generator toolchain until
+`build_runner`, `build`, `analyzer`, and `source_gen` satisfy ObjectBox 5.3.
+Then run `dart run build_runner build`, review only the model/generator diff,
+and fail CI if a subsequent generator run is not clean. Do not land the
+ObjectBox package update mixed with unrelated Flutter upgrades or Cloud Sync
+schema edits.
+
+### Fact: the model JSON and generated bindings, not the Dart class names
+alone, protect existing stores
+
+ObjectBox persists entity/property IDs and UIDs in `lib/objectbox-model.json`,
+then uses the generated model when opening a store. Adding/removing properties
+is generally automatic, but renaming requires the preserved UID and changing a
+persisted property type requires application-managed migration. The repository
+has a version-controlled model with message, contact, attachment, and Cloud
+Sync V2 journal entities; its generated file is currently emitted by the 4.0
+generator and has no ObjectBox 5.1 `generatorVersion` argument. Its Cloud Sync
+V2 state is ordinary local ObjectBox data, not ObjectBox Sync: there is no
+`SyncClient` or ObjectBox Admin dependency in the application paths.
+
+Source: [ObjectBox data-model update rules](https://docs.objectbox.io/advanced/data-model-updates),
+[ObjectBox meta-model/UID rules](https://docs.objectbox.io/advanced/meta-model-ids-and-uids),
+[current repository model](../lib/objectbox-model.json#L1625-L1633),
+[current generated model definition](../lib/objectbox.g.dart#L1643-L1757), and
+[Cloud Sync V2 entity source](../lib/database/io/cloud_sync_records.dart#L1-L11).
+
+**Action:** Freeze `lib/objectbox-model.json` before the package upgrade and
+compare entity names, IDs, UIDs, property IDs/UIDs, relation IDs, and retired
+UID lists after generation. No entity/property rename or type change is in
+scope for the ARM64 unblock. Add a schema-fixture test that opens a realistic
+4.0.3-created store under 5.3.2 and verifies messages, chats, attachments,
+contacts, and every Cloud Sync V2 journal/checkpoint/lease/outbox row before
+and after a close/reopen. Treat any unexpected model-JSON semantic change as a
+release blocker, not formatting noise.
+
+### Fact: rollback must restore a closed-store snapshot, not assume that a
+newer native runtime is backward-compatible
+
+ObjectBox documents how a model mismatch can prevent a store from opening and
+identifies reconstructing the UID model or deleting the database as its two
+resolution paths. Deleting a published user's database loses data. The
+documentation does not make a general promise that an older ObjectBox 4.0.3
+runtime can reopen every store that a newer 5.3.2 runtime has written. This
+application opens its live desktop store directly from the app documents path
+and already copies an older custom-path store into that location, so an
+in-place package replacement without a closed-store rollback artifact would be
+unsafe.
+
+Source: [ObjectBox meta-model conflict guidance](https://docs.objectbox.io/advanced/meta-model-ids-and-uids),
+[ObjectBox troubleshooting](https://docs.objectbox.io/troubleshooting), and
+[repository desktop-store open/copy path](../lib/database/database.dart#L135-L151).
+
+**Action:** Before first production launch of the upgraded build, take a
+verified cold copy of the entire ObjectBox directory only after the store is
+closed, plus the matching app settings/database-version record. In validation,
+run both directions on disposable copies: 4.0.3-created store -> 5.3.2 open,
+mutate, close, then 4.0.3 reopen; and 5.3.2-created store -> 5.3.2 reopen. If
+the first reverse-open is not explicitly validated, rollback means restoring
+the cold snapshot with the prior application build, never downgrading against
+the modified live store. Do not use a data-directory deletion as recovery for
+an update failure.
+
+### Fact: ObjectBox 5.3.2 supplies the Windows ARM64 archive, but its Flutter
+CMake package does not pin that download's digest
+
+ObjectBox C 5.3.2 publishes both `objectbox-windows-arm64.zip` and
+`objectbox-windows-x64.zip`. The current Flutter package derives the archive
+name from `CMAKE_SYSTEM_PROCESSOR`, maps `AMD64` to `x64`, links
+`objectbox.dll`, and exports it as a bundled library. The upstream CMake
+`FetchContent` declaration pins the release URL but contains no `URL_HASH`.
+GitHub's release metadata provides SHA-256 digests: ARM64
+`e32ea12aebd76f00bcf9def941a3c73b24d2cc2dcd0e79a033b49522a2b2c0fd` and
+x64 `57d7db013bbb46efe415307c9f3baf7564bdc40818ee1f1c42046f4241403d63`.
+
+Source: [ObjectBox 5.3.2 release assets](https://github.com/objectbox/objectbox-c/releases/tag/v5.3.2),
+[ObjectBox Flutter Windows CMake](https://github.com/objectbox/objectbox-dart/blob/main/flutter_libs/windows/CMakeLists.txt),
+and [repository platform normalization](../windows/CMakeLists.txt#L6-L11).
+
+**Action:** In CI, independently download or inspect the resolved ObjectBox C
+archive and verify the architecture-specific SHA-256 before CMake configures
+the release. Record the archive name, digest, ObjectBox version, final
+`objectbox.dll` SHA-256, and PE machine type in the per-architecture manifest.
+Use fresh per-architecture FetchContent/CMake caches. The x64 target on an
+ARM64 host may retain the existing `AMD64` normalization; the native ARM64
+target must resolve `objectbox-windows-arm64.zip`. Do not accept an x64 DLL
+loaded through emulation as an ARM64 validation.
+
+### Fact: dual-architecture validation needs physical-store portability tests,
+not only a build and unit-test pass
+
+The repository already has focused ObjectBox Cloud Sync store tests that create
+a store, exercise journal/checkpoint/outbox state, close it, and reopen it.
+That demonstrates a useful test harness, but it currently runs only against
+the active local native library and does not prove x64-to-ARM64 store
+interoperability. The application itself relies on transactions, `Store.attach`,
+and a persisted store containing user history, making an architecture-specific
+corruption or model-open failure a release-critical defect.
+
+Source: [existing ObjectBox Cloud Sync reopen tests](../test/services/cloud_sync/objectbox_cloud_sync_store_test.dart#L17-L45),
+[existing attachment-state reopen test](../test/services/cloud_sync/cloud_attachment_materialization_store_test.dart#L32-L63),
+and [production store initialization](../lib/database/database.dart#L112-L151).
+
+**Action:** Build a disposable seed store with the 4.0.3 x64 baseline and
+record stable counts/hashes for each core box and V2 Cloud Sync box. Validate
+four clean-machine sequences: baseline x64 -> upgraded x64; baseline x64 ->
+upgraded ARM64; upgraded ARM64 -> upgraded x64; and upgraded x64 -> upgraded
+ARM64. In each sequence open, query, write one reversible fixture row, close,
+reopen, then assert stable data plus all journal invariants. Run the same
+smoke test through the packaged release directories, not just `flutter test`.
+Remove the ObjectBox entry from the ARM64 CI block only after all four
+sequences, PE/digest manifest checks, and a native ARM64 physical-device run
+pass; leave PDF/media blockers in place independently.
+
+### Fact: the local ARM64 branch's Flutter blocker is obsolete, but its
+cross-compilation warning remains valid
+
+Flutter's current stable `BuildWindowsCommand` selects `windows-arm64` when
+the host platform is `HostPlatform.windows_arm64`, selects `windows-x64` on
+other Windows hosts, and passes the resulting platform through to CMake as
+both `-A ARM64`/`-A x64` and `FLUTTER_TARGET_PLATFORM`. The same behavior is
+present in the verified 3.44.8 source. The Windows ARM64 umbrella records that
+Flutter 3.44.0 stable began producing the ARM64 Dart SDK and Flutter engine
+for every release; the issue remains open because its broader checklist still
+includes cross-compilation and other follow-up work. The local
+`agent/windows-arm64-native` guide instead says stock 3.44.8 cannot produce a
+native ARM64 application. That statement conflicts with the current 3.44.8
+tool source and must not remain a release decision input.
+
+Source: [Flutter stable Windows build command](https://github.com/flutter/flutter/blob/stable/packages/flutter_tools/lib/src/commands/build_windows.dart),
+[Flutter stable CMake target selection](https://github.com/flutter/flutter/blob/stable/packages/flutter_tools/lib/src/windows/build_windows.dart),
+[Flutter ARM64 umbrella, current open state](https://github.com/flutter/flutter/issues/62597),
+[Flutter 3.44.8 tag](https://github.com/flutter/flutter/tree/3.44.8), and
+[stale local alpha guide](windows-arm64-alpha.md#blocker-flutter-windows-arm64-target).
+
+**Action:** Replace the alpha guide's "blocker zero" assertion with a
+host/target matrix: native Windows ARM64 host plus ARM64 Dart/engine builds
+ARM64 by default; native x64 host builds x64 by default; x64-to-ARM64
+cross-compilation is not a supported release path. Retain Flutter 3.44.8 as
+the minimum native-ARM64 CI baseline, because 3.44.0 is the first stable
+release with the required downloadable artifacts, but do not describe it as a
+patched Flutter requirement. Re-run dependency resolution and a minimal
+native ARM64 app build before treating the application-level dependency work
+as the remaining blocker.
+
+### Fact: `flutter build windows --target-platform windows-arm64` is not the
+official stable command path
+
+The current stable command exposes no Windows `--target-platform` argument;
+its target is derived from the host architecture. Flutter issue #129808,
+still open, explicitly proposes that option for cross-compilation and says it
+should error on the beta and stable channels. A native ARM64 runner must
+therefore execute plain `flutter build windows --release` after its ARM64 SDK
+and engine have been selected. Passing a proposed flag, forcing an ARM64
+CMake generator from an x64 Flutter tool, or interpreting an x64-emulated
+build as an ARM64 artifact would create a false validation result.
+
+Source: [Flutter stable build command](https://github.com/flutter/flutter/blob/stable/packages/flutter_tools/lib/src/commands/build_windows.dart),
+[cross-compilation proposal #129808](https://github.com/flutter/flutter/issues/129808), and
+[repository native ARM64 CI lane](../.github/workflows/windows-build.yml#L26-L92).
+
+**Action:** Keep separate native `windows-2022` x64 and `windows-11-arm` ARM64
+jobs. For each, use plain `flutter build windows --release`, retain the
+post-build PE-machine manifest check, and publish neither job as the other
+architecture. Close the ARM64 experimental gate only after the ARM64 job
+finishes package resolution, release build, and native-device smoke tests;
+do not wait for cross-compilation support that the product does not require.
+
+### Fact: the existing cache refresh is an upstream-described artifact
+selection workaround, not a local Flutter patch
+
+The Windows Flutter SDK archive still starts with x64 Dart and engine
+components. The ARM64 umbrella documents the same sequence used in this
+repository: remove `bin/cache/engine-dart-sdk.stamp`, refresh Dart so it
+downloads `windows_arm64`, then run `flutter precache --windows` and verify
+the ARM64 engine. The repository currently forces
+`subosito/flutter-action@v2` to install `architecture: x64` and then performs
+that refresh manually. The action itself now declares `arm64` as a supported
+SDK architecture and defaults its architecture input to the runner's
+architecture. This makes the forced-x64/bootstrap combination a candidate
+for removal, but third-party action behavior must be demonstrated on the
+native GitHub runner before deleting the independent Dart/engine assertions.
+
+Source: [Flutter ARM64 artifact instructions](https://github.com/flutter/flutter/issues/62597),
+[repository forced-x64/bootstrap sequence](../.github/workflows/windows-build.yml#L60-L92), and
+[flutter-action architecture input](https://github.com/subosito/flutter-action/blob/main/action.yaml).
+
+**Action:** In a disposable ARM64 CI run, set the action architecture to
+`arm64` (or omit it and verify its native-runner default), retain only the
+read-only assertions that `dart --version` reports `windows_arm64` and that
+`windows-arm64-release` exists, then run a clean minimal app build. If that
+passes from an empty Flutter-action cache, remove the forced `architecture:
+x64`, stamp deletion, and direct call to Flutter's internal update script.
+Keep the architecture and engine-directory assertions permanently. If the
+action does not supply the correct artifacts, restore the documented refresh
+sequence and record that as a tool bootstrap constraint rather than a Flutter
+source fork.
+
+### Fact: the ARM64 branch's application changes are native-dependency work,
+not Flutter-framework patches, and cannot be removed on framework support
+alone
+
+The ARM64 branch contains no Flutter engine/tool fork. It removes the
+`printing` plugin's x64-only PDFium use and vendors an ARM64 media bundle;
+its remaining ObjectBox path requires the coordinated 5.3.2 upgrade above.
+Flutter's closed ARM64 plugin-linking report confirms that a clean Flutter app
+builds natively and that failures from plugins carrying x64-only precompiled
+libraries are expected, not a framework regression. Consequently, native
+Flutter support resolves the stale framework blocker but does not make an
+x64-only PDF, media, database, WebView, or other DLL loadable in an ARM64
+process.
+
+Source: [ARM64 branch commit/file delta](https://github.com/OpenBubbles/openbubbles-app/compare/main...agent/windows-arm64-native),
+[local ARM64 media/PDF guide](windows-arm64-alpha.md), and
+[Flutter native-plugin architecture report #186836](https://github.com/flutter/flutter/issues/186836).
+
+**Action:** Preserve the printing removal, media PE-machine verification, and
+ObjectBox upgrade plan until an upstream dependency release replaces each one
+and a clean native ARM64 release package passes audio, video, PDF export, and
+existing-store tests. Update the alpha guide to separate framework readiness
+from dependency readiness: Flutter is now a host-build prerequisite that can
+be validated in CI, while each native dependency remains an independently
+testable release gate. Do not remove a dependency workaround merely because a
+framework-level ARM64 build succeeds.
+
+### Fact: ObjectBox publishes the Dart API, Flutter runtime package, generator,
+and desktop C library as a matched release set
+
+The repository currently resolves `objectbox`, `objectbox_flutter_libs`, and
+`objectbox_generator` to 4.0.3. In that release, both the Flutter runtime
+package and generator depend on `objectbox: 4.0.3` exactly. The corresponding
+5.3.2 packages likewise require `objectbox: 5.3.2` exactly. ObjectBox's
+getting-started guidance instructs Flutter users to add compatible package
+versions together, and its 4.0.3 and 5.3.2 release notes each name the desktop
+ObjectBox C version bundled for Flutter Windows/Linux and Dart Native apps.
+This is a published release-coupling pattern, not a collection of independent
+native artifacts.
+
+Source: [current repository lockfile](../pubspec.lock#L2276-L2298),
+[ObjectBox Dart 4.0.3 package definitions](https://github.com/objectbox/objectbox-dart/tree/v4.0.3),
+[ObjectBox Dart 5.3.2 package definitions](https://github.com/objectbox/objectbox-dart/tree/v5.3.2),
+[ObjectBox getting-started guidance](https://docs.objectbox.io/getting-started),
+[ObjectBox Dart 4.0.3 release](https://github.com/objectbox/objectbox-dart/releases/tag/v4.0.3), and
+[ObjectBox Dart 5.3.2 release](https://github.com/objectbox/objectbox-dart/releases/tag/v5.3.2).
+
+**Action:** Treat `objectbox`, `objectbox_flutter_libs`, and
+`objectbox_generator` as one versioned unit. Do not ship a manifest or local
+native-artifact override that claims the 4.0.3 Dart/generator packages are a
+supported counterpart of ObjectBox C 5.3.2 merely because it supplies the
+needed ARM64 DLL.
+
+### Fact: ObjectBox Dart 4.0.3 performs only a lower-bound native-library
+check, so it will not by itself reject ObjectBox C 5.3.2
+
+The 4.0.3 Dart binding loads `objectbox.dll` dynamically and accepts a native
+library whose reported C API is at least 4.0.1 and whose core version is at
+least `4.0.2-2024-10-15`; it has no upper-bound or exact-version comparison.
+ObjectBox C's own header instructs dynamic-library consumers to verify that a
+compatible version was linked through `obx_version()` or
+`obx_version_is_at_least()`. Therefore, assuming the ARM64 DLL reports its
+published 5.3.2 version, 4.0.3's startup guard is expected to accept it.
+
+Source: [4.0.3 native loader and compatibility guard](https://github.com/objectbox/objectbox-dart/blob/v4.0.3/objectbox/lib/src/native/bindings/bindings.dart),
+[ObjectBox C version-check contract](https://github.com/objectbox/objectbox-c/blob/v5.3.2/include/objectbox.h), and
+[ObjectBox C 5.3.2 release](https://github.com/objectbox/objectbox-c/releases/tag/v5.3.2).
+
+**Action:** Use the lower-bound result only as a diagnostic fact: it explains
+why a 4.0.3/5.3.2 experiment may load rather than fail immediately. Add an
+explicit startup diagnostic that records the loaded C API/core version and PE
+machine type during any temporary experiment, but do not use a successful
+load, smoke test, or absence of a guard failure as proof of production ABI or
+store compatibility.
+
+### Inference: loading is not a published guarantee that the 4.0.3 FFI binding
+is production-safe with ObjectBox C 5.3.2
+
+The 4.0.3 binding was generated from older ObjectBox C headers, while the
+5.3.2 release updates C API headers and generated Dart FFI bindings. ObjectBox
+has added explicit runtime/generator compatibility enforcement in the 5.x
+series and repeatedly directs users to release-matched native dependencies
+when the runtime changes. No official compatibility matrix or maintainer
+statement found in this review guarantees every 4.0.3 Dart FFI call, callback,
+observer, query stream, or on-disk behavior against ObjectBox C 5.3.2 across a
+major version boundary. That absence does not prove an ABI break, but it makes
+the mixed-major configuration unsupported for a production messaging store.
+
+Source: [5.3.2 C-API/binding update commit](https://github.com/objectbox/objectbox-dart/commit/30936773c6f2a4ea6d11758f9564dd8dbe589ea5),
+[ObjectBox 5.1 generator compatibility release note](https://github.com/objectbox/objectbox-dart/releases/tag/v5.1.0),
+[maintainer guidance on matching runtime dependencies](https://github.com/objectbox/objectbox-dart/issues/690), and
+[5.3.2 release pairing](https://github.com/objectbox/objectbox-dart/releases/tag/v5.3.2).
+
+**Action:** Reject 4.0.3 Dart bindings plus ObjectBox C 5.3.2 as the release
+baseline for both Windows architectures. It may remain an isolated,
+non-production proof-of-load only if it uses a disposable database, contains
+no Sync/Admin features, records exact native versions, and is never used to
+mutate or validate an upgrade path for a user's live store.
+
+### Fact: the 5.3.2 upgrade requires Dart 3.7 and generator regeneration, so
+the current x64 and ARM64 toolchains must move together
+
+`objectbox_flutter_libs` 5.3.2 and `objectbox_generator` 5.3.2 require Dart
+`^3.7.0`; the generator also requires analyzer 8.1.1+, build 4+, and
+source_gen 4.0.1+. ObjectBox 5.1 introduced a mandatory `GeneratorVersion`
+argument specifically to enforce generated-code/runtime compatibility and
+states that `dart run build_runner build` must be run after updating the
+ObjectBox package. The repository's x64 workflow is pinned to Flutter 3.24.0,
+whose release coincided with Dart 3.5, so it cannot satisfy the 5.3.2 Dart
+floor. Flutter 3.44 includes Dart 3.12 and meets that floor, but an ARM64-only
+toolchain upgrade would leave x64 package resolution and generated code out of
+parity.
+
+Source: [ObjectBox 5.3.2 Flutter runtime package](https://github.com/objectbox/objectbox-dart/blob/v5.3.2/flutter_libs/pubspec.yaml),
+[ObjectBox 5.3.2 generator constraints](https://github.com/objectbox/objectbox-dart/blob/v5.3.2/generator/pubspec.yaml),
+[ObjectBox 5.1 regeneration requirement](https://github.com/objectbox/objectbox-dart/releases/tag/v5.1.0),
+[current x64/ARM64 CI matrix](../.github/workflows/windows-build.yml#L18-L40),
+[Flutter 3.24/Dart 3.5 release context](https://docs.flutter.dev/release/archive-whats-new), and
+[Flutter 3.44 release notes](https://docs.flutter.dev/release/release-notes/release-notes-3.44.0).
+
+**Action:** Use one coordinated baseline for the production migration:
+Flutter 3.44.8 on both Windows x64 and ARM64, with the exact ObjectBox 5.3.2
+trio and a reviewed Dart/build-runner/analyzer/source_gen resolution. Regenerate
+`objectbox.g.dart`, preserve the model JSON/UIDs, and run the existing-store
+portability matrix before any release. This is a larger upgrade than a DLL
+swap, but it is the safest route because it gives both architectures the same
+toolchain, generated bindings, native API expectation, and supportable
+rollback boundary.
diff --git a/docs/CLOUD_SYNC_V2_PROVENANCE_LEDGER.md b/docs/CLOUD_SYNC_V2_PROVENANCE_LEDGER.md
new file mode 100644
index 0000000000..d711f900d6
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_PROVENANCE_LEDGER.md
@@ -0,0 +1,122 @@
+---
+type: provenance_ledger
+title: OpenBubbles Cloud Sync V2 Provenance Ledger
+description: Per-idea record of borrowed protocol facts and patterns, their source licence, whether code or only a concept was taken, and the file that implements each one.
+resource: openbubbles-app
+tags: [licensing, provenance, cloudkit, sspl, apache-2.0, compliance]
+timestamp: 2026-08-22
+---
+
+# Cloud Sync V2 provenance ledger
+
+## Why this exists
+
+[Open-source pattern review](CLOUD_SYNC_V2_OPEN_SOURCE_REVIEW.md) states the
+rule: for every borrowed implementation idea, record the project, exact source
+URL, licence, whether code or only a concept was used, and the OpenBubbles file
+that implements it. That document holds a per-project table. This one holds the
+per-idea entries the rule actually asks for, and it is a release gate.
+
+## The distinction this ledger turns on
+
+A field name, a wire type number, a zone name, and the grammar of an identifier
+are **facts about Apple's protocol**. Observing that Apple encodes a reaction
+parent as `p:/` is a fact, and facts are not copyrightable.
+
+A struct definition, a derive macro, a `.proto` file, and a function body are
+**expression**. They carry the licence of the project that wrote them.
+
+Every entry below records which of the two was taken. Where the source is
+SSPL-1.0 or GPL-family, only facts were used and the implementation was written
+independently against them.
+
+## The rustpush boundary, stated precisely
+
+`rustpush` is **SSPL-1.0** and ships an exception granting an MIT-style licence
+to OpenBubbles itself, not to third parties. The application already depends on
+it as a submodule and links it, which is a deliberate existing architecture
+decision, not something this work introduced.
+
+What this ledger governs is narrower: **no rustpush source may be copied into
+the Apache-2.0 Dart or `rust/src` layer.** Protocol facts learned by reading it
+may be, and each one is listed below.
+
+## Ledger
+
+### Apple protocol facts
+
+| # | Fact taken | Source | Licence | Code or concept | Implemented in |
+| --- | --- | --- | --- | --- | --- |
+| 1 | Reaction parent is encoded `p:/`, and as a **bare GUID with no prefix** when no part is targeted | [rustpush `messages.rs`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/messages.rs) `amk` construction | SSPL-1.0 | Fact only | `rust/src/cloud_sync_canonical_dto.rs` (`parse_associated_parent`), `lib/services/rustpush/cloud_sync/cloud_associated_message_parent_reference.dart` |
+| 2 | Reply parent is `r::`, colon-separated, an independent grammar from the reaction form | same, `tg` field | SSPL-1.0 | Fact only | `rust/src/cloud_sync_canonical_dto.rs` (`parse_reply_parent`) |
+| 3 | Owned attachment identity is `at__`, and the GUID may itself contain underscores | same, `transfer_guid` | SSPL-1.0 | Fact only | `lib/utils/attachment_guid_utils.dart`, `rust/src/cloud_sync_canonical_dto.rs` (`parse_owned_attachment_guid`) |
+| 4 | `bp` and `bpdi` are IDS wire-payload keys, **not** CloudKit record fields; the record equivalent is `MessageProto.payloadData` | [rustpush `rawmessages.rs`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/rawmessages.rs) vs `cloud_messages.rs` | SSPL-1.0 | Fact only | No code change. Recorded because it prevented inventing presence rules for fields that do not exist on these records. |
+| 5 | `filt`, `sqry`, `ste` are `i64`, not booleans | [rustpush `cloud_messages.rs`](https://github.com/OpenBubbles/rustpush/blob/master/src/imessage/cloud_messages.rs) | SSPL-1.0 | Fact only | Already correct before this review; verified, not changed |
+| 6 | `ust`, `hbr`, `oui`, `osn`, `euh`, `bcg`, `ams`/`ampt`/`amc`/`amb`/`amd` live inside a `MessageSummaryInfo` plist at protobuf field 7, gzipped; `bcg` sits inside a `MessageEdit`; `ec` and `otr` are keyed by decimal-string part | same | SSPL-1.0 | Fact only | Already correct before this review; verified, not changed |
+| 7 | Apple's summary plist omits empty collections rather than sending them, so there is no explicit-clear state in that plist | same, serde `skip_serializing_if` on collections | SSPL-1.0 | Fact only | `rust/src/cloud_sync_canonical_converter.rs` (empty `ec`/`rp` now reads as absent rather than a clear instruction) |
+| 8 | Zone-to-record-type mapping for the three Manatee zones | same | SSPL-1.0 | Fact only | `rust/src/cloud_sync_native_fetch.rs` |
+| 9 | `messageUpdateZone` and `recoverableMessageDeleteZone` exist and are not currently read | same, zone reset list | SSPL-1.0 | Fact only | Not implemented. Recorded as an open scope question in [path to production](CLOUD_SYNC_V2_PATH_TO_PRODUCTION.md). |
+| 10 | PCS GCM additional authenticated data is scoped `zone-record-field`, so a wrong field name fails authentication rather than yielding wrong plaintext | [rustpush `pcs.rs`](https://github.com/OpenBubbles/rustpush/blob/master/src/icloud/pcs.rs) | SSPL-1.0 | Fact only | Not code. Informs live validation: field names are self-verifying against real data. |
+
+### Wire-format facts
+
+| # | Fact taken | Source | Licence | Code or concept | Implemented in |
+| --- | --- | --- | --- | --- | --- |
+| 11 | CloudKit's field value type enum includes `EMPTY_LIST = 9`, a distinct representation of "present but empty" | `rustpush/cloudkit-proto/src/cloudkit.proto`, vendored in-tree | SSPL-1.0 | Fact only | `rust/src/cloud_sync_canonical_converter.rs` records the observation as evidence; no decision reads it |
+| 12 | Error codes `RESET_NEEDED = 17` and `FULL_RESET_NEEDED = 40` | same | SSPL-1.0 | Fact only | Not implemented. Feeds the rebootstrap requirement in the production-readiness notes. |
+
+### Engineering patterns
+
+| # | Pattern taken | Source | Licence | Code or concept | Implemented in |
+| --- | --- | --- | --- | --- | --- |
+| 13 | Windows provides no directory-sync primitive; a no-op is the correct implementation and durability rests on startup reconciliation | [Restic](https://github.com/restic/restic) `local_windows.go` | BSD-2-Clause | Concept only | `rust/src/cloud_sync_native_fetch.rs` (`sync_directory` on Windows) |
+| 14 | Part-index semantics, and that `bp:` is a real GUID prefix for bubble/tapback messages in the local database schema | [imessage-exporter](https://github.com/ReagentX/imessage-exporter) `variants.rs` doc comments | **GPL-3.0** | Concept only, no code | Informed entry 4. No GPL code is present in this repository. |
+| 15 | Exactly-once local projection is achieved by writing the cursor in the same transaction as the projected rows, with a sequence-guarded upsert, rather than a dedup table | CouchDB replication protocol (Apache-2.0), Replicache server-pull docs, Debezium docs | Apache-2.0 and documentation | Concept only | Not yet implemented. Recorded as a binding constraint for semantic apply. |
+| 16 | Contiguous-prefix cursor with a separate high watermark, and a bounded gap set that stops admission rather than evicting | NATS JetStream docs, Apache Pulsar PIP-81, PostgreSQL replication slots | Apache-2.0 and documentation | Concept only | Partially present as the existing contiguous checkpoint. The bounded retry queue and stall timer are not implemented. |
+| 17 | Server-authoritative field classification instead of CRDTs for a single-writer projection | [Figma multiplayer writeup](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/) | Article | Concept only | Not yet implemented. Recorded as a Phase 2 prerequisite. |
+| 18 | One fsynced contiguous-prefix integer per blob, truncated to on startup | Restic, Syncthing (MPL-2.0) design docs | BSD-2-Clause, MPL-2.0 | Concept only, no code from either | Attachment materialisation design; not yet implemented |
+| 19 | Two-level fault injection where a call site is armed per run and then fires probabilistically | FoundationDB `BUGGIFY` | Apache-2.0 | Concept only | Not yet implemented |
+| 20 | Incrementing fail-on-Nth-operation loop for crash testing, run in both fail-once and fail-persistently modes | [SQLite testing](https://www.sqlite.org/testing.html) | Public domain | Concept only | Not yet implemented |
+
+### Apple iOS 26 implementation evidence
+
+These entries record protocol and orchestration facts observed in a fixed-commit
+decompilation of Apple's iOS 26.1 Messages implementation. The mirror does not
+grant a source-code licence. No function body, control flow, symbol layout, or
+other expression from it is copied into OpenBubbles. The facts are corroborated
+where possible with Apple's public CloudKit documentation.
+
+| # | Fact taken | Source | Licence | Code or concept | Implemented in |
+| --- | --- | --- | --- | --- | --- |
+| 21 | Chat record saves use an atomic modify-records operation, while message and attachment saves use non-atomic operations with per-record outcomes | [iOS 26.1 chat factory](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMDaemonCore.framework/IMDaemonCore/IMDCKChatSyncCKOperationFactory.mm), [message factory](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMDaemonCore.framework/IMDaemonCore/IMDCKMessageSyncCKOperationFactory.mm), and [attachment factory](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMDaemonCore.framework/IMDaemonCore/IMDCKAttachmentSyncCKOperationFactory.mm); [Apple `isAtomic`](https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation/isatomic) | Binary-derived fact; Apple documentation | Fact only | Not implemented. Binding input to the Stage 4 batch and acknowledgement design. |
+| 22 | Chat and message factories explicitly select raw save policy `1`, which SDK declarations identify as changed-keys. The attachment factory does not override the operation default; Apple's documented default is if-server-record-unchanged | Same fixed factory sources; [Apple `savePolicy`](https://developer.apple.com/documentation/cloudkit/ckmodifyrecordsoperation/savepolicy) | Binary-derived fact; Apple documentation | Fact only | Not implemented. Stage 4 must represent save policy explicitly and test conflict behavior rather than inheriting one generic policy. |
+| 23 | Apple's chat importer deliberately drops incoming chat-record deletions because IDS handles them in real time | [iOS 26.1 `IMDCKChatSyncController`](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMDaemonCore.framework/IMDaemonCore/IMDCKChatSyncController.mm) | Binary-derived fact; no source licence | Fact only | The read-only canary already quarantines all tombstones. Stage 4 must keep chat tombstones from deleting local conversations merely because CloudKit reported a deletion. |
+| 24 | Chat, message, and attachment record deletes use non-atomic modify operations; the controllers deduplicate pending record IDs before scheduling deletion | Same fixed factories and controllers | Binary-derived fact; no source licence | Fact only | Not implemented. Stage 4 delete identity, deduplication, partial-result acknowledgement, and retry tests required. |
+| 25 | The update-zone importer treats record deletion as unsupported and routes UT1/UT2 save conflicts through type-specific conflict handlers | [iOS 26.1 `IMDCKUpdateSyncController`](https://github.com/EthanArbuckle/iPhone18-3_26.1_23B85_Restore/blob/90aa0cfe59d9682b4265e1354c8b19ec3c7823ab/System/Library/PrivateFrameworks/IMDaemonCore.framework/IMDaemonCore/IMDCKUpdateSyncController.mm) | Binary-derived fact; no source licence | Fact only | Not implemented. `messageUpdateZone` remains outside the first semantic canary and needs a separate conflict model before activation. |
+| 26 | CloudKit has distinct HTTP-request UUID, per-operation UUID, record ETag/change tag, and change-feed ETag identities; none is a substitute for another | [Apple `CKDPOperation`](https://raw.githubusercontent.com/nst/iOS-Runtime-Headers/master/PrivateFrameworks/CloudKitDaemon.framework/CKDPOperation.h), [Apple `CKDPRecord`](https://raw.githubusercontent.com/nst/iOS-Runtime-Headers/master/PrivateFrameworks/CloudKitDaemon.framework/CKDPRecord.h), and the in-tree rustpush request builder | Header declarations and protocol facts | Fact only | `CloudOutboxOperation` and `CloudOutboxOperationEntity` now persist exact request and operation UUIDs atomically at the ambiguity boundary. Predecessor record ETag binding remains a write-activation gate. |
+| 27 | Apple's private save request and response models expose ETag, conflict, protection-tag, and time-statistics fields beyond the fields represented by the current rustpush protobuf | [Apple `CKDPRecordSaveRequest`](https://raw.githubusercontent.com/nst/iOS-Runtime-Headers/master/PrivateFrameworks/CloudKitDaemon.framework/CKDPRecordSaveRequest.h), [Apple `CKDPRecordSaveResponse`](https://raw.githubusercontent.com/nst/iOS-Runtime-Headers/master/PrivateFrameworks/CloudKitDaemon.framework/CKDPRecordSaveResponse.h), and [rustpush `cloudkit.proto`](https://github.com/OpenBubbles/rustpush/blob/master/cloudkit-proto/src/cloudkit.proto) | Header declarations; SSPL-1.0 local schema | Fact only | Not implemented. Wire numbers must come from a serialized fixture or another directly verified schema, never from guessing based on header property order. |
+| 28 | The private error grammar includes request-already-processed, operation-lock, atomic-failure, stale-record-update, and record/zone protection-tag mismatch codes | [InflatableDonkey `cloud_kit.proto`](https://github.com/horrorho/InflatableDonkey/blob/master/src/main/resources/cloud_kit.proto) | MIT | Fact only | Existing rustpush classification covers only part of this set. Stage 4 needs fixture-backed typed outcomes before enabling writes. |
+| 29 | `REQUEST_ALREADY_PROCESSED` without authoritative per-record results is not itself a commit receipt | Same private error grammar, compared with Apple's documented per-record modify results | MIT; Apple documentation | Conservative inference from verified facts | `CloudOutboxStatus.unknownOutcome` remains fail-closed. No replay or confirmation may be based on this error code alone. |
+| 30 | Readback absence after an ambiguous delete does not prove that this operation deleted the record; another writer or a pre-existing absence can produce the same observation | Apple's optimistic-concurrency and per-record result model | Apple documentation | Conservative inference from verified facts | `reconcileUnknownOutcome` must remain unresolved unless a protected proof binds an authoritative operation result or stronger predecessor-version evidence. |
+| 31 | Public CloudKit modify calls return one result per saved or deleted record, while atomic operations can fail the entire zone batch | [Apple `modifyRecords`](https://developer.apple.com/documentation/cloudkit/ckdatabase/modifyrecords(saving:deleting:savepolicy:atomically:)) | Apple documentation | Fact only | V2 must reject missing, duplicate, or unexpected private-operation results and may confirm only exact per-record successes. |
+| 32 | A server-record-changed conflict supplies client, server, and ancestor records; a retry must merge onto the server record because it owns the current change tag | [Apple `serverRecordChanged`](https://developer.apple.com/documentation/cloudkit/ckerror/serverrecordchanged) | Apple documentation | Fact only | The private writer needs fixture-proven predecessor ETag/change-tag fields and a typed conflict result before writes can be enabled. |
+| 33 | Database and record-zone change tokens are opaque, persistable, and not interchangeable; token expiry requires a scoped refetch rather than interpreting token contents | [Apple `CKFetchDatabaseChangesOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchdatabasechangesoperation), [Apple `CKFetchRecordZoneChangesOperation`](https://developer.apple.com/documentation/cloudkit/ckfetchrecordzonechangesoperation) | Apple documentation | Fact only | Keep token bytes protected and stream-scoped. Expiry must preserve local rows, reset only the affected checkpoint, and restart that stream from no token. |
+| 34 | CloudKit subscriptions are change hints, not complete change records, and notifications may be coalesced | [Apple `CKDatabaseSubscription`](https://developer.apple.com/documentation/cloudkit/ckdatabasesubscription), [Apple `CKRecordZoneSubscription`](https://developer.apple.com/documentation/cloudkit/ckrecordzonesubscription) | Apple documentation | Fact only | Poll/fetch remains authoritative. Subscription setup must be idempotent and cannot replace checkpointed page fetching. |
+
+## Sources deliberately not used
+
+| Project | Licence | Why excluded |
+| --- | --- | --- |
+| Signal Desktop | AGPL-3.0 | Incompatible with the Apache-2.0 layer; architecture comparison only |
+| mautrix/imessage | AGPL-3.0 | Same, and it reads a local database rather than CloudKit |
+| imessage-exporter | GPL-3.0 | Concepts only, as recorded in entry 14 |
+| Apple Security / CKKS mirror | Apple Public Source-style, no licence metadata on the mirror | Concepts only; also predates Advanced Data Protection |
+| Decompiled iOS restoration mirrors | No source-code licence | Protocol and orchestration facts only, as recorded in entries 21-25; no expression copied |
+| InflatableDonkey | MIT | Permissively licensed and reusable, but scoped to iOS 9 backups with no Manatee zones or per-field encryption. Nothing taken so far. |
+
+## Maintenance
+
+Add an entry whenever a protocol fact or pattern is taken from an outside
+project, in the same change that implements it. An entry naming no
+OpenBubbles file is acceptable only when the fact prevented work, as in entries
+4 and 9, and that should be stated in the row.
diff --git a/docs/CLOUD_SYNC_V2_SEMANTIC_APPLIER.md b/docs/CLOUD_SYNC_V2_SEMANTIC_APPLIER.md
new file mode 100644
index 0000000000..027b8dd2e3
--- /dev/null
+++ b/docs/CLOUD_SYNC_V2_SEMANTIC_APPLIER.md
@@ -0,0 +1,94 @@
+---
+type: design
+title: Cloud Sync V2 Semantic Applier Boundary
+description: Platform-neutral reconciliation contract and remaining adapters for guarded semantic pull.
+resource: OpenBubbles Cloud Sync V2
+tags:
+ - openbubbles
+ - cloud-sync
+ - reconciliation
+timestamp: 2026-08-01
+---
+
+# Cloud Sync V2 Semantic Applier Boundary
+
+`TransactionalCloudInboxApplier` and
+`ObjectBoxCloudSemanticStoreGateway` are dormant Phase 2 building blocks.
+Neither has a production canonical adapter or runtime composition, and
+`semanticApply` remains disabled.
+
+## Established boundary
+
+The native decoder must return a `CloudDecodedMutation` with two deliberately
+separate lanes:
+
+- A typed `CloudSemanticEntityPayload` carries transient plaintext needed to
+ create or update the canonical Message, Chat, Attachment, Reaction, group
+ photo, or shared-profile row. Payload types have no JSON or Map conversion,
+ and their string representation is permanently redacted.
+- A `CloudSemanticSnapshot` carries only account-scoped, content-free merge
+ metadata: digests, timestamps, protected raw-record references, and safe
+ logical-key hashes.
+
+The transient payload may exist in memory because the app must render the
+decrypted entity. It must never be persisted in Cloud Sync replay, conflict,
+quarantine, checkpoint, or diagnostic metadata.
+
+The local adapter must implement `CloudSemanticStoreGateway`. Its transaction
+must atomically:
+
+1. Revalidate the active account, coordinator lease owner/generation/expiry,
+ checkpoint scope/generation, and exact pending inbox row.
+2. Check a replay outcome bound to the change, server-record digest, payload
+ digest/protected-reference digest, generation, sequence, and change type.
+3. Read and merge the content-free local semantic snapshot.
+4. Apply the transient payload to the canonical entity, write the semantic
+ snapshot, and upsert the protected record map.
+5. Commit exactly one replay outcome (`applied`, `appliedWithConflict`, or
+ `quarantined`) and the matching inbox terminal state.
+
+The transaction callback is synchronous by design. An adapter must not await
+network, native decoding, or another isolate while an ObjectBox write
+transaction is open. Per-Store reentrancy and transaction-lifetime guards reject
+nested calls, discarded nested futures, microtask use, and use after return.
+
+Every durable semantic digest is either a 43-character unpadded base64url value
+or a 64-character lowercase hexadecimal value. Scope components are bounded
+and reject control characters and the storage-key delimiter. Unknown
+adapter/ObjectBox exceptions are converted to a fixed redacted failure code.
+
+## Remaining native decoder adapter
+
+The Rust adapter still needs a reviewed decoder for each supported Apple record
+type. It must:
+
+- decrypt only inside the existing native/keystore boundary;
+- canonicalize logical keys, content, edit parts, group metadata, reactions,
+ and parent references into keyed hashes or digests;
+- prove whether a tombstone is authoritative server state;
+- preserve unknown fields through a protected raw-record reference;
+- return typed malformed, PCS, authorization, and unsupported-record failures;
+- clear plaintext buffers after decoding where practical;
+- never send message bodies, handles, account identifiers, or keys to Dart
+ diagnostics or durable sync metadata.
+
+## Established ObjectBox metadata adapter
+
+The ObjectBox gateway now provides the fenced transaction, snapshot, protected
+record-map, replay, and inbox-status metadata boundary. Tests cover lease
+takeover/expiry, account switch, generation reset, exact inbox matching,
+rollback, replay binding, record-map conflicts, strict digest privacy,
+secret-bearing exceptions, nested/escaped transactions, process reopen,
+parent-integrity checks, and stream/entity allowlists.
+
+The remaining production adapter must map the transient payload to the app's
+existing canonical chat/message/reaction models rather than create a second
+message database. The current fixture adapter exists only to prove transaction
+behavior.
+
+Tombstones remain disabled in the durable gateway. Before enabling
+`semanticApply`, add the production canonical adapter, reviewed native-to-Dart
+binding, real canonical-model relation tests, cross-architecture reopen tests,
+schema migration fixtures, reaction-parent arrival ordering, local-outbox
+ordering, and authoritative deletion recovery. No adapter may reuse the Phase 1
+shadow inbox row itself as plaintext semantic storage.
diff --git a/docs/DECISION_OBJECTBOX_DEPENDENCY.md b/docs/DECISION_OBJECTBOX_DEPENDENCY.md
new file mode 100644
index 0000000000..d9c59043d0
--- /dev/null
+++ b/docs/DECISION_OBJECTBOX_DEPENDENCY.md
@@ -0,0 +1,102 @@
+---
+type: decision_record
+title: ObjectBox Dependency Posture
+description: What OpenBubbles actually depends on from ObjectBox, which licence covers which part, and the two conditions that would force this decision to be revisited.
+resource: openbubbles-app
+tags: [objectbox, licensing, dependencies, redistribution, decision]
+timestamp: 2026-08-06
+---
+
+# Decision: ObjectBox dependency posture
+
+## Status
+
+Accepted, with two named review triggers. Recorded now because Cloud Sync V2 is
+about to make ObjectBox the durability boundary for reconciled message data, and
+a storage engine is expensive to change once a schema is frozen.
+
+## What we actually depend on
+
+Verified in the tree on 2026-08-06 rather than assumed:
+
+| Component | Version | How it is used |
+| --- | --- | --- |
+| `objectbox` (Dart) | 5.3.2 | Official Dart binding, used throughout `lib/database` |
+| `objectbox_flutter_libs` | 5.3.2 | Ships the native library into the app bundle |
+| `objectbox_generator` | 5.3.2 | Build-time code generation |
+| ObjectBox C native library | 5.3.2 | Loaded at runtime on Android and Windows |
+
+**There is no Rust dependency on ObjectBox.** `rust/Cargo.toml` and
+`rustpush/Cargo.toml` do not reference it, and the only occurrences in
+`rust/src` are comments describing the Dart-side transaction boundary that
+native work must not cross.
+
+This matters because published commentary warns about the community Rust
+binding `vaind/objectbox-rust`, which its owner archived on 2024-07-24. **That
+warning does not apply here.** We use the official Dart binding, which is
+maintained. Any future proposal to reach ObjectBox from Rust would be adopting
+that archived-binding risk for the first time, and should be treated as a new
+decision rather than an extension of this one.
+
+## The licence split
+
+The bindings are Apache-2.0. The **native library is proprietary** under the
+ObjectBox Binary Licence, and ObjectBox's own FAQ describes the current terms as
+temporary. So the app ships a proprietary binary inside an Apache-2.0
+application.
+
+This is not a new condition introduced by Cloud Sync V2. The app already
+depends on ObjectBox for its entire local database. What Cloud Sync V2 changes
+is the consequence of a future forced migration: reconciled CloudKit state,
+checkpoints, journals, and quarantine records would all have to move with it.
+
+## Decision
+
+Continue with ObjectBox for Cloud Sync V2, for three reasons.
+
+**Its durability contract is the one this design needs.** ObjectBox commits
+synchronously to physical storage and waits for filesystem confirmation, with
+MVCC and serialised writers. The exactly-once local projection this engine
+depends on requires writing the checkpoint and the projected rows in a single
+durable transaction, and that is available today.
+
+**Switching now would be the more dangerous change.** The alternative is
+migrating the app's whole local database, not just the sync tables, while the
+CloudKit work is mid-flight and unvalidated against a live account.
+
+**The licence exposure is bounded and already present.** A proprietary runtime
+library inside a distributed application is a redistribution question, and
+redistribution is already blocked on a separate and larger licensing item: the
+libmpv and FFmpeg transitive inventory for the Windows media stack.
+
+## Consequences
+
+- The pending redistribution review must cover the ObjectBox Binary Licence
+ explicitly, alongside libmpv and FFmpeg. It is currently unlisted.
+- The repository has a `LICENSE` but no `NOTICE` or third-party notices file.
+ One is needed before public distribution, and ObjectBox belongs in it.
+- Schema changes stay behind the freeze the Cloud Sync V2 documents already
+ require. A storage engine we cannot fork makes an unplanned migration more
+ expensive, not less.
+
+## Review triggers
+
+Revisit this decision if either occurs:
+
+1. **ObjectBox changes the binary licence terms** in a way that restricts
+ redistribution in a shipped application. Their FAQ already signals the
+ current terms are provisional.
+2. **A proposal appears to access ObjectBox from Rust.** That would mean either
+ depending on an archived community binding or writing and owning an FFI
+ layer against a proprietary library, and neither is covered by this record.
+
+## What was considered and rejected
+
+**Moving the sync tables to SQLite while leaving the app on ObjectBox.** Two
+storage engines with two durability models, and the single-transaction
+checkpoint guarantee would have to span both. The guarantee is the point, so
+splitting it is self-defeating.
+
+**Migrating the whole app now.** Correct only if the licence forced it, which it
+does not yet. Doing it during unvalidated CloudKit work would confound two large
+risks at once.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index cb860ae2e7..642f0f14fd 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -27,8 +27,8 @@ configuration behaves identically.
The current CI workflow is the source of truth for the tested build matrix:
-- Flutter 3.24.0, stable channel
-- Dart SDK supplied by that Flutter release
+- Flutter 3.44.8, stable channel
+- Dart 3.12 or newer, supplied by that Flutter release
- Rust stable for the Rust bridge and RustPush components
- Java 21 (Temurin in CI)
- Android SDK and command-line tools
@@ -85,6 +85,57 @@ with a store release and attribute every frame difference to application code.
Generated files, signing keys, `.env` values, relay registration codes, and
Apple credentials must not be committed.
+### Rust bridge regeneration guard
+
+The currently pinned `flutter_rust_bridge_codegen` can emit six duplicate SSE
+codec implementations after the Cloud Sync API types are added. The duplicate
+bodies observed in this repository are byte-identical, but Rust correctly
+rejects the duplicate trait implementations.
+
+After bridge regeneration, run the guarded postprocessor once and then verify
+the result:
+
+```powershell
+flutter_rust_bridge_codegen generate
+.\tooling\frb\guard_generated_sse_impls.ps1 -Mode Deduplicate -ExpectedRemovalCount 6
+.\tooling\frb\guard_generated_sse_impls.ps1 -Mode Verify
+```
+
+The script refuses to write if the number changes, duplicate bodies differ,
+the generated file changes concurrently, or any duplicate signature remains.
+Treat any refusal as a code-generation change that requires review. Do not
+increase the expected count merely to make generation pass. The long-term fix
+is to update or correct the generator, then remove this narrow guard.
+
+## Windows x64 build notes
+
+When an x64 build runs on a Windows ARM64 host, use the Visual Studio x64
+generator and confirm every packaged PE has machine type `0x8664`. The
+repository CMake configuration normalizes `CMAKE_SYSTEM_PROCESSOR` for this
+case so ObjectBox selects its x64 binary.
+
+Long OpenSSL build paths can exceed compiler limits. `cargokit.cmake` accepts
+`CARGOKIT_TARGET_TEMP_DIR_OVERRIDE` for an explicit short Cargo target
+directory. Keep that cache outside the source tree and do not silently reuse
+an artifact built for another architecture.
+
+The Windows install rule excludes the isolated debug-runtime cluster copied by
+the media package and explicitly bundles the target-specific
+`WebView2Loader.dll`. After packaging, require all of the following:
+
+1. every EXE and DLL reports x64 machine type;
+2. no static import points to a missing redistributable DLL;
+3. no debug C/C++ runtime is bundled or imported;
+4. all Flutter tests pass with the release directory on `PATH`;
+5. signing is applied only to the isolated release bundle, never to build
+ inputs.
+
+The local x64 verification currently also depends on an uncommitted
+`desktop_webview_auth` package-cache patch that replaces ATL `CW2A` conversion
+with `WideCharToMultiByte` and frees the WebView2 source buffer with
+`CoTaskMemFree`. Move that change to a reviewable plugin fork and pin its
+revision before claiming clean-machine reproducibility.
+
## Change and review workflow
1. Start from a clean branch based on the intended upstream branch.
diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md
index 179146afc5..0aadd42da8 100644
--- a/docs/DIAGNOSTICS.md
+++ b/docs/DIAGNOSTICS.md
@@ -49,6 +49,18 @@ An acknowledgement before persistence can create a loss window after a process
restart. Any instrumentation should use a short redacted event ID and elapsed
milliseconds, never message text or a secret.
+On Android, a notification can arrive before Flutter finishes registering its
+method-channel handler. The bounded startup handoff records only these redacted
+state markers:
+
+- `engine_buffered`: the pointer is waiting for the UI engine to become ready;
+- `engine_buffer_flush`: the ready UI engine accepted the buffered pointer;
+- `headless_handoff`: a destroyed UI engine transferred the pointer once to
+ the existing headless worker.
+
+The raw native pointer must never be logged. Repeated `engine_buffered` entries
+without a flush or handoff indicate a startup-readiness regression.
+
### Duplicates or wrong app routing
Check that only one SMS/MMS/RCS path is enabled. If both Google Messages and
@@ -78,19 +90,77 @@ format. Validation errors should be surfaced as a bounded failure and retry,
not a tight loop. Relay registration secrets belong in Keychain on iOS and must
never appear in logs or shared preferences.
+### Cloud message sync
+
+A message is synchronized only after the CloudKit API explicitly confirms the
+write. A missing or false result remains pending and should be retried. A batch
+that contains only retryable failures stops without spinning forever.
+
+When diagnosing a cross-device gap, distinguish:
+
+1. local database save;
+2. cloud upload requested;
+3. cloud write confirmed;
+4. other device pull started;
+5. pulled record saved locally.
+
+Do not treat an upload request or generated record ID as proof of cloud
+persistence. Current sync is startup, periodic, or manual rather than a
+continuous real-time replication channel.
+
## Current audit themes
-The recent field logs identified four recurring classes to keep covered by
+The recent field logs identified five recurring classes to keep covered by
tests and review:
- notification avatar data can be incomplete;
- CloudKit plist decoding can receive an unexpected byte-array shape;
- reaction events can race message persistence;
-- anisette/validation WebSockets can reset during provisioning.
+- anisette/validation WebSockets can reset during provisioning;
+- APS can lose DNS or a socket, reconnect, and then miss or mis-correlate a
+ rapid send acknowledgement. Keep DNS, TCP 5223-to-443 fallback, connection
+ budget, subscribe-before-send, and acknowledgement-ID tests together.
These are not all necessarily present in every build. When a fix is proposed,
include the before/after log counts and a focused test or reproduction.
+### Redacted Pixel field evidence
+
+The pre-fix Pixel capture contained 406 warnings and 9 errors in the affected
+archived log. The useful signals were:
+
+- contact matching emitted hundreds of per-candidate warnings that included
+ phone or email identifiers;
+- relay reminder scheduling sometimes ran before timezone initialization;
+- initial clique checks and scheduled password or CloudKit maintenance could
+ escape as unhandled asynchronous errors when the account was not in a
+ clique.
+
+The current implementation replaces candidate-level output with one redacted
+debug summary, initializes timezone data inside the notification service
+owner with a UTC fallback, and awaits or catches the initial and scheduled
+maintenance futures. A failed clique check now records the service as not
+ready instead of escaping through the Flutter zone.
+
+After the updated profile APK was installed over the existing package, the
+bounded per-process `logcat` check contained no matching fatal, exception,
+timeout, clique, APS, CloudKit, or WebSocket entry. The active on-device
+developer log was still empty, so this is a startup smoke check, not an
+endurance or delivery-pass claim.
+
+### Fullscreen video paging
+
+On mobile, video controls and the surrounding `PageView` can both claim a
+horizontal drag. The fullscreen viewer now gives an active video one
+navigation owner: a bounded raw-pointer swipe surface advances the parent
+pager, while the bottom 88 logical pixels remain reserved for playback
+controls. Inactive videos pause and do not retain their page unnecessarily.
+
+Keep the gesture unit tests and a physical-device check together. Test from
+the middle of a playing video in both directions, then verify that seeking,
+play/pause, and the bottom controls still work. Do not call the gesture fix
+device-validated until that exact check passes.
+
## Privacy and retention
Keep raw captures in a local, access-controlled folder. Delete them when the
diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md
index 55a3a96e81..bbfd358af7 100644
--- a/docs/VERIFICATION.md
+++ b/docs/VERIFICATION.md
@@ -19,22 +19,127 @@ Run the same commands locally from the repository root:
```bash
flutter pub get
-flutter test test/helpers/message_helper_test.dart
-flutter analyze
-flutter build apk --flavor alpha --profile --target-platform android-arm64
-flutter build apk --flavor alpha --debug --target-platform android-arm64
+flutter test --no-pub
+flutter analyze --no-pub
```
-The focused helper test is the required CI test today. `flutter analyze` is an
-additional local gate for source changes. Do not call a build verified when the
-machine has a different Flutter or Java major version.
+The repository still contains analyzer noise in vendored and example sources,
+so review the changed Dart paths directly and require zero analyzer errors
+there. Existing warnings or deprecations should be listed rather than hidden.
+
+On Windows, use the verified Android wrapper so a stale or incomplete APK
+cannot be mistaken for a successful build:
+
+```powershell
+.\tooling\android\build_verified_alpha.ps1 -Mode profile
+.\tooling\android\build_verified_alpha.ps1 -Mode debug
+.\tooling\android\build_verified_alpha.ps1 -Mode profile -SplitPerAbi
+```
+
+The wrapper removes the prior target artifact before building and verifies
+that the APK contains nonempty ARM64 `libflutter.so`, `libapp.so`, and
+`librust_lib_bluebubbles.so` entries. `-SplitPerAbi` additionally rejects any
+native library outside `lib/arm64-v8a/`; use it for a Pixel-specific sideload
+instead of carrying unused ARMv7 and x86 libraries. `-Mode release` is
+supported only when `android/key.properties` supplies the release signing
+configuration. The Android CI job also runs:
+
+```bash
+cd android
+./gradlew :app:testAlphaDebugUnitTest :app:compileAlphaDebugKotlin
+```
+
+Do not call a build verified when tests failed, the native-library inspection
+was skipped, or the machine used a different Flutter or Java major version.
### Environment evidence from the Windows review host
-On 2026-07-24 this host had `adb 37.0.0` and Java 8, but `flutter` and `dart`
-were not on `PATH`. Therefore no Flutter test, analyzer, or APK build was
-claimed locally. The expected next action is to install/use Flutter 3.24.0 and
-Java 21, then run the commands above or rely on the GitHub workflow.
+On 2026-07-31 this host verified the changed surfaces with Flutter 3.24.0,
+Java 21, the local Android SDK, and Rust-backed APK packaging. Record the exact
+test count, artifact byte size, SHA-256, and required native-library entries in
+the review handoff. Device installation and live delivery behavior remain
+separate gates.
+
+### Pixel profile installation record
+
+The ARM64-only Alpha profile artifact installed as an in-place update on the
+Pixel test device. The installation retained the application package and data;
+no uninstall or data clear was used.
+
+```text
+version name: 1.15.0
+version code: 20004227
+artifact bytes: 194,911,911
+SHA-256: 518E6F0ADD5811170B13F57E222B7021060BE032A0EC5BC74C9B0F2428407539
+lib/arm64-v8a/libflutter.so: 15,402,480 bytes
+lib/arm64-v8a/libapp.so: 39,388,064 bytes
+lib/arm64-v8a/librust_lib_bluebubbles.so: 36,620,096 bytes
+```
+
+The post-install inspection found no native library outside `arm64-v8a`.
+ObjectBox files, app-managed files, and existing logs remained present. The
+app started without a native-library load failure or fatal startup exception.
+That proves packaging, upgrade preservation, and startup only. It does not
+replace foreground/background delivery, locked-phone endurance, battery, or
+the explicit fullscreen-video gesture check.
+
+### Windows x64 release record
+
+The same source state produced a Windows x64 Release bundle on the ARM64
+review host. The portable bundle was isolated from the build tree before
+signing and contains:
+
+```text
+files: 167
+bundle bytes: 170,009,166
+PE files: 99
+PE machine type: 0x8664 (x64), 99 of 99
+Authenticode status: Valid, 99 of 99
+portable archive bytes: 66,633,911
+portable archive SHA-256:
+00253D214A45F4AC78DD68A4AB2B73C6FC3719008AE6819796F50ABB894A84E4
+```
+
+Static import inspection found no unresolved redistributable DLL. The eight
+names not present as physical files are Windows API-set contracts. The bundle
+includes the target-specific `WebView2Loader.dll`, the Rust library, ObjectBox,
+Flutter, and media playback libraries. Debug-only C/C++ runtime DLLs were
+excluded.
+
+All 152 Flutter tests passed with the x64 bundle on `PATH`. The changed Pixel
+logging, fullscreen-video, and Cloud Sync surfaces also passed focused
+analysis with no issue, and the generated-Rust guard reported zero duplicate
+SSE implementation groups.
+
+The bundle is signed for local testing with a certificate trusted only in the
+current user's certificate stores. That signature is not a public publisher
+identity and should not be used for distribution.
+
+One clean-build dependency remains before an upstream Windows submission is
+fully reproducible: `desktop_webview_auth` currently requires a small
+Windows patch that removes its unnecessary ATL dependency, converts the
+WebView URL with `WideCharToMultiByte`, and releases the COM-allocated source
+buffer. The verified host used that patch in its package cache. Publish it in
+a dedicated plugin fork or upstream plugin change, then pin the reviewed
+revision in `pubspec.yaml`; do not present the application repository alone as
+a clean-machine reproduction until that is done.
+
+### Windows ARM64 boundary
+
+The Windows ARM64 port has source parity for the transport, Cloud Sync,
+diagnostic, fullscreen-media, and UI fixes. Its Flutter tests pass, focused
+analysis has no errors, and a WSL ARM64 Rust check succeeds. A native Windows
+ARM64 Release artifact is not yet validated because this host's Code Integrity
+policy blocks an unsigned Rust proc-macro DLL during compilation. The locked
+`media_kit_libs_windows_video` 1.0.11 package still selects an x86_64 libmpv
+archive and an x64 ANGLE bundle. ObjectBox 5.3.2 publishes a Windows ARM64
+binary, and `printing` 5.15.0 derives its PDFium architecture from the Flutter
+target and uses a release that publishes `pdfium-win-arm64`. Those two
+dependencies are no longer known static blockers, but neither has been
+validated inside a complete native ARM64 app bundle. WSL proves source
+compatibility only; it does not produce a Windows executable. Keep the ARM64
+package experimental until the native build, PE architecture audit, dependency
+audit, signing, launch, media playback, and sync tests all pass.
## Functional delivery matrix
diff --git a/docs/WINDOWS_ARM64_NATIVE_MEDIA.md b/docs/WINDOWS_ARM64_NATIVE_MEDIA.md
new file mode 100644
index 0000000000..47bc19ebed
--- /dev/null
+++ b/docs/WINDOWS_ARM64_NATIVE_MEDIA.md
@@ -0,0 +1,232 @@
+---
+type: implementation-guide
+title: Windows ARM64 Native Media Supply Chain
+description: Auditable x64 and ARM64 libmpv and ANGLE build, verification, CI, and release gates.
+resource: packages/media_kit_libs_windows_video
+tags:
+ - windows
+ - arm64
+ - media-kit
+ - supply-chain
+ - provenance
+timestamp: 2026-08-02
+---
+
+# Windows ARM64 native media supply chain
+
+## Current status
+
+The repo-local `media_kit_libs_windows_video` fork removes the remaining
+architecture assumption from the native media package without accepting an
+opaque ARM64 binary.
+
+The scaffold is integrated through a repo-local dependency override. The
+existing x64 input remains pinned, and CMake now selects either x64 or ARM64
+explicitly and rejects every other architecture. Flutter's generated plugin
+path installs the three runtime DLLs. The runner separately installs portable
+provenance and the ANGLE license inventory under `data/native-media`.
+
+The installed evidence deliberately excludes resolver and cache paths. CMake
+fails before compilation if any required portable manifest, notice, or ANGLE
+license directory is missing.
+
+The package currently proves:
+
+* x64 and ARM64 are selected explicitly from the CMake generator;
+* libmpv assets are pinned by URL, source-build metadata, and SHA-256;
+* ANGLE is built from pinned official Google source with pinned depot_tools;
+* each accepted DLL is present in its manifest, has the committed hash, and
+ has the expected PE machine;
+* extra DLL or EXE files in the ANGLE bundle are rejected;
+* generated ANGLE builds carry resolved source revisions, GN arguments,
+ tool versions, runtime hashes, and a license-file inventory;
+* installed evidence carries runtime hashes, source pins, the libmpv archive
+ hash, the release gate, and sanitized ANGLE/license manifests without local
+ absolute paths;
+* a missing, altered, or wrong-architecture input stops CMake configuration.
+
+No native binary or downloaded archive is committed.
+
+## Dependency flow
+
+```text
+pinned Google ANGLE + pinned depot_tools
+ |
+ v
+ build_official_angle.ps1
+ |
+ v
+manifest + SHA-256 + PE machine + licenses + resolved revisions
+ |
+ v
+ prepare_native_media.ps1 <--- pinned SHA-256 libmpv archive
+ |
+ v
+ generated CMake paths + resolution record
+ |
+ v
+ Flutter Windows runner bundles three DLLs
+ libmpv-2.dll, libEGL.dll, libGLESv2.dll
+```
+
+Windows supplies the Direct3D compiler and Direct3D runtime. This fork does
+not copy an untracked `d3dcompiler_47.dll` from an unrelated archive.
+
+## Reviewed source pins
+
+The machine-readable authority is
+`packages/media_kit_libs_windows_video/provenance/native-dependencies.json`.
+
+| Input | Pin |
+| --- | --- |
+| ANGLE | `cd05752a5137b5f068c11a7a3561e7441a34df75` |
+| depot_tools | `e154c8eda5e63cbe85a765ae9d06e2b7af05139e` |
+| libmpv builder | `8ddbe5472465950b87853789f7173f2eedc5586a` |
+| mpv | `0f7858451817c5fd5ebdb74a807a7c997662c390` |
+| libmpv release | `20241021`, separate committed SHA-256 per architecture |
+
+A pin update is a supply-chain change. Review the source diff, licenses,
+release assets, expected exports, and PE machines together.
+
+## Local verification
+
+Run the non-native tests on any Windows host:
+
+```powershell
+pwsh -NoProfile -File `
+ .\packages\media_kit_libs_windows_video\tool\test_native_media_scaffold.ps1
+
+pwsh -NoProfile -File `
+ .\tooling\windows\verify_native_media_integration.ps1 `
+ -RequireEphemeralSymlink
+```
+
+The tests create synthetic PE fixtures in a unique temporary directory and
+prove the rejection paths for bad hashes, wrong architecture, missing files,
+unlisted executables, evidence tampering, and path traversal. They also parse
+every PowerShell file and confirm no binary or archive was committed.
+
+Build ANGLE from official source:
+
+```powershell
+pwsh -NoProfile -File `
+ .\packages\media_kit_libs_windows_video\tool\build_official_angle.ps1 `
+ -Architecture arm64 `
+ -WorkRoot C:\Codex\OpenBubblesReview\build-cache\angle-arm64 `
+ -OutputRoot `
+ .\packages\media_kit_libs_windows_video\windows\native\arm64\angle
+```
+
+Resolve the pinned libmpv archive and verify the combined inputs:
+
+```powershell
+pwsh -NoProfile -File `
+ .\packages\media_kit_libs_windows_video\tool\prepare_native_media.ps1 `
+ -Architecture arm64 `
+ -AngleBundleRoot `
+ .\packages\media_kit_libs_windows_video\windows\native\arm64\angle `
+ -CacheRoot C:\Codex\OpenBubblesReview\build-cache\native-arm64 `
+ -GeneratedCmakePath `
+ C:\Codex\OpenBubblesReview\build-cache\native-arm64\native-media.cmake
+```
+
+Finally, run the DLL load and export smoke test on a native machine of the
+same architecture:
+
+```powershell
+pwsh -NoProfile -File `
+ .\packages\media_kit_libs_windows_video\tool\runtime_smoke.ps1 `
+ -ResolutionPath `
+ C:\Codex\OpenBubblesReview\build-cache\native-arm64\native-media-resolution.json
+```
+
+An x64 process is not accepted as ARM64 runtime evidence, even when Windows
+emulation can launch it.
+
+Build and verify the complete Flutter runner after the official ANGLE bundle
+exists:
+
+```powershell
+pwsh -NoProfile -File `
+ .\tooling\windows\build_verified_native_media_runner.ps1 `
+ -Architecture arm64 `
+ -AngleBundleRoot `
+ C:\Codex\OpenBubblesReview\build-cache\official-angle-arm64 `
+ -FlutterRoot C:\path\to\native-arm64-flutter `
+ -Configuration release
+```
+
+The wrapper verifies that the Flutter/Dart toolchain matches the target,
+builds without cleaning or overwriting app data, inventories every bundled PE
+file, checks the three media-runtime hashes against installed evidence,
+loads libmpv through the target-architecture Dart process, checks the evidence
+and license layout, and runs the full three-DLL smoke test when the host
+architecture matches.
+
+The heavyweight source-build job in
+`.github/workflows/windows-arm64-native-media.yml` is intentionally manual. It
+builds official ANGLE and the complete runner on native x64 and ARM64 Windows
+runners, rejects an emulated PowerShell process for the three-DLL load test,
+and uploads only a short-lived, attested ANGLE engineering bundle. It does not
+upload the application or libmpv while the redistribution gate is closed.
+Ordinary pull requests still run the fast fail-closed scaffold and application
+integration checks.
+
+## Application integration
+
+`pubspec.yaml` overrides `media_kit_libs_windows_video` to the local package,
+while retaining the normal dependency declaration for upstream compatibility.
+`pubspec.lock` must resolve version `1.0.11+openbubbles.1` from that relative
+path. The integration verifier checks the pubspec, lockfile, Dart package
+configuration, ephemeral Flutter symlink, generated plugin registration,
+runner install rules, provenance gate, and absence of committed binaries.
+
+The same override serves x64 and ARM64. There is no separate ARM-only Dart
+package and no fallback to an x64 DLL on ARM64.
+
+Before distributing a runner:
+
+1. Build or consume CI-attested ANGLE bundles from the reviewed source pins.
+2. Build both Windows runners with the matching native Flutter toolchain.
+3. Confirm the runner PE machine and every bundled native DLL.
+4. Confirm `data/native-media` contains the portable evidence, dependency
+ manifest, third-party notice, libmpv extraction manifest, sanitized ANGLE
+ manifest, and complete ANGLE license tree.
+5. Run native DLL load/export smoke tests.
+6. Exercise photo, audio, and video playback, seeking, full-screen navigation,
+ attachment download, suspend/resume, and relaunch on both architectures.
+7. Compare startup, first-frame latency, seek latency, memory, handles, and
+ crash-free playback against the existing x64 release.
+
+## Release gates
+
+The following are still required before calling this production-ready:
+
+- [ ] Official ANGLE x64 source build passes on a native x64 CI runner.
+- [ ] Official ANGLE ARM64 cross-build and load test pass on a native Windows
+ ARM64 CI runner.
+- [ ] Flutter x64 and ARM64 runners compile and package the local fork.
+- [ ] Playback and endurance testing pass on both architectures.
+- [ ] Exact libmpv, FFmpeg, and linked-library source and license inventory is
+ complete.
+- [ ] Installer notices, LGPL source/relinkability obligations, and source
+ offers are reviewed.
+- [ ] Generated artifacts retain hashes, manifests, license inventory, and
+ build provenance attestation.
+
+The libmpv release metadata requests non-GPL builds, but flags alone do not
+prove the final linked binary is redistributable under the intended terms.
+Both reviewed archives contain eight entries and no license, notice, source
+revision, or relinkability inventory. The pinned builder recipe also leaves
+some transitive inputs on floating default branches or `main`, so the exact
+linked source set cannot be reconstructed from the builder commit.
+Public installer redistribution therefore remains blocked until the
+transitive inventory is complete. Local engineering validation is not a
+release approval.
+
+## Failure handling
+
+Do not delete or silently replace a cached file after a hash mismatch. Keep
+the failed artifact for diagnosis, use a new clean cache path, and identify
+whether the source pin, release asset, or local transport changed. Never fix a
+failure by weakening a hash, PE, manifest, or source-origin check.
diff --git a/docs/WINDOWS_HOST_BUILD_ENVIRONMENT.md b/docs/WINDOWS_HOST_BUILD_ENVIRONMENT.md
new file mode 100644
index 0000000000..3410e80f04
--- /dev/null
+++ b/docs/WINDOWS_HOST_BUILD_ENVIRONMENT.md
@@ -0,0 +1,245 @@
+---
+type: build_runbook
+title: OpenBubbles Windows ARM64 Host Build Environment
+description: Verified toolchain layout and the non-obvious constraints for building the Rust bridge and running the test suites for Android, Windows ARM64, and Windows x64 from one Windows-on-ARM host.
+resource: openbubbles-app
+tags: [windows, arm64, x64, android, rust, objectbox, openssl, toolchain, testing]
+timestamp: 2026-08-06
+---
+
+# OpenBubbles Windows ARM64 host build environment
+
+## Decision
+
+Drive all three architecture targets from one Windows-on-ARM host. Nothing here
+changes shipped code; it records the host constraints that otherwise present as
+unrelated failures (60+ Dart test errors, a missing proc-macro crate, an
+OpenSSL Makefile that never appears).
+
+Every item below was verified on this host on 2026-08-06. Where a constraint is
+a host-policy interaction rather than a repository problem, that is stated.
+
+## Toolchain layout
+
+| Component | Path | Notes |
+| --- | --- | --- |
+| Flutter ARM64 | `C:\Codex\Toolchains\flutter-3.44.8-arm64` | Native host; Dart 3.12.2 |
+| Flutter x64 | `C:\Codex\Toolchains\flutter-3.44.8` | Runs emulated |
+| Cargo/rustup | `C:\Codex\Toolchains\cargo`, `C:\Codex\Toolchains\rustup` | Set `CARGO_HOME` and `RUSTUP_HOME` |
+| MSVC | Visual Studio Build Tools 2022 17.14, MSVC 14.44.35207 | `Hostarm64` tools present |
+| clang | `C:\Codex\Toolchains\LLVM-22.1.8-woa64-portable\bin` | Required by `ring`; see below |
+| Android SDK/NDK | `C:\Codex\Toolchains\AndroidSdk`, NDK `26.1.10909125` | Host prebuilt is `windows-x86_64` |
+| GNU make | `C:\Codex\Toolchains\android-build-bin\make.exe` | Needs Strawberry's mingw runtime DLLs on PATH |
+| Perl modules | `C:\Codex\Toolchains\git-perl-extra` | Point `PERL5LIB` here |
+| ObjectBox ARM64 | `C:\Codex\Toolchains\objectbox-windows-arm64-v5.3.2\lib` | Matches the pinned 5.3.2 |
+
+Installed Rust targets: `aarch64-pc-windows-msvc`, `x86_64-pc-windows-msvc`,
+`aarch64-linux-android`.
+
+## Constraint: the Dart test host needs an architecture- and version-matched ObjectBox
+
+`pubspec.yaml` pins the `objectbox` trio to 5.3.2. The Dart test host loads
+`objectbox.dll` from `PATH`, so that library must match both the pinned version
+and the architecture of the Flutter SDK's own `dart.exe`.
+
+A stale 4.0.2 `objectbox.dll` still sits in this repository's
+`build\windows\x64\runner\Release`. Selecting it fails 63 Cloud Sync tests with
+`LateInitializationError: Local 'objectBox' has not been initialized`, which
+names neither the version nor the library. `tooling\cloud_sync\verify_foundation.ps1`
+now derives the host architecture from `dart.exe`, reads the pinned version from
+`pubspec.yaml`, inspects each candidate library's PE machine type and embedded
+version banner, and refuses to run on a mismatch.
+
+Verified libraries:
+
+- ARM64 host: `C:\Codex\Toolchains\objectbox-windows-arm64-v5.3.2\lib`
+- x64 host: `..\cloudsync_objectbox5_sandbox\build\windows\x64\runner\Release`
+
+## Constraint: `ring` needs clang for the ARM64 MSVC target
+
+`ring` 0.17.8 assembles GNU-syntax `.S` sources for
+`aarch64-pc-windows-msvc`. `cl.exe` cannot consume them, so `clang` must be
+reachable or the build fails with `failed to find tool "clang"`. Put the
+portable LLVM `bin` directory after the MSVC directories so `cl`, `link`, and
+`lib` still resolve to Visual Studio.
+
+Remove `CC`, `CXX`, `AR`, `LD`, `RANLIB`, `CFLAGS`, and `CXXFLAGS` before
+building an MSVC target. The `cc` crate honours them ahead of `cl.exe`, and a
+stale GNU value either fails compiler detection or produces the wrong machine
+type. Drop Strawberry's `c\bin` from `PATH` for the same reason, but keep
+`C:\Strawberry\perl\bin` because the OpenSSL build needs perl.
+
+## Constraint: `ring` 0.16.20 cannot target ARM64 and was reachable only through a dead dependency
+
+`icloud_auth` declared `rustls = "0.20.7"` and `rustls-pemfile = "1.0.1"` while
+using neither; its `reqwest` uses `default-tls`, and `rustpush` itself uses
+rustls 0.23.38. Those two unused declarations pulled `rustls` 0.20.9 and with it
+`ring` 0.16.20, which predates ARM64 Windows support and fails in `build.rs`.
+
+Removing them drops `ring` 0.16.20, `rustls` 0.20.9, `spin` 0.5.2,
+`untrusted` 0.7.1, and `webpki` 0.22.4 and changes no other resolved package.
+This edit lands in the `rustpush` submodule.
+
+## Constraint: vendored OpenSSL for Android needs a Unix-path perl, GNU make, and forward-slash compiler paths
+
+`openssl` is a vendored dependency, so the Android build compiles OpenSSL from
+source. Three separate host requirements follow, each of which fails with a
+different and unrelated-looking message:
+
+1. **Configure needs Unix-style paths.** Strawberry's MSWin32 perl reports
+ `This perl implementation doesn't produce Unix like paths` and no Makefile
+ appears. Git's msys perl (`C:\Program Files\Git\usr\bin\perl.exe`) must win
+ the `perl` lookup.
+2. **Configure needs modules Git's minimal perl omits.** Point `PERL5LIB` at
+ `C:\Codex\Toolchains\git-perl-extra`. `tooling\android\build_verified_alpha.ps1`
+ converts that to a `//localhost/C$/...` UNC path first, because OpenSSL runs
+ perl through a POSIX shell where the drive-letter colon would otherwise be
+ read as a `PERL5LIB` separator.
+3. **The generated Makefile routes `CC` through msys `sh`, which eats
+ backslashes.** A Windows-style compiler path arrives as
+ `C:CodexToolchains...clang.exe: command not found`. Set
+ `CC_aarch64_linux_android`, `AR_aarch64_linux_android`, and
+ `RANLIB_aarch64_linux_android` with forward slashes. Configure already
+ supplies `--target=aarch64-linux-android24`, so plain `clang.exe` is
+ correct; the `.cmd` wrapper is still right for the Cargo linker.
+
+`make` must also be on `PATH`, and `android-build-bin\make.exe` links
+`libintl-8.dll` from Strawberry's `c\bin`. Removing Strawberry entirely to force
+msys perl makes `make` fail with `0xc0000135` (DLL not found). Order `PATH` so
+Git's `usr\bin` precedes Strawberry rather than removing Strawberry.
+
+## Host policy: Smart App Control blocks `cargo test` on the main crate
+
+This host runs Smart App Control in enforcement mode
+(`HKLM:\SYSTEM\CurrentControlSet\Control\CI\Policy` →
+`VerifiedAndReputablePolicyState = 1`). It intermittently blocks freshly built,
+unsigned binaries with `An Application Control policy has blocked this file.
+(os error 4551)`.
+
+This affects `cargo test` on `rust_lib_bluebubbles`, which builds
+dev-dependencies and their proc-macro DLLs. A blocked proc-macro surfaces as the
+misleading `error[E0463]: can't find crate for 'rinja_derive'` even though that
+crate compiles and its DLL exists. Retrying gets different binaries through, so
+the failure moves rather than clearing.
+
+What this does and does not block:
+
+- **Not affected:** `cargo build --release`, which needs no dev-dependencies.
+ All three release libraries build cleanly.
+- **Not affected:** the standalone `cloud_sync_protector_harness`, whose 39
+ tests run on ARM64.
+- **Affected:** `cargo test` on the main crate.
+
+Do not disable Smart App Control to work around this; on Windows 11 it cannot be
+re-enabled without reinstalling. Run the main crate's Rust tests on a host or CI
+runner without the policy. This is the open item that the live-validation
+document records as "run the x64 harness without triggering Windows Application
+Control".
+
+## Defect: CargoKit silently skipped the entire Rust build on Flutter 3.44.8
+
+`rust_builder/cargokit/gradle/plugin.gradle` located Flutter's Gradle plugin by
+comparing the fully qualified class name to `"FlutterPlugin"`. That matched while
+Flutter's plugin was Groovy in the default package. Flutter 3.44.8 ships the
+Kotlin rewrite as `com.flutter.gradle.FlutterPlugin`, so the comparison failed
+and CargoKit printed `Flutter plugin not found, CargoKit plugin will not be
+applied.` and returned.
+
+The consequence is silent and serious: the Android build completes and reports
+success while packaging no `librust_lib_bluebubbles.so` at all. A split ARM64
+profile APK built before the fix contained `libflutter.so` and `libapp.so` but
+no Rust bridge; `tooling\android\build_verified_alpha.ps1` is what caught it.
+Its ABI assertion is the only thing standing between this failure mode and a
+package that installs and then cannot work.
+
+The fix accepts either the bare class name or any package-qualified
+`.FlutterPlugin`. After it, the skip message no longer appears under
+`flutter build`. Note that invoking a CargoKit Gradle task directly, rather than
+through `flutter build`, legitimately prints the same message because the
+Flutter plugin is not applied in that invocation; that is not a regression and
+is not a valid way to test this.
+
+`build_verified_alpha.ps1` also required `lib/arm64-v8a/libapp.so` for every
+mode even though a debug package carries interpreted Dart in its asset bundle
+instead. It now requires that entry only for profile and release.
+
+### Host limit: memory, not correctness
+
+Re-verifying a packaged APK after the CargoKit fix did not complete on this
+host. With the fix in place Gradle additionally drives the Android cargo build,
+including compiling OpenSSL from source in CargoKit's own target directory. This
+machine has 15.6 GB of RAM, `android/gradle.properties` requests
+`-Xmx6400M`, and with a browser and editor resident the daemon settles at
+roughly 2.6 GB while free memory falls to about 1.4 GB. It then burns CPU
+without writing build output, which is memory thrash rather than progress.
+
+Before rerunning, free memory first and stop stale daemons
+(`android\gradlew.bat --stop`), or lower `org.gradle.jvmargs`, or build on a
+larger machine or CI runner:
+
+```powershell
+pwsh -NoProfile -File .\tooling\android\build_verified_alpha.ps1 -Mode profile -SplitPerAbi `
+ -AndroidSdkRoot C:\Codex\Toolchains\AndroidSdk `
+ -CargoHome C:\Codex\Toolchains\cargo -RustupHome C:\Codex\Toolchains\rustup `
+ -ProtocPath C:\Codex\Toolchains\protoc-35.1-win64\bin\protoc.exe `
+ -PerlExecutable "C:\Program Files\Git\usr\bin\perl.exe" `
+ -PerlModuleRoot C:\Codex\Toolchains\git-perl-extra `
+ -MakeExecutable C:\Codex\Toolchains\android-build-bin\make.exe
+```
+
+## Verified state on 2026-08-06
+
+| Check | Result |
+| --- | --- |
+| Dart suite, ARM64 host | 388 tests pass |
+| Cloud Sync Dart suite, x64 host | 294 tests pass |
+| Cloud Sync focused analyzer | clean |
+| `cloud_sync_protector_harness`, ARM64 | 39 tests pass |
+| Kotlin unit tests, Alpha variant | 12 tests pass |
+| `cargo check --locked --all-targets`, ARM64 | clean |
+| Release library, `aarch64-pc-windows-msvc` | PE ARM64 |
+| Release library, `x86_64-pc-windows-msvc` | PE x64 |
+| Release library, `aarch64-linux-android` | ELF64 AArch64 |
+| `cargo test`, main crate | blocked by host policy above |
+
+No live CloudKit access, account mutation, or message send was performed.
+
+## Deliberate gate: the Windows desktop build needs an ANGLE bundle built from source
+
+`flutter build windows` fails at CMake configure on this host:
+
+```text
+Cannot find path '...\media_kit_libs_windows_video\windows\native\arm64\angle'
+because it does not exist.
+```
+
+This is the repo-local `media_kit_libs_windows_video` fork failing closed on
+purpose. Its README states that both architectures fail closed unless an ANGLE
+bundle built from pinned official Google ANGLE source is present and passes its
+manifest, SHA-256, PE-machine, provenance, and license-inventory checks, and
+that the package never accepts the unlicensed third-party ARM64 ANGLE bundle.
+
+`..\scratch\arm64-media-provenance` holds bare `angle-x64.7z`,
+`libmpv-arm64.7z`, and `libmpv-x64.7z` with no manifest, license inventory, or
+attestation beside them. **Do not stage those to satisfy the gate.** Produce a
+bundle instead:
+
+```powershell
+pwsh -NoProfile -File .\packages\media_kit_libs_windows_video\tool\build_official_angle.ps1 -Architecture arm64 -WorkRoot C:\Codex\OpenBubblesReview\build-cache\official-angle-arm64 -OutputRoot .\packages\media_kit_libs_windows_video\windows\native\arm64\angle
+```
+
+That fetches pinned depot_tools and ANGLE source and runs a Chromium-scale
+build, so treat it as a maintainer or CI step. Note also that
+`provenance/native-dependencies.json` records libmpv redistribution as
+`blocked_pending_transitive_license_inventory`, so this gate is not the only
+thing standing between the current tree and a public Windows release.
+
+This gate is unrelated to Cloud Sync. The CloudKit-relevant Windows artifact,
+the Rust bridge, builds and verifies for both architectures.
+
+## Not covered here
+
+Android release signing needs a keystore and `android/key.properties`, neither
+of which is present. The `alpha`, `beta`, and `prod` flavours use
+`signingConfigs.release`, so only debug-signed packages can be produced on this
+host.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000000..238ed63822
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,53 @@
+---
+type: index
+title: OpenBubbles Engineering Documents
+description: Progressive index for local technical designs, diagnostics, and verification plans.
+resource: openbubbles-app
+tags: [openbubbles, engineering, documentation]
+timestamp: 2026-07-31
+---
+
+# OpenBubbles engineering documents
+
+- [Development](DEVELOPMENT.md): local development and build guidance.
+- [Diagnostics](DIAGNOSTICS.md): bounded, redacted runtime logging.
+- [Verification](VERIFICATION.md): delivery, routing, and performance gates.
+- [Memory management](MEMORY_MANAGEMENT.md): bounded media and conversation
+ resource ownership.
+- [Cloud Sync V2](CLOUD_SYNC_V2.md): guarded Pixel Android and Windows
+ ARM64/x64 reconciliation architecture and rollout.
+- [Cloud Sync V2 live validation](CLOUD_SYNC_V2_LIVE_VALIDATION.md):
+ two-account test topology, safety gates, evidence, and stop conditions.
+- [Cloud Sync V2 open-source pattern review](CLOUD_SYNC_V2_OPEN_SOURCE_REVIEW.md):
+ license-aware queue, checkpoint, recovery, and media patterns worth
+ reimplementing.
+- [Cloud Sync V2 production-readiness research](CLOUD_SYNC_V2_PRODUCTION_READINESS_RESEARCH.md):
+ current platform evidence, upstream risks, bounded operating budgets, and
+ cross-platform release gates.
+- [Cloud Sync V2 developer shadow sampler](CLOUD_SYNC_V2_MANUAL_SAMPLER.md):
+ exact fail-closed composition, tripwires, report contract, and tests for the
+ first live entry point.
+- [Cloud Sync V2 semantic applier](CLOUD_SYNC_V2_SEMANTIC_APPLIER.md):
+ content-free decoder contract, transactional reconciliation boundary, and
+ remaining native and ObjectBox adapters.
+- [Cloud Sync V2 canonical mapping](CLOUD_SYNC_V2_CANONICAL_MAPPING.md): Apple
+ record field mapping, presence rules, parsing grammars, and fixture matrix.
+- [Cloud Sync V2 Android scheduling](CLOUD_SYNC_V2_ANDROID_SCHEDULING.md):
+ dormant WorkManager wake policy and its activation gates.
+- [Cloud Sync V2 native protected fetch](CLOUD_SYNC_V2_NATIVE_PROTECTED_FETCH.md):
+ narrow protected-page bridge, adoption leases, and bounded collection.
+- [Cloud Sync V2 path to production](CLOUD_SYNC_V2_PATH_TO_PRODUCTION.md):
+ dependency-ordered remaining sequence, separating code work from work that
+ needs live Apple access, hardware, or a licensing decision.
+- [Cloud Sync V2 field ownership](CLOUD_SYNC_V2_FIELD_OWNERSHIP.md): which
+ fields the server owns, which the device owns, and the merge rule each class
+ carries.
+- [Cloud Sync V2 provenance ledger](CLOUD_SYNC_V2_PROVENANCE_LEDGER.md):
+ per-idea record of borrowed protocol facts and patterns, their source licence,
+ and the file implementing each one.
+- [Decision: ObjectBox dependency posture](DECISION_OBJECTBOX_DEPENDENCY.md):
+ what is actually depended on, which licence covers which part, and the
+ triggers that would reopen the choice.
+- [Windows host build environment](WINDOWS_HOST_BUILD_ENVIRONMENT.md): verified
+ Windows-on-ARM toolchain layout and the host constraints for building the
+ Rust bridge and running the suites for all three targets.
diff --git a/lib/app/components/custom_text_editing_controllers.dart b/lib/app/components/custom_text_editing_controllers.dart
index 30934ea600..582493bd2a 100644
--- a/lib/app/components/custom_text_editing_controllers.dart
+++ b/lib/app/components/custom_text_editing_controllers.dart
@@ -17,7 +17,6 @@ import "package:languagetool_textfield/core/enums/mistake_type.dart";
import 'package:languagetool_textfield/languagetool_textfield.dart';
import "package:languagetool_textfield/utils/closed_range.dart";
import "package:languagetool_textfield/utils/keep_latest_response_service.dart";
-import 'package:tuple/tuple.dart';
import 'package:bluebubbles/utils/logger/logger.dart';
class Mentionable {
@@ -41,6 +40,70 @@ class Mentionable {
String toString() => displayName;
}
+/// Repairs the annotation partition after keyboards or IMEs report an editing
+/// delta that does not line up exactly with the previous selection.
+///
+/// Composer annotations are expected to cover the text once, without gaps or
+/// overlaps. Preserve valid formatting, clip overlaps, and fill uncovered text
+/// with a plain annotation.
+void normalizeComposerAnnotationCoverage(
+ List annotations, int textLength) {
+ if (textLength <= 0) {
+ annotations.clear();
+ return;
+ }
+
+ final candidates = annotations.where((annotation) {
+ if (annotation.range.length < 2) return false;
+ final start = annotation.range[0].clamp(0, textLength);
+ final end = annotation.range[1].clamp(0, textLength);
+ annotation.range = [start, end];
+ return end > start;
+ }).toList()
+ ..sort((a, b) {
+ final startComparison = a.range[0].compareTo(b.range[0]);
+ return startComparison != 0
+ ? startComparison
+ : a.range[1].compareTo(b.range[1]);
+ });
+
+ final repaired = [];
+ var pointer = 0;
+
+ void append(Annotation annotation) {
+ final last = repaired.lastOrNull;
+ if (last != null &&
+ last.range[1] == annotation.range[0] &&
+ last.eqUnranged(annotation)) {
+ last.range[1] = annotation.range[1];
+ } else {
+ repaired.add(annotation);
+ }
+ }
+
+ for (final annotation in candidates) {
+ final end = annotation.range[1];
+ if (end <= pointer) continue;
+
+ final start = max(pointer, annotation.range[0]);
+ if (start > pointer) {
+ append(Annotation(range: [pointer, start]));
+ }
+
+ annotation.range = [start, end];
+ append(annotation);
+ pointer = end;
+ }
+
+ if (pointer < textLength) {
+ append(Annotation(range: [pointer, textLength]));
+ }
+
+ annotations
+ ..clear()
+ ..addAll(repaired);
+}
+
class SpellCheckTextEditingController extends TextEditingController {
SpellCheckTextEditingController({super.text, this.focusNode}) {
assert(focusNode != null || !(kIsDesktop || kIsWeb));
@@ -442,14 +505,13 @@ class MentionTextEditingController extends SpellCheckTextEditingController {
@override
void notifyListeners() {
super.notifyListeners();
- Logger.info("a $lastText $text");
if (lastText != text) {
// something changed, compute deltas
// use text diff because some keyboards can bump the cursor forward into an existing space when typing a period during an autocorrect.
int caret = min(selection.baseOffset, selection.extentOffset) - min(oldTextFieldSelection.baseOffset, oldTextFieldSelection.extentOffset);
var textdiff = text.length - lastText.length;
if (caret != textdiff) {
- Logger.info("Caret diff $caret $textdiff");
+ Logger.debug("Caret diff $caret $textdiff");
}
try {
mutateRange(oldTextFieldSelection, oldTextFieldSelection.isCollapsed ? textdiff : caret);
@@ -509,30 +571,7 @@ class MentionTextEditingController extends SpellCheckTextEditingController {
bool changeLock = false;
void validateRange() {
- annotations.sort((a, b) => a.range[0].compareTo(b.range[0]));
- if (text.isEmpty) {
- assert(annotations.isEmpty);
- }
- Annotation? lastAnnotation;
- var pointer = 0;
- while (pointer < text.length) {
- var annotation = annotations.firstWhere((a) => a.range[0] == pointer);
- // do we overlap with any other annotation?
- assert(!annotations.any((a) => ((a.range[0] >= annotation.range[0] && a.range[0] < annotation.range[1]) ||
- (a.range[1] > annotation.range[0] && a.range[1] <= annotation.range[1])) && annotation != a));
- // we cannot have zero length
- assert(annotation.range[0] != annotation.range[1]);
- pointer = annotation.range[1];
-
- if (lastAnnotation?.eqUnranged(annotation) ?? false) {
- lastAnnotation!.range[1] = annotation.range[1]; // merge equal annotations
- annotations.remove(annotation);
- } else {
- lastAnnotation = annotation;
- }
- }
- assert(pointer == text.length);
- assert(lastAnnotation == annotations.lastOrNull);
+ normalizeComposerAnnotationCoverage(annotations, text.length);
}
List annotationsForRange(TextSelection range) {
@@ -545,7 +584,6 @@ class MentionTextEditingController extends SpellCheckTextEditingController {
}
void mutateRange(TextSelection collapse, int length, { Annotation? newAnnotation }) {
- Logger.info("annotations ${annotations.map((a) => a.toMap()).toList()}");
// base < offset
if (collapse.baseOffset > collapse.extentOffset) {
collapse = TextSelection(baseOffset: collapse.extentOffset, extentOffset: collapse.baseOffset);
diff --git a/lib/app/layouts/conversation_details/conversation_details.dart b/lib/app/layouts/conversation_details/conversation_details.dart
index c92ae8ca9d..824849748c 100644
--- a/lib/app/layouts/conversation_details/conversation_details.dart
+++ b/lib/app/layouts/conversation_details/conversation_details.dart
@@ -44,12 +44,13 @@ class _ConversationDetailsState extends OptimizedState with
bool showMoreParticipants = false;
late Chat chat = widget.chat;
late StreamSubscription sub;
+ late final ConversationMediaPager mediaPager;
+ bool _attachmentsLoaded = false;
+ bool _fetchingAttachments = false;
final RxList selected = [].obs;
bool get shouldShowMore => chat.participants.length > 5;
- List get clippedParticipants => showMoreParticipants
- ? chat.participants
- : chat.participants.take(5).toList();
+ List get clippedParticipants => showMoreParticipants ? chat.participants : chat.participants.take(5).toList();
List ftSupportedParticipants = [];
@@ -60,11 +61,13 @@ class _ConversationDetailsState extends OptimizedState with
cm.setActiveToDead();
cvc(widget.chat).showingOverlays = true;
+ mediaPager = ConversationMediaPager(chat: chat)..addListener(_onMediaChanged);
(() async {
var data = await chat.getConversationData();
- ftSupportedParticipants = await api.validateTargetsFacetime(state: pushService.state!.client, targets: data.participants, sender: await chat.ensureHandle());
- setState(() { });
+ ftSupportedParticipants = await api.validateTargetsFacetime(
+ state: pushService.state!.client, targets: data.participants, sender: await chat.ensureHandle());
+ setState(() {});
})();
if (!kIsWeb) {
@@ -102,6 +105,9 @@ class _ConversationDetailsState extends OptimizedState with
@override
void dispose() {
sub.cancel();
+ mediaPager
+ ..removeListener(_onMediaChanged)
+ ..dispose();
cvc(widget.chat).showingOverlays = false;
if (cm.activeChat != null) {
cm.setActiveToAlive();
@@ -110,39 +116,55 @@ class _ConversationDetailsState extends OptimizedState with
super.dispose();
}
- void fetchAttachments() {
- if (kIsWeb) return;
- chat.getAttachmentsAsync().then((value) {
- final _media = value.where((e) => !(e.message.target?.isGroupEvent ?? true)
- && !(e.message.target?.isInteractive ?? true)
- && (e.mimeStart == "image" || e.mimeStart == "video")).take(24);
- final _docs = value.where((e) => !(e.message.target?.isGroupEvent ?? true)
- && !(e.message.target?.isInteractive ?? true)
- && e.mimeStart != "image" && e.mimeStart != "video" && !(e.mimeType ?? "").contains("location")).take(24);
- final _locations = value.where((e) => (e.mimeType ?? "").contains("location")).take(10);
- for (Attachment a in _media) {
- a.message.target?.handle = chat.participants.firstWhereOrNull((e) => e.originalROWID == a.message.target?.handleId);
- }
- for (Attachment a in _docs) {
- a.message.target?.handle = chat.participants.firstWhereOrNull((e) => e.originalROWID == a.message.target?.handleId);
+ void _onMediaChanged() {
+ if (!mounted) return;
+ final updated = mediaPager.items;
+ for (final attachment in updated) {
+ _attachHandle(attachment);
+ }
+ setState(() => media = updated);
+ }
+
+ void _attachHandle(Attachment attachment) {
+ attachment.message.target?.handle = chat.participants.firstWhereOrNull(
+ (handle) => handle.originalROWID == attachment.message.target?.handleId,
+ );
+ }
+
+ Future fetchAttachments() async {
+ if (kIsWeb || _fetchingAttachments) return;
+ _fetchingAttachments = true;
+ try {
+ final overviewFuture = chat.getAttachmentOverviewAsync();
+ if (_attachmentsLoaded) {
+ await mediaPager.refreshNewer();
+ } else {
+ await mediaPager.loadInitial();
}
- for (Attachment a in _locations) {
- a.message.target?.handle = chat.participants.firstWhereOrNull((e) => e.originalROWID == a.message.target?.handleId);
+ final overview = await overviewFuture;
+ for (final attachment in [
+ ...overview.documents,
+ ...overview.locations,
+ ]) {
+ _attachHandle(attachment);
}
+ if (!mounted) return;
setState(() {
- media = _media.toList();
- docs = _docs.toList();
- locations = _locations.toList();
+ docs = overview.documents;
+ locations = overview.locations;
+ _attachmentsLoaded = true;
});
- });
+ } finally {
+ _fetchingAttachments = false;
+ }
}
void fetchLinks() {
- final query = (Database.messages.query(Message_.dateDeleted.isNull()
- & Message_.dbPayloadData.notNull()
- & Message_.balloonBundleId.contains("URLBalloonProvider"))
- ..link(Message_.chat, Chat_.id.equals(chat.id!))
- ..order(Message_.dateCreated, flags: Order.descending))
+ final query = (Database.messages.query(Message_.dateDeleted.isNull() &
+ Message_.dbPayloadData.notNull() &
+ Message_.balloonBundleId.contains("URLBalloonProvider"))
+ ..link(Message_.chat, Chat_.id.equals(chat.id!))
+ ..order(Message_.dateCreated, flags: Order.descending))
.build();
query.limit = 20;
links = query.find();
@@ -153,186 +175,180 @@ class _ConversationDetailsState extends OptimizedState with
Widget build(BuildContext context) {
return AnnotatedRegion(
value: SystemUiOverlayStyle(
- systemNavigationBarColor: ss.settings.immersiveMode.value ? Colors.transparent : context.theme.colorScheme.background, // navigation bar color
+ systemNavigationBarColor: ss.settings.immersiveMode.value
+ ? Colors.transparent
+ : context.theme.colorScheme.background, // navigation bar color
systemNavigationBarIconBrightness: context.theme.colorScheme.brightness.opposite,
statusBarColor: Colors.transparent, // status bar color
statusBarIconBrightness: context.theme.colorScheme.brightness.opposite,
),
child: Theme(
- data: context.theme.copyWith(
- // in case some components still use legacy theming
- primaryColor: context.theme.colorScheme.bubble(context, chat.isIMessage),
- colorScheme: context.theme.colorScheme.copyWith(
- primary: context.theme.colorScheme.bubble(context, chat.isIMessage),
- onPrimary: context.theme.colorScheme.onBubble(context, chat.isIMessage),
- surface: ss.settings.monetTheming.value == Monet.full
- ? null
- : (context.theme.extensions[BubbleColors] as BubbleColors?)?.receivedBubbleColor,
- onSurface: ss.settings.monetTheming.value == Monet.full
- ? null
- : (context.theme.extensions[BubbleColors] as BubbleColors?)?.onReceivedBubbleColor,
+ data: context.theme.copyWith(
+ // in case some components still use legacy theming
+ primaryColor: context.theme.colorScheme.bubble(context, chat.isIMessage),
+ colorScheme: context.theme.colorScheme.copyWith(
+ primary: context.theme.colorScheme.bubble(context, chat.isIMessage),
+ onPrimary: context.theme.colorScheme.onBubble(context, chat.isIMessage),
+ surface: ss.settings.monetTheming.value == Monet.full
+ ? null
+ : (context.theme.extensions[BubbleColors] as BubbleColors?)?.receivedBubbleColor,
+ onSurface: ss.settings.monetTheming.value == Monet.full
+ ? null
+ : (context.theme.extensions[BubbleColors] as BubbleColors?)?.onReceivedBubbleColor,
+ ),
),
- ),
- child: Obx(() {
- var actions = [
- Obx(() {
- if (selected.isNotEmpty) {
- return IconButton(
- icon: Icon(iOS ? CupertinoIcons.xmark : Icons.close, color: context.theme.colorScheme.onBackground),
- onPressed: () {
- selected.clear();
- },
- );
- } else {
- return const SizedBox.shrink();
- }
- }),
- Obx(() {
- if (selected.isNotEmpty) {
- return IconButton(
- icon: Icon(iOS ? CupertinoIcons.cloud_download : Icons.file_download, color: context.theme.colorScheme.onBackground),
- onPressed: () {
- final attachments = media.where((e) => selected.contains(e.guid!));
- for (Attachment a in attachments) {
- final file = as.getContent(a, autoDownload: false);
- if (file is PlatformFile) {
- as.saveToDisk(file);
+ child: Obx(() {
+ var actions = [
+ Obx(() {
+ if (selected.isNotEmpty) {
+ return IconButton(
+ icon: Icon(iOS ? CupertinoIcons.xmark : Icons.close, color: context.theme.colorScheme.onBackground),
+ onPressed: () {
+ selected.clear();
+ },
+ );
+ } else {
+ return const SizedBox.shrink();
+ }
+ }),
+ Obx(() {
+ if (selected.isNotEmpty) {
+ return IconButton(
+ icon: Icon(iOS ? CupertinoIcons.cloud_download : Icons.file_download,
+ color: context.theme.colorScheme.onBackground),
+ onPressed: () {
+ final attachments = media.where((e) => selected.contains(e.guid!));
+ for (Attachment a in attachments) {
+ final file = as.getContent(a, autoDownload: false);
+ if (file is PlatformFile) {
+ as.saveToDisk(file);
+ }
}
- }
- },
- );
- } else {
- return const SizedBox.shrink();
- }
- }),
- ];
-
- var slivers = [
- if (chat.isGroup)
- SliverToBoxAdapter(
- child: ChatInfo(chat: chat, ftSupportedParticipants: ftSupportedParticipants,),
- ),
- if (chat.isGroup)
- SliverList(
- delegate: SliverChildBuilderDelegate((context, index) {
- final addMember = ListTile(
- mouseCursor: MouseCursor.defer,
- title: Text("Add ${iOS ? "Member" : "people"}", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)),
- leading: Container(
- width: 40 * ss.settings.avatarScale.value,
- height: 40 * ss.settings.avatarScale.value,
- decoration: BoxDecoration(
- color: !iOS ? null : context.theme.colorScheme.properSurface,
- shape: BoxShape.circle,
- border: iOS ? null : Border.all(color: context.theme.colorScheme.primary, width: 3)
- ),
- child: Icon(
- Icons.add,
- color: context.theme.colorScheme.primary,
- size: 20
- ),
- ),
- onTap: () {
- showAddParticipant(context, chat);
},
);
+ } else {
+ return const SizedBox.shrink();
+ }
+ }),
+ ];
- if (index > clippedParticipants.length) {
- if (ss.settings.enablePrivateAPI.value && chat.isIMessage && chat.isGroup && shouldShowMore) {
- return addMember;
- } else {
- return const SizedBox.shrink();
+ var slivers = [
+ if (chat.isGroup)
+ SliverToBoxAdapter(
+ child: ChatInfo(
+ chat: chat,
+ ftSupportedParticipants: ftSupportedParticipants,
+ ),
+ ),
+ if (chat.isGroup)
+ SliverList(
+ delegate: SliverChildBuilderDelegate((context, index) {
+ final addMember = ListTile(
+ mouseCursor: MouseCursor.defer,
+ title: Text("Add ${iOS ? "Member" : "people"}",
+ style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)),
+ leading: Container(
+ width: 40 * ss.settings.avatarScale.value,
+ height: 40 * ss.settings.avatarScale.value,
+ decoration: BoxDecoration(
+ color: !iOS ? null : context.theme.colorScheme.properSurface,
+ shape: BoxShape.circle,
+ border: iOS ? null : Border.all(color: context.theme.colorScheme.primary, width: 3)),
+ child: Icon(Icons.add, color: context.theme.colorScheme.primary, size: 20),
+ ),
+ onTap: () {
+ showAddParticipant(context, chat);
+ },
+ );
+
+ if (index > clippedParticipants.length) {
+ if (ss.settings.enablePrivateAPI.value && chat.isIMessage && chat.isGroup && shouldShowMore) {
+ return addMember;
+ } else {
+ return const SizedBox.shrink();
+ }
}
- }
- if (index == clippedParticipants.length) {
- if (shouldShowMore) {
- return ListTile(
- mouseCursor: SystemMouseCursors.click,
- onTap: () {
- setState(() {
- showMoreParticipants = !showMoreParticipants;
- });
- },
- title: Text(
- showMoreParticipants ? "Show less" : "Show more",
- style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary),
- ),
- leading: Container(
- width: 40 * ss.settings.avatarScale.value,
- height: 40 * ss.settings.avatarScale.value,
- decoration: BoxDecoration(
- color: !iOS ? null : context.theme.colorScheme.properSurface,
- shape: BoxShape.circle,
- border: iOS ? null : Border.all(color: context.theme.colorScheme.primary, width: 3)
+ if (index == clippedParticipants.length) {
+ if (shouldShowMore) {
+ return ListTile(
+ mouseCursor: SystemMouseCursors.click,
+ onTap: () {
+ setState(() {
+ showMoreParticipants = !showMoreParticipants;
+ });
+ },
+ title: Text(
+ showMoreParticipants ? "Show less" : "Show more",
+ style:
+ context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary),
),
- child: Icon(
- Icons.more_horiz,
- color: context.theme.colorScheme.primary,
- size: 20
+ leading: Container(
+ width: 40 * ss.settings.avatarScale.value,
+ height: 40 * ss.settings.avatarScale.value,
+ decoration: BoxDecoration(
+ color: !iOS ? null : context.theme.colorScheme.properSurface,
+ shape: BoxShape.circle,
+ border: iOS ? null : Border.all(color: context.theme.colorScheme.primary, width: 3)),
+ child: Icon(Icons.more_horiz, color: context.theme.colorScheme.primary, size: 20),
),
- ),
- );
- } else if (ss.settings.enablePrivateAPI.value && chat.isIMessage && chat.isGroup) {
- return addMember;
- } else {
- return const SizedBox.shrink();
+ );
+ } else if (ss.settings.enablePrivateAPI.value && chat.isIMessage && chat.isGroup) {
+ return addMember;
+ } else {
+ return const SizedBox.shrink();
+ }
}
- }
- return ContactTile(
- key: Key(chat.participants[index].address),
- handle: chat.participants[index],
- chat: chat,
- canBeRemoved: chat.participants.length > 1
- && ss.settings.enablePrivateAPI.value
- && chat.isIMessage,
- facetimeSupported: ftSupportedParticipants.contains(RustPushBBUtils.bbHandleToRust(chat.participants[index])),
- );
- }, childCount: clippedParticipants.length + 2),
- ),
- if (ss.settings.enablePrivateAPI.value && chat.participants.length > 2 && backend.canLeaveChat()) // evaluate this first to make GetX happy
- SliverToBoxAdapter(
- child: Builder(
- builder: (context) {
+ return ContactTile(
+ key: Key(chat.participants[index].address),
+ handle: chat.participants[index],
+ chat: chat,
+ canBeRemoved:
+ chat.participants.length > 1 && ss.settings.enablePrivateAPI.value && chat.isIMessage,
+ facetimeSupported:
+ ftSupportedParticipants.contains(RustPushBBUtils.bbHandleToRust(chat.participants[index])),
+ );
+ }, childCount: clippedParticipants.length + 2),
+ ),
+ if (ss.settings.enablePrivateAPI.value &&
+ chat.participants.length > 2 &&
+ backend.canLeaveChat()) // evaluate this first to make GetX happy
+ SliverToBoxAdapter(
+ child: Builder(builder: (context) {
return ListTile(
mouseCursor: MouseCursor.defer,
- title: Text("Leave ${iOS ? "Chat" : "chat"}", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.error)),
+ title: Text("Leave ${iOS ? "Chat" : "chat"}",
+ style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.error)),
leading: Container(
width: 40 * ss.settings.avatarScale.value,
height: 40 * ss.settings.avatarScale.value,
decoration: BoxDecoration(
- color: !iOS ? null : context.theme.colorScheme.properSurface,
- shape: BoxShape.circle,
- border: iOS ? null : Border.all(color: context.theme.colorScheme.error, width: 3)
- ),
- child: Icon(
- Icons.error_outline,
- color: context.theme.colorScheme.error,
- size: 20
- ),
+ color: !iOS ? null : context.theme.colorScheme.properSurface,
+ shape: BoxShape.circle,
+ border: iOS ? null : Border.all(color: context.theme.colorScheme.error, width: 3)),
+ child: Icon(Icons.error_outline, color: context.theme.colorScheme.error, size: 20),
),
onTap: () async {
showDialog(
- context: context,
- builder: (BuildContext context) {
- return AlertDialog(
- backgroundColor: context.theme.colorScheme.properSurface,
- title: Text(
- "Leaving chat...",
- style: context.theme.textTheme.titleLarge,
- ),
- content: Container(
- height: 70,
- child: Center(
- child: CircularProgressIndicator(
- backgroundColor: context.theme.colorScheme.properSurface,
- valueColor: AlwaysStoppedAnimation(context.theme.colorScheme.primary),
+ context: context,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ backgroundColor: context.theme.colorScheme.properSurface,
+ title: Text(
+ "Leaving chat...",
+ style: context.theme.textTheme.titleLarge,
+ ),
+ content: Container(
+ height: 70,
+ child: Center(
+ child: CircularProgressIndicator(
+ backgroundColor: context.theme.colorScheme.properSurface,
+ valueColor: AlwaysStoppedAnimation(context.theme.colorScheme.primary),
+ ),
),
),
- ),
- );
- }
- );
+ );
+ });
final response = await backend.leaveChat(chat);
if (response) {
Get.back();
@@ -343,239 +359,243 @@ class _ConversationDetailsState extends OptimizedState with
}
},
);
- }
+ }),
),
+ const SliverPadding(
+ padding: EdgeInsets.symmetric(vertical: 10),
),
- const SliverPadding(
- padding: EdgeInsets.symmetric(vertical: 10),
- ),
- ChatOptions(chat: chat),
- if (!kIsWeb && media.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
- sliver: SliverToBoxAdapter(
- child: Text("IMAGES & VIDEOS", style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
- ),
- ),
- if (!kIsWeb && media.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.all(10),
- sliver: SliverGrid(
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: max(2, ns.width(context) ~/ 200),
- mainAxisSpacing: 10,
- crossAxisSpacing: 10
+ ChatOptions(chat: chat),
+ if (!kIsWeb && media.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
+ sliver: SliverToBoxAdapter(
+ child: Text("IMAGES & VIDEOS",
+ style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
),
- delegate: SliverChildBuilderDelegate(
- (context, int index) {
- return Obx(() => AnimatedContainer(
- duration: const Duration(milliseconds: 250),
- margin: EdgeInsets.all(selected.contains(media[index].guid) ? 10 : 0),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(20),
- ),
- clipBehavior: Clip.antiAlias,
- child: GestureDetector(
- onTap: selected.isNotEmpty ? () {
- if (selected.contains(media[index].guid)) {
- selected.remove(media[index].guid!);
- } else {
- selected.add(media[index].guid!);
- }
- } : null,
- onLongPress: () {
- if (selected.contains(media[index].guid)) {
- selected.remove(media[index].guid!);
- } else {
- selected.add(media[index].guid!);
- }
- },
- child: AbsorbPointer(
- absorbing: selected.isNotEmpty,
- child: Stack(
- alignment: Alignment.center,
- children: [
- MediaGalleryCard(
- attachment: media[index],
- ),
- if (selected.contains(media[index].guid))
- Container(
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: context.theme.colorScheme.primary
- ),
- child: Padding(
- padding: const EdgeInsets.all(5.0),
- child: Icon(
- iOS ? CupertinoIcons.check_mark : Icons.check,
- color: context.theme.colorScheme.onPrimary,
- size: 18,
+ ),
+ if (!kIsWeb && media.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.all(10),
+ sliver: SliverGrid(
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: max(2, ns.width(context) ~/ 200), mainAxisSpacing: 10, crossAxisSpacing: 10),
+ delegate: SliverChildBuilderDelegate(
+ (context, int index) {
+ if (index >= media.length - 4 && mediaPager.hasOlder && !mediaPager.loadingOlder) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ mediaPager.loadOlder();
+ });
+ }
+ return Obx(() => AnimatedContainer(
+ duration: const Duration(milliseconds: 250),
+ margin: EdgeInsets.all(selected.contains(media[index].guid) ? 10 : 0),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(20),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: GestureDetector(
+ onTap: selected.isNotEmpty
+ ? () {
+ if (selected.contains(media[index].guid)) {
+ selected.remove(media[index].guid!);
+ } else {
+ selected.add(media[index].guid!);
+ }
+ }
+ : null,
+ onLongPress: () {
+ if (selected.contains(media[index].guid)) {
+ selected.remove(media[index].guid!);
+ } else {
+ selected.add(media[index].guid!);
+ }
+ },
+ child: AbsorbPointer(
+ absorbing: selected.isNotEmpty,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ MediaGalleryCard(
+ attachment: media[index],
+ mediaPager: mediaPager,
),
- ),
+ if (selected.contains(media[index].guid))
+ Container(
+ decoration: BoxDecoration(
+ shape: BoxShape.circle, color: context.theme.colorScheme.primary),
+ child: Padding(
+ padding: const EdgeInsets.all(5.0),
+ child: Icon(
+ iOS ? CupertinoIcons.check_mark : Icons.check,
+ color: context.theme.colorScheme.onPrimary,
+ size: 18,
+ ),
+ ),
+ ),
+ ],
),
- ],
- ),
- ),
- ),
- ));
- },
- childCount: media.length,
+ ),
+ ),
+ ));
+ },
+ childCount: media.length,
+ ),
),
),
- ),
- if (!kIsWeb && links.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
- sliver: SliverToBoxAdapter(
- child: Text("LINKS", style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
+ if (!kIsWeb && links.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
+ sliver: SliverToBoxAdapter(
+ child: Text("LINKS",
+ style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
+ ),
),
- ),
- if (!kIsWeb && links.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.all(10),
- sliver: SliverToBoxAdapter(
- child: MasonryGridView.count(
- crossAxisCount: max(2, ns.width(context) ~/ 200),
- mainAxisSpacing: 10,
- crossAxisSpacing: 10,
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- itemBuilder: (context, index) {
- if (links[index].payloadData?.urlData?.firstOrNull == null) {
- return const Text("Failed to load link!");
- }
- return Material(
- color: context.theme.colorScheme.properSurface,
- borderRadius: BorderRadius.circular(20),
- clipBehavior: Clip.antiAlias,
- child: InkWell(
+ if (!kIsWeb && links.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.all(10),
+ sliver: SliverToBoxAdapter(
+ child: MasonryGridView.count(
+ crossAxisCount: max(2, ns.width(context) ~/ 200),
+ mainAxisSpacing: 10,
+ crossAxisSpacing: 10,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ itemBuilder: (context, index) {
+ if (links[index].payloadData?.urlData?.firstOrNull == null) {
+ return const Text("Failed to load link!");
+ }
+ return Material(
+ color: context.theme.colorScheme.properSurface,
borderRadius: BorderRadius.circular(20),
- onTap: () async {
- final data = links[index].payloadData!.urlData!.first;
- if ((data.url ?? data.originalUrl) == null) return;
- await launchUrl(
- Uri.parse((data.url ?? data.originalUrl)!),
- mode: LaunchMode.externalApplication
- );
- },
- child: Center(
- child: UrlPreview(
- data: links[index].payloadData!.urlData!.first,
- message: links[index],
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(20),
+ onTap: () async {
+ final data = links[index].payloadData!.urlData!.first;
+ if ((data.url ?? data.originalUrl) == null) return;
+ await launchUrl(Uri.parse((data.url ?? data.originalUrl)!),
+ mode: LaunchMode.externalApplication);
+ },
+ child: Center(
+ child: UrlPreview(
+ data: links[index].payloadData!.urlData!.first,
+ message: links[index],
+ ),
),
),
- ),
- );
- },
- itemCount: links.length,
+ );
+ },
+ itemCount: links.length,
+ ),
),
),
- ),
- if (!kIsWeb && locations.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
- sliver: SliverToBoxAdapter(
- child: Text("LOCATIONS", style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
+ if (!kIsWeb && locations.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
+ sliver: SliverToBoxAdapter(
+ child: Text("LOCATIONS",
+ style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
+ ),
),
- ),
- if (!kIsWeb && locations.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.all(10),
- sliver: SliverToBoxAdapter(
- child: MasonryGridView.count(
- crossAxisCount: max(2, ns.width(context) ~/ 200),
- mainAxisSpacing: 10,
- crossAxisSpacing: 10,
- shrinkWrap: true,
- physics: const NeverScrollableScrollPhysics(),
- itemBuilder: (context, index) {
- if (as.getContent(locations[index]) is! PlatformFile) {
- return const Text("Failed to load location!");
- }
- return Material(
- color: context.theme.colorScheme.properSurface,
- borderRadius: BorderRadius.circular(20),
- clipBehavior: Clip.antiAlias,
- child: InkWell(
+ if (!kIsWeb && locations.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.all(10),
+ sliver: SliverToBoxAdapter(
+ child: MasonryGridView.count(
+ crossAxisCount: max(2, ns.width(context) ~/ 200),
+ mainAxisSpacing: 10,
+ crossAxisSpacing: 10,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ itemBuilder: (context, index) {
+ if (as.getContent(locations[index]) is! PlatformFile) {
+ return const Text("Failed to load location!");
+ }
+ return Material(
+ color: context.theme.colorScheme.properSurface,
borderRadius: BorderRadius.circular(20),
- onTap: () async {
- final data = links[index].payloadData!.urlData!.first;
- if ((data.url ?? data.originalUrl) == null) return;
- await launchUrl(
- Uri.parse((data.url ?? data.originalUrl)!),
- mode: LaunchMode.externalApplication
- );
- },
- child: Center(
- child: UrlPreview(
- data: UrlPreviewData(
- title: "Location from ${DateFormat.yMd().format(locations[index].message.target!.dateCreated!)}",
- siteName: "Tap to open",
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(20),
+ onTap: () async {
+ final data = links[index].payloadData!.urlData!.first;
+ if ((data.url ?? data.originalUrl) == null) return;
+ await launchUrl(Uri.parse((data.url ?? data.originalUrl)!),
+ mode: LaunchMode.externalApplication);
+ },
+ child: Center(
+ child: UrlPreview(
+ data: UrlPreviewData(
+ title:
+ "Location from ${DateFormat.yMd().format(locations[index].message.target!.dateCreated!)}",
+ siteName: "Tap to open",
+ ),
+ message: locations[index].message.target!,
+ file: as.getContent(locations[index]),
),
- message: locations[index].message.target!,
- file: as.getContent(locations[index]),
),
),
- ),
- );
- },
- itemCount: locations.length,
+ );
+ },
+ itemCount: locations.length,
+ ),
),
),
- ),
- if (!kIsWeb && docs.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
- sliver: SliverToBoxAdapter(
- child: Text("OTHER FILES", style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
- ),
- ),
- if (!kIsWeb && docs.isNotEmpty)
- SliverPadding(
- padding: const EdgeInsets.all(10),
- sliver: SliverGrid(
- gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: max(2, ns.width(context) ~/ 200),
- mainAxisSpacing: 10,
- crossAxisSpacing: 10,
- childAspectRatio: 1.75,
+ if (!kIsWeb && docs.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.only(top: 20, bottom: 10, left: 15),
+ sliver: SliverToBoxAdapter(
+ child: Text("OTHER FILES",
+ style: context.theme.textTheme.bodyMedium!.copyWith(color: context.theme.colorScheme.outline)),
),
- delegate: SliverChildBuilderDelegate(
- (context, int index) {
- return MediaGalleryCard(
- attachment: docs[index],
- );
- },
- childCount: docs.length,
+ ),
+ if (!kIsWeb && docs.isNotEmpty)
+ SliverPadding(
+ padding: const EdgeInsets.all(10),
+ sliver: SliverGrid(
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: max(2, ns.width(context) ~/ 200),
+ mainAxisSpacing: 10,
+ crossAxisSpacing: 10,
+ childAspectRatio: 1.75,
+ ),
+ delegate: SliverChildBuilderDelegate(
+ (context, int index) {
+ return MediaGalleryCard(
+ attachment: docs[index],
+ );
+ },
+ childCount: docs.length,
+ ),
),
),
+ const SliverPadding(
+ padding: EdgeInsets.only(top: 50),
),
- const SliverPadding(
- padding: EdgeInsets.only(top: 50),
- ),
- ];
-
- if (!chat.isGroup) {
- return ProfileScaffold(
- bodySlivers: slivers,
- handle: chat.participants[0],
- actions: actions,
- chatOptions: ChatInfo(chat: chat, ftSupportedParticipants: ftSupportedParticipants,),
- );
- }
+ ];
- return SettingsScaffold(
- headerColor: headerColor,
- title: "Details",
- tileColor: tileColor,
- initialHeader: null,
- iosSubtitle: iosSubtitle,
- materialSubtitle: materialSubtitle,
- actions: actions,
- bodySlivers: slivers
- );
- })
- ),
+ if (!chat.isGroup) {
+ return ProfileScaffold(
+ bodySlivers: slivers,
+ handle: chat.participants[0],
+ actions: actions,
+ chatOptions: ChatInfo(
+ chat: chat,
+ ftSupportedParticipants: ftSupportedParticipants,
+ ),
+ );
+ }
+
+ return SettingsScaffold(
+ headerColor: headerColor,
+ title: "Details",
+ tileColor: tileColor,
+ initialHeader: null,
+ iosSubtitle: iosSubtitle,
+ materialSubtitle: materialSubtitle,
+ actions: actions,
+ bodySlivers: slivers);
+ })),
);
}
}
diff --git a/lib/app/layouts/conversation_details/widgets/media_gallery_card.dart b/lib/app/layouts/conversation_details/widgets/media_gallery_card.dart
index 518e651578..c06f8b9fbb 100644
--- a/lib/app/layouts/conversation_details/widgets/media_gallery_card.dart
+++ b/lib/app/layouts/conversation_details/widgets/media_gallery_card.dart
@@ -18,8 +18,9 @@ import 'package:universal_io/io.dart';
import 'package:video_player/video_player.dart';
class MediaGalleryCard extends StatefulWidget {
- MediaGalleryCard({super.key, required this.attachment});
+ MediaGalleryCard({super.key, required this.attachment, this.mediaPager});
final Attachment attachment;
+ final ConversationMediaPager? mediaPager;
@override
State createState() => _MediaGalleryCardState();
@@ -29,6 +30,8 @@ class _MediaGalleryCardState extends OptimizedState with Autom
Uint8List? videoPreview;
Duration? duration;
AttachmentDownloadController? controller;
+ bool localFileAvailable = false;
+ String? galleryThumbnailPath;
late PlatformFile attachmentFile = PlatformFile(
name: attachment.transferName!,
path: kIsWeb ? null : attachment.path,
@@ -41,20 +44,26 @@ class _MediaGalleryCardState extends OptimizedState with Autom
@override
void initState() {
super.initState();
+ localFileAvailable = attachment.bytes != null;
// check active downloader otherwise check file exists
if (attachmentDownloader.getController(attachment.guid) != null) {
controller = attachmentDownloader.getController(attachment.guid);
controller!.completeFuncs.add((file) {
+ if (!mounted) return;
setState(() {
controller = null;
attachmentFile = file;
+ localFileAvailable = file.bytes != null || file.path != null;
});
if (attachment.mimeType?.contains("video") ?? false) {
getVideoPreview(file);
+ } else if (attachment.mimeStart == 'image') {
+ getBytes();
}
});
controller!.errorFuncs.add(() {
+ if (!mounted) return;
setState(() {
controller = null;
});
@@ -70,15 +79,20 @@ class _MediaGalleryCardState extends OptimizedState with Autom
AttachmentDownloadController(
attachment: attachment,
onComplete: (file) {
+ if (!mounted) return;
setState(() {
controller = null;
attachmentFile = file;
+ localFileAvailable = file.bytes != null || file.path != null;
});
if (attachment.mimeType?.contains("video") ?? false) {
getVideoPreview(file);
+ } else if (attachment.mimeStart == 'image') {
+ getBytes();
}
},
onError: () {
+ if (!mounted) return;
setState(() {
controller = null;
});
@@ -92,8 +106,37 @@ class _MediaGalleryCardState extends OptimizedState with Autom
Future getBytes() async {
final file = File(attachment.path);
- if (await file.exists()) {
+ if (await file.exists() && mounted) {
+ if (attachment.mimeStart == 'image') {
+ final thumbnail = await as.getImageGalleryThumbnail(attachment.path);
+ if (!mounted) return;
+ setState(() {
+ attachmentFile = PlatformFile(
+ name: attachment.transferName!,
+ path: attachment.path,
+ size: attachment.totalBytes!,
+ );
+ galleryThumbnailPath = thumbnail;
+ localFileAvailable = true;
+ });
+ return;
+ }
+ if (attachment.mimeStart == 'video') {
+ setState(() {
+ attachmentFile = PlatformFile(
+ name: attachment.transferName!,
+ path: attachment.path,
+ size: attachment.totalBytes!,
+ );
+ localFileAvailable = true;
+ });
+ if (attachment.mimeStart == 'video') {
+ getVideoPreview(attachmentFile);
+ }
+ return;
+ }
final bytes = await file.readAsBytes();
+ if (!mounted) return;
setState(() {
attachmentFile = PlatformFile(
name: attachment.transferName!,
@@ -101,6 +144,7 @@ class _MediaGalleryCardState extends OptimizedState with Autom
bytes: bytes,
size: attachment.totalBytes!,
);
+ localFileAvailable = true;
});
if (attachment.mimeType?.contains("video") ?? false) {
getVideoPreview(attachmentFile);
@@ -114,10 +158,10 @@ class _MediaGalleryCardState extends OptimizedState with Autom
return;
}
+ VideoPlayerController? tempController;
try {
videoPreview = await as.getVideoThumbnail(file.path!);
- dynamic _file = File(file.path!);
- final tempController = VideoPlayerController.file(_file);
+ tempController = VideoPlayerController.file(File(file.path!));
await tempController.initialize();
duration = tempController.value.duration;
} catch (_) {
@@ -129,9 +173,11 @@ class _MediaGalleryCardState extends OptimizedState with Autom
attachment.metadata!['thumbnail_status'] = 'error';
attachment.save(null);
}
+ } finally {
+ await tempController?.dispose();
}
- setState(() {});
+ if (mounted) setState(() {});
}
@override
@@ -153,12 +199,11 @@ class _MediaGalleryCardState extends OptimizedState with Autom
height: 40,
width: 40,
child: Obx(() => CircleProgressBar(
- foregroundColor: context.theme.colorScheme.primary,
- backgroundColor: context.theme.colorScheme.outline,
- value: controller!.progress.value?.toDouble() ?? 0
- )),
+ foregroundColor: context.theme.colorScheme.primary,
+ backgroundColor: context.theme.colorScheme.outline,
+ value: controller!.progress.value?.toDouble() ?? 0)),
);
- } else if (attachmentFile.bytes == null) {
+ } else if (!localFileAvailable) {
child = InkWell(
onTap: downloadAttachment,
child: Column(
@@ -169,12 +214,8 @@ class _MediaGalleryCardState extends OptimizedState with Autom
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 5),
- Icon(ss.settings.skin.value == Skins.iOS
- ? CupertinoIcons.cloud_download
- : Icons.cloud_download,
- size: 28.0,
- color: context.theme.colorScheme.properOnSurface
- ),
+ Icon(ss.settings.skin.value == Skins.iOS ? CupertinoIcons.cloud_download : Icons.cloud_download,
+ size: 28.0, color: context.theme.colorScheme.properOnSurface),
const SizedBox(height: 5),
Text(
attachment.mimeType ?? "Unknown File Type",
@@ -185,11 +226,21 @@ class _MediaGalleryCardState extends OptimizedState with Autom
),
);
} else if (attachment.mimeType?.startsWith("image") ?? false) {
- child = ImageDisplay(attachment: attachment, image: attachmentFile.bytes!);
+ child = ImageDisplay(
+ attachment: attachment,
+ image: attachmentFile.bytes,
+ path: galleryThumbnailPath ?? (attachmentFile.bytes == null ? attachmentFile.path : null),
+ mediaPager: widget.mediaPager,
+ );
addPadding = false;
} else if ((attachment.mimeType?.startsWith("video") ?? false) && !kIsDesktop && !kIsWeb) {
if (videoPreview != null) {
- child = ImageDisplay(attachment: attachment, image: videoPreview!, duration: duration);
+ child = ImageDisplay(
+ attachment: attachment,
+ image: videoPreview!,
+ duration: duration,
+ mediaPager: widget.mediaPager,
+ );
addPadding = false;
} else {
child = const Text(
@@ -226,20 +277,28 @@ class ImageDisplay extends StatelessWidget {
const ImageDisplay({
super.key,
required this.attachment,
- required this.image,
+ this.image,
+ this.path,
this.duration,
+ this.mediaPager,
});
final Attachment attachment;
- final Uint8List image;
+ final Uint8List? image;
+ final String? path;
final Duration? duration;
+ final ConversationMediaPager? mediaPager;
@override
Widget build(BuildContext context) {
+ final columns = max(2, ns.width(context) ~/ 200);
+ final logicalWidth = ns.width(context) / columns;
+ final cacheWidth = (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil().clamp(256, 1024);
return OpenContainer(
openBuilder: (_, closeContainer) {
return FullscreenMediaHolder(
attachment: attachment,
+ mediaPager: mediaPager,
showInteractions: true,
);
},
@@ -253,27 +312,43 @@ class ImageDisplay extends StatelessWidget {
height: ns.width(context) / max(2, ns.width(context) ~/ 200),
child: Stack(
children: [
- Image.memory(
- image,
- fit: BoxFit.cover,
- alignment: Alignment.center,
- cacheWidth: ns.width(context) ~/ max(2, ns.width(context) ~/ 200) * 2,
- width: ns.width(context) / max(2, ns.width(context) ~/ 200),
- height: ns.width(context) / max(2, ns.width(context) ~/ 200),
- ),
+ if (path != null && !kIsWeb)
+ Image.file(
+ File(path!),
+ fit: BoxFit.cover,
+ alignment: Alignment.center,
+ cacheWidth: cacheWidth,
+ width: logicalWidth,
+ height: logicalWidth,
+ )
+ else
+ Image.memory(
+ image!,
+ fit: BoxFit.cover,
+ alignment: Alignment.center,
+ cacheWidth: cacheWidth,
+ width: logicalWidth,
+ height: logicalWidth,
+ ),
if ((attachment.mimeType?.contains("video") ?? false) && duration != null)
Positioned(
bottom: 10,
right: 10,
- child: Text(duration.toString().split('.').first
- .padLeft(8, "0").padLeft(9, "a")
- .replaceFirst("a00:", "").replaceFirst("a", ""),
+ child: Text(
+ duration
+ .toString()
+ .split('.')
+ .first
+ .padLeft(8, "0")
+ .padLeft(9, "a")
+ .replaceFirst("a00:", "")
+ .replaceFirst("a", ""),
style: context.theme.textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.bold),
),
),
- if (!(attachment.message.target?.isFromMe ?? true)
- && attachment.message.target?.handle != null
- && ss.settings.skin.value == Skins.iOS)
+ if (!(attachment.message.target?.isFromMe ?? true) &&
+ attachment.message.target?.handle != null &&
+ ss.settings.skin.value == Skins.iOS)
Positioned(
top: 10,
right: 10,
diff --git a/lib/app/layouts/conversation_list/pages/conversation_list.dart b/lib/app/layouts/conversation_list/pages/conversation_list.dart
index 3486e11a9b..23933aa794 100644
--- a/lib/app/layouts/conversation_list/pages/conversation_list.dart
+++ b/lib/app/layouts/conversation_list/pages/conversation_list.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:bluebubbles/app/layouts/chat_creator/chat_creator.dart';
import 'package:bluebubbles/app/layouts/conversation_list/widgets/conversation_list_fab.dart';
+import 'package:bluebubbles/app/layouts/conversation_list/widgets/apple_network_banner.dart';
import 'package:bluebubbles/app/layouts/conversation_list/widgets/footer/samsung_footer.dart';
import 'package:bluebubbles/app/layouts/conversation_list/widgets/header/material_header.dart';
import 'package:bluebubbles/app/layouts/conversation_list/widgets/header/samsung_header.dart';
@@ -41,11 +42,11 @@ class ConversationListController extends StatefulController {
bool showMaterialFABText = true;
double materialScrollStartPosition = 0;
- ConversationListController({required this.showArchivedChats, required this.showUnknownSenders, this.showDeletedMessages = false}) {
+ ConversationListController(
+ {required this.showArchivedChats, required this.showUnknownSenders, this.showDeletedMessages = false}) {
if (showDeletedMessages) {
- var subscription = (Database.chats.query()
- ..backlink(Message_.chat, Message_.dateDeleted.notNull()))
- .watch(triggerImmediately: true);
+ var subscription = (Database.chats.query()..backlink(Message_.chat, Message_.dateDeleted.notNull()))
+ .watch(triggerImmediately: true);
sub = subscription.listen((Query query) {
deletedChats.value = query.find();
@@ -121,7 +122,8 @@ class ConversationListController extends StatefulController {
}
class ConversationList extends CustomStateful {
- ConversationList({super.key, required bool showArchivedChats, required bool showUnknownSenders, showDeletedMessages = false})
+ ConversationList(
+ {super.key, required bool showArchivedChats, required bool showUnknownSenders, showDeletedMessages = false})
: super(
parentController: Get.put(
ConversationListController(
@@ -150,8 +152,8 @@ class _ConversationListState extends CustomState(
value: SystemUiOverlayStyle(
- systemNavigationBarColor: ss.settings.immersiveMode.value ? Colors.transparent : context.theme.colorScheme.background, // navigation bar color
+ systemNavigationBarColor: ss.settings.immersiveMode.value
+ ? Colors.transparent
+ : context.theme.colorScheme.background, // navigation bar color
systemNavigationBarIconBrightness: brightness,
statusBarColor: Colors.transparent, // status bar color
statusBarIconBrightness: brightness.opposite,
diff --git a/lib/app/layouts/conversation_list/widgets/apple_network_banner.dart b/lib/app/layouts/conversation_list/widgets/apple_network_banner.dart
new file mode 100644
index 0000000000..755f5419af
--- /dev/null
+++ b/lib/app/layouts/conversation_list/widgets/apple_network_banner.dart
@@ -0,0 +1,68 @@
+import 'package:bluebubbles/services/rustpush/apple_network_health.dart';
+import 'package:bluebubbles/services/rustpush/rustpush_service.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class AppleNetworkBanner extends StatelessWidget {
+ const AppleNetworkBanner({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ final health = pushService.appleNetworkHealth.value;
+ final (Color color, IconData icon, String message) = switch (health) {
+ AppleNetworkHealth.fallback => (
+ Colors.green.shade700,
+ Icons.security,
+ pushService.appleNetworkDetail.value ?? "Apple messaging connected through TCP 443 fallback",
+ ),
+ AppleNetworkHealth.reconnecting => (
+ Colors.amber.shade800,
+ Icons.sync,
+ pushService.appleNetworkDetail.value ?? "Reconnecting to Apple messaging...",
+ ),
+ AppleNetworkHealth.blocked => (
+ Colors.red.shade700,
+ Icons.wifi_off,
+ pushService.appleNetworkDetail.value ?? "This network may be blocking Apple messaging.",
+ ),
+ _ => (Colors.transparent, Icons.check, ""),
+ };
+
+ if (health != AppleNetworkHealth.fallback &&
+ health != AppleNetworkHealth.reconnecting &&
+ health != AppleNetworkHealth.blocked) {
+ return const SizedBox.shrink();
+ }
+
+ return Material(
+ color: color,
+ child: Semantics(
+ liveRegion: true,
+ label: message,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ child: Row(
+ children: [
+ Icon(icon, size: 18, color: Colors.white),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ message,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ });
+ }
+}
diff --git a/lib/app/layouts/conversation_view/pages/conversation_keyboard_policy.dart b/lib/app/layouts/conversation_view/pages/conversation_keyboard_policy.dart
new file mode 100644
index 0000000000..4887d75720
--- /dev/null
+++ b/lib/app/layouts/conversation_view/pages/conversation_keyboard_policy.dart
@@ -0,0 +1,9 @@
+bool shouldDismissKeyboardFromTranscript({
+ required bool hasActiveMessageEdit,
+ required bool keyboardOpen,
+ required bool composerHasFocus,
+ required bool subjectHasFocus,
+}) {
+ if (hasActiveMessageEdit) return false;
+ return keyboardOpen || composerHasFocus || subjectHasFocus;
+}
diff --git a/lib/app/layouts/conversation_view/pages/conversation_view.dart b/lib/app/layouts/conversation_view/pages/conversation_view.dart
index 5844f077e0..2e92fcdf82 100644
--- a/lib/app/layouts/conversation_view/pages/conversation_view.dart
+++ b/lib/app/layouts/conversation_view/pages/conversation_view.dart
@@ -1,6 +1,7 @@
import 'package:bluebubbles/app/layouts/conversation_view/widgets/header/cupertino_header.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/header/material_header.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart';
+import 'package:bluebubbles/app/layouts/conversation_view/pages/conversation_keyboard_policy.dart';
import 'package:bluebubbles/app/layouts/settings/pages/profile/posterkit.dart';
import 'package:bluebubbles/app/wrappers/gradient_background_wrapper.dart';
import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart';
@@ -61,6 +62,10 @@ class ConversationViewState extends OptimizedState {
@override
void dispose() {
controller.saveReplyToMessageState(); // P8bda
+ // Keep the controller alive until the route and its scrollable children
+ // have actually been disposed. Deleting it from a back-button callback
+ // leaves the mounted transcript using a disposed scroll controller.
+ controller.close();
super.dispose();
}
@@ -113,9 +118,9 @@ class ConversationViewState extends OptimizedState {
}
if (ls.isBubble) {
SystemNavigator.pop();
+ controller.close();
+ return;
}
- controller.close();
- if (ls.isBubble) return;
return Navigator.of(context).pop();
},
child: SafeArea(
@@ -166,9 +171,12 @@ class ConversationViewState extends OptimizedState {
Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) {
- if (controller.keyboardOpen ||
- controller.focusNode.hasFocus ||
- controller.subjectFocusNode.hasFocus) {
+ if (shouldDismissKeyboardFromTranscript(
+ hasActiveMessageEdit: controller.editing.isNotEmpty,
+ keyboardOpen: controller.keyboardOpen,
+ composerHasFocus: controller.focusNode.hasFocus,
+ subjectHasFocus: controller.subjectFocusNode.hasFocus,
+ )) {
controller.dismissKeyboard();
}
},
diff --git a/lib/app/layouts/conversation_view/pages/messages_view.dart b/lib/app/layouts/conversation_view/pages/messages_view.dart
index 9c8de0f055..f49495d39d 100644
--- a/lib/app/layouts/conversation_view/pages/messages_view.dart
+++ b/lib/app/layouts/conversation_view/pages/messages_view.dart
@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:audio_waveforms/audio_waveforms.dart' as audio;
import 'package:bluebubbles/app/components/avatars/contact_avatar_group_widget.dart';
+import 'package:bluebubbles/app/layouts/conversation_view/pages/transcript_pagination.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/message_holder.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/typing/typing_indicator.dart';
import 'package:bluebubbles/database/database.dart';
@@ -375,17 +376,26 @@ class MessagesViewState extends OptimizedState {
return;
}
- final oldLength = _messages.length;
- _messages = messageService.struct.messages;
- _messages.sort(Message.sort);
- _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) {
- final c = mwc(m);
+ final previousMessages = List.from(_messages);
+ final nextMessages = List.from(messageService.struct.messages)
+ ..sort(Message.sort);
+ final insertions = transcriptInsertions(
+ previous: previousMessages,
+ next: nextMessages,
+ identityOf: (message) => message.guid!,
+ );
+ _messages = nextMessages;
+ for (final insertion in insertions) {
+ final c = mwc(insertion.item);
c.cvController = controller;
- listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0));
- });
+ listKey.currentState?.insertItem(
+ insertion.index,
+ duration: Duration.zero,
+ );
+ }
_syncBottomMessageFocusNode();
// should only happen when a reaction is the most recent message
- if (oldLength == 0) {
+ if (previousMessages.isEmpty) {
setState(() {});
}
} finally {
diff --git a/lib/app/layouts/conversation_view/pages/transcript_pagination.dart b/lib/app/layouts/conversation_view/pages/transcript_pagination.dart
new file mode 100644
index 0000000000..9b225e1bb6
--- /dev/null
+++ b/lib/app/layouts/conversation_view/pages/transcript_pagination.dart
@@ -0,0 +1,34 @@
+class TranscriptInsertion {
+ const TranscriptInsertion({
+ required this.index,
+ required this.item,
+ });
+
+ final int index;
+ final T item;
+}
+
+/// Returns only the items newly present in [next], at their authoritative
+/// indices in that already-sorted transcript.
+///
+/// The result is ordered by ascending index so it can be applied directly to
+/// an AnimatedList. For the normal newest-first transcript case, older page
+/// items remain tail insertions and do not shift the current reverse-scroll
+/// anchor.
+List> transcriptInsertions({
+ required Iterable previous,
+ required List next,
+ required Object Function(T item) identityOf,
+}) {
+ final knownIdentities = previous.map(identityOf).toSet();
+ final insertions = >[];
+
+ for (var index = 0; index < next.length; index++) {
+ final item = next[index];
+ if (knownIdentities.add(identityOf(item))) {
+ insertions.add(TranscriptInsertion(index: index, item: item));
+ }
+ }
+
+ return List.unmodifiable(insertions);
+}
diff --git a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart
index 5b60474f8c..e98b07abaa 100644
--- a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart
+++ b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart
@@ -55,10 +55,6 @@ class CupertinoHeader extends StatelessWidget implements PreferredSizeWidget {
SystemNavigator.pop();
return;
}
- controller.close();
- if (Get.isSnackbarOpen) {
- Get.closeAllSnackbars();
- }
Navigator.of(context).pop();
}
diff --git a/lib/app/layouts/conversation_view/widgets/header/material_header.dart b/lib/app/layouts/conversation_view/widgets/header/material_header.dart
index 6c315cc8fd..3b7e510715 100644
--- a/lib/app/layouts/conversation_view/widgets/header/material_header.dart
+++ b/lib/app/layouts/conversation_view/widgets/header/material_header.dart
@@ -58,7 +58,6 @@ class MaterialHeader extends StatelessWidget implements PreferredSizeWidget {
SystemNavigator.pop();
return true;
}
- controller.close();
return false;
},
),
diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart
index 43e8b1b5f9..003e47d9da 100644
--- a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart
+++ b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart
@@ -89,9 +89,9 @@ class _AttachmentHolderState extends CustomState with AutomaticKeepAliveClientMixin {
Message get message => widget.message;
- late Metadata? metadata = MetadataHelper.mapIsNotEmpty(message.metadata) ? Metadata.fromJson(message.metadata!) : null;
+ /// The URL this preview should resolve and open.
+ String? get effectiveUrl => widget.previewUrl ?? message.url;
+
+ /// `message.metadata` is a single blob per message, so it can only ever
+ /// describe one link. Only the message's primary URL may read or write it;
+ /// the other links in the same message stay in memory rather than
+ /// overwriting each other in the database.
+ bool get isPrimaryUrl => widget.previewUrl == null || widget.previewUrl == message.url;
+
+ late Metadata? metadata = isPrimaryUrl && MetadataHelper.mapIsNotEmpty(message.metadata)
+ ? Metadata.fromJson(message.metadata!)
+ : null;
@override
void initState() {
@@ -33,13 +53,13 @@ class _LegacyUrlPreviewState extends OptimizedState with Autom
updateObx(() async {
if (metadata == null) {
try {
- metadata = await MetadataHelper.fetchMetadata(message);
+ metadata = await MetadataHelper.fetchMetadata(message, previewUrl: widget.previewUrl);
} catch (ex, stack) {
Logger.error("Failed to fetch metadata!", error: ex, trace: stack);
return;
}
// If the data isn't empty, save/update it in the DB
- if (MetadataHelper.isNotEmpty(metadata)) {
+ if (isPrimaryUrl && MetadataHelper.isNotEmpty(metadata)) {
message.updateMetadata(metadata);
}
setState(() {});
@@ -53,14 +73,21 @@ class _LegacyUrlPreviewState extends OptimizedState with Autom
@override
Widget build(BuildContext context) {
super.build(context);
- final siteText = Uri.tryParse(metadata?.url ?? message.text ?? "")?.host;
+ // Fall back to this preview's own URL rather than the whole message text.
+ // With several links in one message that text is not a parseable URI, so
+ // the host came back null and the card rendered with no site label at all.
+ final siteText = Uri.tryParse(metadata?.url ?? effectiveUrl ?? "")?.host;
return InkWell(
onTap: () async {
- if ((metadata?.url ?? message.text) != null) {
- await launchUrl(
- Uri.parse((metadata?.url ?? message.text)!),
- mode: LaunchMode.externalApplication,
- );
+ final target = metadata?.url ?? effectiveUrl;
+ if (target != null) {
+ final parsed = Uri.tryParse(target);
+ if (parsed != null) {
+ await launchUrl(
+ parsed,
+ mode: LaunchMode.externalApplication,
+ );
+ }
}
},
child: Column(
diff --git a/lib/app/layouts/conversation_view/widgets/message/message_edit_tap_surface.dart b/lib/app/layouts/conversation_view/widgets/message/message_edit_tap_surface.dart
new file mode 100644
index 0000000000..65e3d0f636
--- /dev/null
+++ b/lib/app/layouts/conversation_view/widgets/message/message_edit_tap_surface.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/widgets.dart';
+
+/// Makes the complete inline-edit bubble a focus target without disturbing an
+/// existing cursor or text selection.
+class MessageEditTapSurface extends StatelessWidget {
+ const MessageEditTapSurface({
+ super.key,
+ required this.focusNode,
+ required this.child,
+ });
+
+ final FocusNode focusNode;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ return Listener(
+ behavior: HitTestBehavior.opaque,
+ onPointerDown: (_) {
+ if (!focusNode.hasFocus && focusNode.canRequestFocus) {
+ focusNode.requestFocus();
+ }
+ },
+ child: child,
+ );
+ }
+}
diff --git a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart
index 18a2299d10..fa3201432d 100644
--- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart
+++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart
@@ -12,6 +12,7 @@ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/misc/m
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/misc/select_checkbox.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/misc/slide_to_reply.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/misc/tail_clipper.dart';
+import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/message_edit_tap_surface.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/popup/message_popup_holder.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reaction/reaction_holder.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_bubble.dart';
@@ -59,27 +60,40 @@ class MessageHolder extends CustomStateful {
CustomState createState() => _MessageHolderState();
}
-class _MessageHolderState extends CustomState {
+class _MessageHolderState
+ extends CustomState {
Message get message => controller.message;
Message? get olderMessage => controller.oldMessage;
Message? get newerMessage => controller.newMessage;
Message? get replyTo => message.threadOriginatorGuid == null
? null
: ss.settings.repliesToPrevious.value
- ? (service.struct.getPreviousReply(message.threadOriginatorGuid!, message.normalizedThreadPart, message.guid!) ?? service.struct.getThreadOriginator(message.threadOriginatorGuid!))
- : service.struct.getThreadOriginator(message.threadOriginatorGuid!);
+ ? (service.struct.getPreviousReply(message.threadOriginatorGuid!,
+ message.normalizedThreadPart, message.guid!) ??
+ service.struct.getThreadOriginator(message.threadOriginatorGuid!))
+ : service.struct.getThreadOriginator(message.threadOriginatorGuid!);
Chat get chat => widget.cvController.chat;
MessagesService get service => ms(widget.cvController.chat.guid);
- bool get canSwipeToReply => ss.settings.enablePrivateAPI.value
- && ss.isMinBigSurSync
- && chat.isIMessage
- && !widget.isReplyThread
- && !message.guid!.startsWith("temp")
- && !message.guid!.startsWith("error");
- bool get showSender => !message.isGroupEvent && (!message.sameSender(olderMessage) || (olderMessage?.isGroupEvent ?? false)
- || (olderMessage == null || !message.dateCreated!.isWithin(olderMessage!.dateCreated!, minutes: 30)));
+ bool get canSwipeToReply =>
+ ss.settings.enablePrivateAPI.value &&
+ ss.isMinBigSurSync &&
+ chat.isIMessage &&
+ !widget.isReplyThread &&
+ !message.guid!.startsWith("temp") &&
+ !message.guid!.startsWith("error");
+ bool get showSender =>
+ !message.isGroupEvent &&
+ (!message.sameSender(olderMessage) ||
+ (olderMessage?.isGroupEvent ?? false) ||
+ (olderMessage == null ||
+ !message.dateCreated!
+ .isWithin(olderMessage!.dateCreated!, minutes: 30)));
bool get showAvatar => chat.isGroup;
- bool isEditing(int part) => message.isFromMe! && widget.cvController.editing.firstWhereOrNull((e2) => e2.item1.guid == message.guid! && e2.item2.part == part) != null;
+ bool isEditing(int part) =>
+ message.isFromMe! &&
+ widget.cvController.editing.firstWhereOrNull((e2) =>
+ e2.item1.guid == message.guid! && e2.item2.part == part) !=
+ null;
List messageParts = [];
List replyOffsets = [];
@@ -92,7 +106,7 @@ class _MessageHolderState extends CustomState getBubbleColors() {
- List bubbleColors = [context.theme.colorScheme.properSurface, context.theme.colorScheme.properSurface];
+ List bubbleColors = [
+ context.theme.colorScheme.properSurface,
+ context.theme.colorScheme.properSurface
+ ];
if (ss.settings.colorfulBubbles.value && !message.isFromMe!) {
if (message.handle?.color == null) {
bubbleColors = toColorGradient(message.handle?.address);
@@ -145,32 +162,34 @@ class _MessageHolderState extends CustomState e2.item1.guid == message.guid! && e2.item2.part == part);
- if (newEdit.string.isNotEmpty && jsonEncode(newEdit.toMap()) != jsonEncode(message.attributedBody.first.toMap())) {
+ widget.cvController.stopEditing(message.guid!, part);
+ if (newEdit.string.isNotEmpty &&
+ jsonEncode(newEdit.toMap()) !=
+ jsonEncode(message.attributedBody.first.toMap())) {
showDialog(
- context: context,
- builder: (BuildContext context) {
- return AlertDialog(
- backgroundColor: context.theme.colorScheme.properSurface,
- title: Text(
- "Editing message...",
- style: context.theme.textTheme.titleLarge,
- ),
- content: Container(
- height: 70,
- child: Center(
- child: CircularProgressIndicator(
- backgroundColor: context.theme.colorScheme.properSurface,
- valueColor: AlwaysStoppedAnimation(context.theme.colorScheme.primary),
+ context: context,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ backgroundColor: context.theme.colorScheme.properSurface,
+ title: Text(
+ "Editing message...",
+ style: context.theme.textTheme.titleLarge,
+ ),
+ content: Container(
+ height: 70,
+ child: Center(
+ child: CircularProgressIndicator(
+ backgroundColor: context.theme.colorScheme.properSurface,
+ valueColor: AlwaysStoppedAnimation(
+ context.theme.colorScheme.primary),
+ ),
),
),
- ),
- );
- }
- );
+ );
+ });
final updatedMessage = await backend.edit(message, newEdit, part);
if (updatedMessage != null) {
- ah.handleUpdatedMessage(chat, updatedMessage, null);
+ await ah.handleUpdatedMessage(chat, updatedMessage, null);
}
if (ns.isTabletMode(context)) {
Get.close(1);
@@ -178,26 +197,36 @@ class _MessageHolderState extends CustomState e.associatedMessageType == "sticker");
- final reactions = message.associatedMessages.where((e) => ReactionTypes.toList().contains(e.associatedMessageType?.replaceAll("-", "")));
+ // Gesture callbacks can run while their element is being deactivated.
+ // Cache this inherited-state lookup during build instead of consulting a
+ // stale BuildContext from drag update/end/cancel callbacks.
+ final isInReplyScope = ReplyScope.maybeOf(context) != null;
+ final stickers = message.associatedMessages
+ .where((e) => e.associatedMessageType == "sticker");
+ final reactions = message.associatedMessages.where((e) =>
+ ReactionTypes.toList()
+ .contains(e.associatedMessageType?.replaceAll("-", "")));
Iterable stickersForPart(int part) {
return stickers.where((s) => (s.associatedMessagePart ?? 0) == part);
}
+
Iterable reactionsForPart(int part) {
return reactions.where((s) => (s.associatedMessagePart ?? 0) == part);
}
+
final replyTarget = replyTo;
MessageWidgetController? replyController;
if (replyTarget?.guid != null) {
replyController = getActiveMwc(replyTarget!.guid!) ?? mwc(replyTarget);
replyController.cvController ??= widget.cvController;
}
+
/// Layout tree
/// - Timestamp
/// - Stack (see code comment)
@@ -216,17 +245,27 @@ class _MessageHolderState extends CustomState Padding(
- padding: const EdgeInsets.symmetric(vertical: 2.0),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: message.isFromMe! ? CrossAxisAlignment.end : CrossAxisAlignment.start,
- children: [
- // add previous edits if needed
- if (e.isEdited)
- Padding(
- padding: showAvatar || ss.settings.alwaysShowAvatars.value
- ? EdgeInsets.only(left: 35.0 * ss.settings.avatarScale.value) : EdgeInsets.zero,
- child: Obx(() => AnimatedSize(
- duration: const Duration(milliseconds: 250),
- alignment: Alignment.bottomCenter,
- curve: controller.showEdits.value ? Curves.easeOutBack : Curves.easeOut,
- child: controller.showEdits.value ? Opacity(
- opacity: 0.75,
- child: Column(
- crossAxisAlignment: message.isFromMe! ? CrossAxisAlignment.end : CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: e.edits.map((edit) => ClipPath(
- clipper: TailClipper(
- isFromMe: message.isFromMe!,
- showTail: message.showTail(newerMessage) && e.part == controller.parts.length - 1,
- connectLower: iOS ? false : (e.part != 0 && e.part != controller.parts.length - 1)
- || (e.part == 0 && controller.parts.length > 1),
- connectUpper: iOS ? false : e.part != 0,
- ),
- child: TextBubble(
- parentController: controller,
- message: edit,
+ padding: const EdgeInsets.symmetric(vertical: 2.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: message.isFromMe!
+ ? CrossAxisAlignment.end
+ : CrossAxisAlignment.start,
+ children: [
+ // add previous edits if needed
+ if (e.isEdited)
+ Padding(
+ padding: showAvatar ||
+ ss.settings.alwaysShowAvatars.value
+ ? EdgeInsets.only(
+ left: 35.0 *
+ ss.settings.avatarScale.value)
+ : EdgeInsets.zero,
+ child: Obx(() => AnimatedSize(
+ duration:
+ const Duration(milliseconds: 250),
+ alignment: Alignment.bottomCenter,
+ curve: controller.showEdits.value
+ ? Curves.easeOutBack
+ : Curves.easeOut,
+ child: controller.showEdits.value
+ ? Opacity(
+ opacity: 0.75,
+ child: Column(
+ crossAxisAlignment: message
+ .isFromMe!
+ ? CrossAxisAlignment.end
+ : CrossAxisAlignment
+ .start,
+ mainAxisSize:
+ MainAxisSize.min,
+ children: e.edits
+ .map((edit) => ClipPath(
+ clipper:
+ TailClipper(
+ isFromMe: message
+ .isFromMe!,
+ showTail: message
+ .showTail(
+ newerMessage) &&
+ e.part ==
+ controller
+ .parts
+ .length -
+ 1,
+ connectLower: iOS
+ ? false
+ : (e.part !=
+ 0 &&
+ e.part !=
+ controller.parts.length -
+ 1) ||
+ (e.part ==
+ 0 &&
+ controller.parts.length >
+ 1),
+ connectUpper: iOS
+ ? false
+ : e.part != 0,
+ ),
+ child: TextBubble(
+ parentController:
+ controller,
+ message: edit,
+ ),
+ ))
+ .toList(),
+ ),
+ )
+ : Container(
+ height: 0,
+ constraints: BoxConstraints(
+ maxWidth: ns.width(
+ context) *
+ MessageWidgetController
+ .maxBubbleSizeFactor -
+ 30)),
+ )),
+ ),
+ if (iOS &&
+ index == 0 &&
+ !widget.isReplyThread &&
+ olderMessage != null &&
+ message.threadOriginatorGuid != null &&
+ message.showUpperMessage(olderMessage!) &&
+ replyTarget != null &&
+ replyController != null)
+ Padding(
+ padding: EdgeInsets.only(
+ left: (showAvatar ||
+ ss.settings.alwaysShowAvatars
+ .value) &&
+ replyTarget.isFromMe!
+ ? 35
+ : 0),
+ child: DecoratedBox(
+ decoration:
+ replyTarget.isFromMe == message.isFromMe
+ ? ReplyLineDecoration(
+ isFromMe: message.isFromMe!,
+ color: context.theme.colorScheme
+ .properSurface,
+ connectUpper: false,
+ connectLower: true,
+ context: context,
+ )
+ : const BoxDecoration(),
+ child: Container(
+ width: double.infinity,
+ alignment: replyTarget.isFromMe!
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
+ child: ReplyBubble(
+ parentController: replyController,
+ part: replyTarget.guid! ==
+ message.threadOriginatorGuid
+ ? message.normalizedThreadPart
+ : 0,
+ showAvatar: (chat.isGroup ||
+ ss.settings.alwaysShowAvatars
+ .value ||
+ !iOS) &&
+ !replyTarget.isFromMe!,
+ cvController: widget.cvController,
),
- )).toList(),
- ),
- ) : Container(
- height: 0,
- constraints: BoxConstraints(
- maxWidth: ns.width(context) * MessageWidgetController.maxBubbleSizeFactor - 30
- )),
- )),
- ),
- if (iOS && index == 0 && !widget.isReplyThread
- && olderMessage != null
- && message.threadOriginatorGuid != null
- && message.showUpperMessage(olderMessage!)
- && replyTarget != null
- && replyController != null)
- Padding(
- padding: EdgeInsets.only(left: (showAvatar || ss.settings.alwaysShowAvatars.value) && replyTarget.isFromMe! ? 35 : 0),
- child: DecoratedBox(
- decoration: replyTarget.isFromMe == message.isFromMe ? ReplyLineDecoration(
- isFromMe: message.isFromMe!,
- color: context.theme.colorScheme.properSurface,
- connectUpper: false,
- connectLower: true,
- context: context,
- ) : const BoxDecoration(),
- child: Container(
- width: double.infinity,
- alignment: replyTarget.isFromMe! ? Alignment.centerRight : Alignment.centerLeft,
- child: ReplyBubble(
- parentController: replyController,
- part: replyTarget.guid! == message.threadOriginatorGuid ? message.normalizedThreadPart : 0,
- showAvatar: (chat.isGroup || ss.settings.alwaysShowAvatars.value || !iOS) && !replyTarget.isFromMe!,
- cvController: widget.cvController,
+ ),
),
),
- ),
- ),
- // show sender, if needed
- if (chat.isGroup
- && !message.isFromMe!
- && showSender
- && e.part == (messageParts.firstWhereOrNull((e) => !e.isUnsent)?.part))
- Padding(
- padding: showAvatar || ss.settings.alwaysShowAvatars.value
- ? EdgeInsets.only(left: 35.0 * ss.settings.avatarScale.value) : EdgeInsets.zero,
- child: MessageSender(olderMessage: olderMessage, message: message),
- ),
- // add a box to account for height of reactions
- if ((messageParts.length == 1 && reactions.isNotEmpty) || reactionsForPart(e.part).isNotEmpty)
- const SizedBox(height: 12.5),
- if (!iOS && index == 0 && !widget.isReplyThread
- && olderMessage != null
- && message.threadOriginatorGuid != null
- && replyTarget != null
- && replyController != null)
- Padding(
- padding: showAvatar || ss.settings.alwaysShowAvatars.value
- ? const EdgeInsets.only(left: 45.0, right: 10) : const EdgeInsets.symmetric(horizontal: 10),
- child: DecoratedBox(
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(25),
- border: Border.fromBorderSide(BorderSide(color: context.theme.colorScheme.properSurface)),
- ),
- child: ReplyBubble(
- parentController: replyController,
- part: replyTarget.guid! == message.threadOriginatorGuid ? message.normalizedThreadPart : 0,
- showAvatar: (chat.isGroup || ss.settings.alwaysShowAvatars.value || !iOS)
- && !replyTarget.isFromMe!,
- cvController: widget.cvController,
+ // show sender, if needed
+ if (chat.isGroup &&
+ !message.isFromMe! &&
+ showSender &&
+ e.part ==
+ (messageParts
+ .firstWhereOrNull((e) => !e.isUnsent)
+ ?.part))
+ Padding(
+ padding: showAvatar ||
+ ss.settings.alwaysShowAvatars.value
+ ? EdgeInsets.only(
+ left: 35.0 *
+ ss.settings.avatarScale.value)
+ : EdgeInsets.zero,
+ child: MessageSender(
+ olderMessage: olderMessage,
+ message: message),
),
- ),
- ),
- Stack(
- alignment: Alignment.bottomLeft,
- children: [
- // avatar, if needed
- if (message.showTail(newerMessage)
- && e.part == controller.parts.length - 1
- && (showAvatar || ss.settings.alwaysShowAvatars.value)
- && !message.isFromMe! && !message.isGroupEvent)
+ // add a box to account for height of reactions
+ if ((messageParts.length == 1 &&
+ reactions.isNotEmpty) ||
+ reactionsForPart(e.part).isNotEmpty)
+ const SizedBox(height: 12.5),
+ if (!iOS &&
+ index == 0 &&
+ !widget.isReplyThread &&
+ olderMessage != null &&
+ message.threadOriginatorGuid != null &&
+ replyTarget != null &&
+ replyController != null)
Padding(
- padding: const EdgeInsets.only(left: 5.0),
- child: ContactAvatarWidget(
- handle: message.handle,
- size: iOS ? 30 : 35,
- fontSize: context.theme.textTheme.bodyLarge!.fontSize!,
- borderThickness: 0.1,
+ padding: showAvatar ||
+ ss.settings.alwaysShowAvatars.value
+ ? const EdgeInsets.only(
+ left: 45.0, right: 10)
+ : const EdgeInsets.symmetric(
+ horizontal: 10),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(25),
+ border: Border.fromBorderSide(BorderSide(
+ color: context.theme.colorScheme
+ .properSurface)),
+ ),
+ child: ReplyBubble(
+ parentController: replyController,
+ part: replyTarget.guid! ==
+ message.threadOriginatorGuid
+ ? message.normalizedThreadPart
+ : 0,
+ showAvatar: (chat.isGroup ||
+ ss.settings.alwaysShowAvatars
+ .value ||
+ !iOS) &&
+ !replyTarget.isFromMe!,
+ cvController: widget.cvController,
+ ),
),
),
- Padding(
- padding: (showAvatar || ss.settings.alwaysShowAvatars.value) && !(message.isGroupEvent || e.isUnsent)
- ? EdgeInsets.only(left: 35.0 * ss.settings.avatarScale.value) : EdgeInsets.zero,
- child: DecoratedBox(
- decoration: iOS && !widget.isReplyThread && ((index == 0 && message.threadOriginatorGuid != null && olderMessage != null)
- || (index == messageParts.length - 1 && service.struct.threads(message.guid!, index).isNotEmpty && newerMessage != null))
- ? ReplyLineDecoration(
- isFromMe: message.isFromMe!,
- color: context.theme.colorScheme.properSurface,
- connectUpper: message.connectToUpper(),
- connectLower: newerMessage != null && message.connectToLower(newerMessage!),
- context: context,
- ) : const BoxDecoration(),
- child: Obx(() => GestureDetector(
- behavior: HitTestBehavior.translucent,
- onTap: widget.cvController.inSelectMode.value ? () {
- if (widget.cvController.isSelected(message.guid!)) {
- widget.cvController.selected.remove(message);
- } else {
- widget.cvController.selected.add(message);
- }
- } : message.threadOriginatorGuid != null && !widget.isReplyThread ? () {
- showReplyThread(context, message, e, service, widget.cvController);
- } : kIsDesktop || kIsWeb || iOS || material ? () => tapped.value = !tapped.value : null,
- child: IgnorePointer(
- ignoring: widget.cvController.inSelectMode.value,
- child: Container(
- width: double.infinity,
- alignment: message.isFromMe! ? Alignment.centerRight : Alignment.centerLeft,
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- // show group event
- if (message.isGroupEvent || e.isUnsent)
- ChatEvent(
- part: e,
- message: message,
- ),
- if (samsung)
- Padding(
- padding: (messageParts.length == 1 && reactions.isNotEmpty) || reactionsForPart(e.part).isNotEmpty
- ? EdgeInsets.only(left: message.isFromMe! ? 0 : 10, right: message.isFromMe! ? 20 : 0)
- : const EdgeInsets.only(right: 10),
- child: MessageTimestamp(controller: controller, cvController: widget.cvController),
- ),
- // otherwise show content
- if (!message.isGroupEvent && !e.isUnsent)
- Column(
- crossAxisAlignment: message.isFromMe! ? CrossAxisAlignment.end : CrossAxisAlignment.start,
- children: [
- // interactive messages may have subjects, so render them here
- // also render the subject for attachments that may have not rendered already
- if ((message.hasApplePayloadData || message.isLegacyUrlPreview || message.isInteractive
- || (e.part == 0 && isNullOrEmpty(e.text) && e.attachments.isNotEmpty))
- && !isNullOrEmpty(message.subject))
- Padding(
- padding: const EdgeInsets.only(bottom: 2.0),
- child: ClipPath(
- clipper: TailClipper(
- isFromMe: message.isFromMe!,
- showTail: false,
- connectLower: iOS ? false : (e.part != 0 && e.part != controller.parts.length - 1)
- || (e.part == 0 && controller.parts.length > 1),
- connectUpper: iOS ? false : e.part != 0,
- ),
- child: TextBubble(
- parentController: controller,
- message: MessagePart(
- subject: e.subject,
- part: e.part,
- ),
- ),
- ),
- ),
- Stack(
- alignment: Alignment.center,
- fit: StackFit.loose,
- clipBehavior: Clip.none,
- children: [
- // actual message content
- BubbleEffects(
+ Stack(
+ alignment: Alignment.bottomLeft,
+ children: [
+ // avatar, if needed
+ if (message.showTail(newerMessage) &&
+ e.part == controller.parts.length - 1 &&
+ (showAvatar ||
+ ss.settings.alwaysShowAvatars
+ .value) &&
+ !message.isFromMe! &&
+ !message.isGroupEvent)
+ Padding(
+ padding: const EdgeInsets.only(left: 5.0),
+ child: ContactAvatarWidget(
+ handle: message.handle,
+ size: iOS ? 30 : 35,
+ fontSize: context.theme.textTheme
+ .bodyLarge!.fontSize!,
+ borderThickness: 0.1,
+ ),
+ ),
+ Padding(
+ padding: (showAvatar ||
+ ss.settings.alwaysShowAvatars
+ .value) &&
+ !(message.isGroupEvent ||
+ e.isUnsent)
+ ? EdgeInsets.only(
+ left: 35.0 *
+ ss.settings.avatarScale.value)
+ : EdgeInsets.zero,
+ child: DecoratedBox(
+ decoration: iOS &&
+ !widget.isReplyThread &&
+ ((index == 0 &&
+ message.threadOriginatorGuid !=
+ null &&
+ olderMessage != null) ||
+ (index ==
+ messageParts
+ .length -
+ 1 &&
+ service.struct
+ .threads(
+ message.guid!,
+ index)
+ .isNotEmpty &&
+ newerMessage != null))
+ ? ReplyLineDecoration(
+ isFromMe: message.isFromMe!,
+ color: context.theme.colorScheme
+ .properSurface,
+ connectUpper:
+ message.connectToUpper(),
+ connectLower:
+ newerMessage != null &&
+ message.connectToLower(
+ newerMessage!),
+ context: context,
+ )
+ : const BoxDecoration(),
+ child: Obx(
+ () => GestureDetector(
+ behavior:
+ HitTestBehavior.translucent,
+ onTap: widget.cvController
+ .inSelectMode.value
+ ? () {
+ if (widget.cvController
+ .isSelected(
+ message.guid!)) {
+ widget
+ .cvController.selected
+ .remove(message);
+ } else {
+ widget
+ .cvController.selected
+ .add(message);
+ }
+ }
+ : message.threadOriginatorGuid !=
+ null &&
+ !widget.isReplyThread
+ ? () {
+ showReplyThread(
+ context,
+ message,
+ e,
+ service,
+ widget
+ .cvController);
+ }
+ : kIsDesktop ||
+ kIsWeb ||
+ iOS ||
+ material
+ ? () => tapped.value =
+ !tapped.value
+ : null,
+ child: IgnorePointer(
+ ignoring: widget.cvController
+ .inSelectMode.value,
+ child: Container(
+ width: double.infinity,
+ alignment: message.isFromMe!
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
+ child: Row(
+ mainAxisSize:
+ MainAxisSize.min,
+ children: [
+ // show group event
+ if (message.isGroupEvent ||
+ e.isUnsent)
+ ChatEvent(
+ part: e,
message: message,
- part: index,
- globalKey: keys.length > index ? keys[index] : null,
- showTail: message.showTail(newerMessage) && e.part == controller.parts.length - 1,
- child: MessagePopupHolder(
- key: keys.length > index ? keys[index] : null,
- controller: controller,
- cvController: widget.cvController,
- part: e,
- isEditing: isEditing(e.part),
- child: GestureDetector(
- behavior: HitTestBehavior.deferToChild,
- onHorizontalDragUpdate: !canSwipeToReply || isEditing(e.part) ? null : (details) {
- if (ReplyScope.maybeOf(context) != null) return;
- final offset = replyOffsets[index];
- offset.value += details.delta.dx * 0.5;
- if (message.isFromMe!) {
- offset.value = offset.value.clamp(-double.infinity, 0);
- } else {
- offset.value = offset.value.clamp(0, double.infinity);
- }
- if (!gaveHapticFeedback && offset.value.abs() >= SlideToReply.replyThreshold) {
- HapticFeedback.lightImpact();
- gaveHapticFeedback = true;
- } else if (offset.value.abs() < SlideToReply.replyThreshold) {
- gaveHapticFeedback = false;
- }
- },
- onHorizontalDragEnd: !canSwipeToReply || isEditing(e.part) ? null : (details) {
- if (ReplyScope.maybeOf(context) != null) return;
- final offset = replyOffsets[index];
- if (offset.value.abs() >= SlideToReply.replyThreshold) {
- widget.cvController.replyToMessage = Tuple2(message, index);
- }
- offset.value = 0;
- },
- onHorizontalDragCancel: !canSwipeToReply || isEditing(e.part) ? null : () {
- if (ReplyScope.maybeOf(context) != null) return;
- replyOffsets[index].value = 0;
- },
- child: Builder(builder: (context) {
- var child = ClipPath(
- clipper: TailClipper(
- isFromMe: message.isFromMe!,
- showTail: message.showTail(newerMessage) && e.part == controller.parts.length - 1,
- connectLower: iOS ? false : (e.part != 0 && e.part != controller.parts.length - 1)
- || (e.part == 0 && controller.parts.length > 1),
- connectUpper: iOS ? false : e.part != 0,
+ ),
+ if (samsung)
+ Padding(
+ padding: (messageParts
+ .length ==
+ 1 &&
+ reactions
+ .isNotEmpty) ||
+ reactionsForPart(
+ e.part)
+ .isNotEmpty
+ ? EdgeInsets.only(
+ left: message
+ .isFromMe!
+ ? 0
+ : 10,
+ right: message
+ .isFromMe!
+ ? 20
+ : 0)
+ : const EdgeInsets
+ .only(
+ right: 10),
+ child: MessageTimestamp(
+ controller:
+ controller,
+ cvController: widget
+ .cvController),
+ ),
+ // otherwise show content
+ if (!message.isGroupEvent &&
+ !e.isUnsent)
+ Column(
+ crossAxisAlignment: message
+ .isFromMe!
+ ? CrossAxisAlignment
+ .end
+ : CrossAxisAlignment
+ .start,
+ children: [
+ // interactive messages may have subjects, so render them here
+ // also render the subject for attachments that may have not rendered already
+ if ((message.hasApplePayloadData ||
+ message
+ .isLegacyUrlPreview ||
+ message
+ .isInteractive ||
+ (e.part ==
+ 0 &&
+ isNullOrEmpty(e
+ .text) &&
+ e.attachments
+ .isNotEmpty)) &&
+ !isNullOrEmpty(
+ message
+ .subject))
+ Padding(
+ padding:
+ const EdgeInsets
+ .only(
+ bottom:
+ 2.0),
+ child: ClipPath(
+ clipper:
+ TailClipper(
+ isFromMe: message
+ .isFromMe!,
+ showTail:
+ false,
+ connectLower: iOS
+ ? false
+ : (e.part != 0 && e.part != controller.parts.length - 1) ||
+ (e.part == 0 &&
+ controller.parts.length > 1),
+ connectUpper: iOS
+ ? false
+ : e.part !=
+ 0,
+ ),
+ child:
+ TextBubble(
+ parentController:
+ controller,
+ message:
+ MessagePart(
+ subject: e
+ .subject,
+ part:
+ e.part,
+ ),
+ ),
),
- child: Stack(
- alignment: Alignment.centerRight,
- children: [
- message.hasApplePayloadData
- || message.isLegacyUrlPreview
- || message.isInteractive ? InteractiveHolder(
- parentController: controller,
- message: e,
- ) : e.attachments.isEmpty
- && (e.text != null || e.subject != null) ? TextBubble(
- parentController: controller,
- message: e,
- ) : e.attachments.isNotEmpty ? AttachmentHolder(
- parentController: controller,
- message: e,
- ) : const SizedBox.shrink(),
- if (message.isFromMe!)
- Obx(() {
- final editStuff = widget.cvController.editing.firstWhereOrNull((e2) => e2.item1.guid == message.guid! && e2.item2.part == e.part);
- return AnimatedSize(
- duration: const Duration(milliseconds: 250),
- alignment: Alignment.centerRight,
- curve: Curves.easeOutBack,
- child: editStuff == null ? const SizedBox.shrink() : Material(
- color: Colors.transparent,
- child: Container(
- decoration: BoxDecoration(
- color: !message.isBigEmoji
- ? context.theme.colorScheme.primary
- : context.theme.colorScheme.background,
- ),
- constraints: BoxConstraints(
- maxWidth: ns.width(context) * MessageWidgetController.maxBubbleSizeFactor - 40,
- minHeight: 40,
- ),
- padding: const EdgeInsets.only(right: 10).add(const EdgeInsets.all(5)),
- child: Focus(
- focusNode: FocusNode(),
- onKeyEvent: (_, ev) {
- if (ev is! KeyDownEvent) {
- if (ev.logicalKey == LogicalKeyboardKey.tab) { // Absorb tab
- return KeyEventResult.skipRemainingHandlers;
- }
- return KeyEventResult.ignored;
- }
- if (ev.logicalKey == LogicalKeyboardKey.enter && !HardwareKeyboard.instance.isShiftPressed) {
- completeEdit(editStuff.item3.getFinalAnnotations(), e.part);
- return KeyEventResult.handled;
- }
- if (ev.logicalKey == LogicalKeyboardKey.escape) {
- widget.cvController.editing.removeWhere((e2) => e2.item1.guid == message.guid! && e2.item2.part == e.part);
- if (widget.cvController.editing.isEmpty) {
- widget.cvController.lastFocusedNode.requestFocus();
- } else {
- widget.cvController.editing.last.item3.focusNode?.requestFocus();
- }
- return KeyEventResult.handled;
- }
- if (ev.logicalKey == LogicalKeyboardKey.tab) { // Absorb tab
- return KeyEventResult.skipRemainingHandlers;
- }
- return KeyEventResult.ignored;
- },
- child: CallbackShortcuts(
- bindings: editStuff.item3.getShortcuts(),
- child: TextField(
- textCapitalization: TextCapitalization.sentences,
- autocorrect: true,
- controller: editStuff.item3,
- focusNode: editStuff.item3.focusNode,
- scrollPhysics: const CustomBouncingScrollPhysics(),
- style: context.theme.extension()!.bubbleText.apply(
- fontSizeFactor: message.isBigEmoji ? 3 : 1,
- color: context.theme.colorScheme.onPrimary,
- ),
- keyboardType: TextInputType.multiline,
- maxLines: 14,
- minLines: 1,
- contextMenuBuilder: editStuff.item3.getContextMenuBuilder(),
- autofocus: !(kIsDesktop || kIsWeb),
- enableIMEPersonalizedLearning: !ss.settings.incognitoKeyboard.value,
- textInputAction: ss.settings.sendWithReturn.value && !kIsWeb && !kIsDesktop
- ? TextInputAction.send
- : TextInputAction.newline,
- cursorColor: context.theme.colorScheme.onPrimary,
- cursorHeight: context.theme.extension()!.bubbleText.fontSize! * 1.25 * (message.isBigEmoji ? 3 : 1),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(iOS ? 10 : 12.5),
- isDense: true,
- isCollapsed: true,
- hintText: "Edited Message",
- enabledBorder: OutlineInputBorder(
- borderSide: BorderSide(
- color: context.theme.colorScheme.onPrimary,
- width: 1.5
- ),
- borderRadius: BorderRadius.circular(20),
- ),
- border: OutlineInputBorder(
- borderSide: BorderSide(
- color: context.theme.colorScheme.onPrimary,
- width: 1.5
- ),
- borderRadius: BorderRadius.circular(20),
- ),
- focusedBorder: OutlineInputBorder(
- borderSide: BorderSide(
- color: context.theme.colorScheme.onPrimary,
- width: 1.5
- ),
- borderRadius: BorderRadius.circular(20),
- ),
- fillColor: Colors.transparent,
- hintStyle: context.theme.extension()!.bubbleText.copyWith(color: context.theme.colorScheme.outline),
- prefixIconConstraints: const BoxConstraints(minHeight: 0, minWidth: 40),
- prefixIcon: IconButton(
- constraints: const BoxConstraints(maxWidth: 27),
- padding: const EdgeInsets.only(left: 5),
- visualDensity: VisualDensity.compact,
- icon: Icon(
- CupertinoIcons.xmark_circle_fill,
- color: context.theme.colorScheme.onPrimary,
- size: 22,
- ),
- onPressed: () {
- widget.cvController.editing.removeWhere((e2) => e2.item1.guid == message.guid! && e2.item2.part == e.part);
- widget.cvController.lastFocusedNode.requestFocus();
- },
- iconSize: 22,
- style: const ButtonStyle(
- tapTargetSize: MaterialTapTargetSize.shrinkWrap,
- visualDensity: VisualDensity.compact,
- ),
- ),
- suffixIconConstraints: const BoxConstraints(minHeight: 0, minWidth: 40),
- suffixIcon: ValueListenableBuilder(
- valueListenable: editStuff.item3,
- builder: (context, value, _) {
- return Padding(
- padding: const EdgeInsets.all(3.0),
- child: TextButton(
- style: TextButton.styleFrom(
- backgroundColor: Colors.transparent,
- shape: const CircleBorder(),
- padding: const EdgeInsets.all(0),
- maximumSize: const Size(27, 27),
- minimumSize: const Size(27, 27),
- tapTargetSize: MaterialTapTargetSize.shrinkWrap,
- ),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 150),
- constraints: const BoxConstraints(minHeight: 27, minWidth: 27),
- decoration: BoxDecoration(
- shape: iOS ? BoxShape.circle : BoxShape.rectangle,
- color: !iOS ? null : editStuff.item3.text.isNotEmpty ? Colors.white : context.theme.colorScheme.outline,
- ),
- alignment: Alignment.center,
- child: Icon(
- iOS ? CupertinoIcons.arrow_up : Icons.send_outlined,
- color: !iOS ? context.theme.extension()!.bubbleText.color : context.theme.colorScheme.bubble(context, chat.isIMessage),
- size: iOS ? 18 : 26,
- ),
- ),
- onPressed: () {
- completeEdit(editStuff.item3.getFinalAnnotations(), e.part);
- },
- ),
- );
- },
- ),
- ),
- onTap: () {
- HapticFeedback.selectionClick();
- },
- onSubmitted: (String value) {
- completeEdit(editStuff.item3.getFinalAnnotations(), e.part);
- },
- ),
- ),
- ),
+ ),
+ Stack(
+ alignment: Alignment
+ .center,
+ fit: StackFit.loose,
+ clipBehavior:
+ Clip.none,
+ children: [
+ // actual message content
+ BubbleEffects(
+ message:
+ message,
+ part: index,
+ globalKey:
+ keys.length >
+ index
+ ? keys[
+ index]
+ : null,
+ showTail: message
+ .showTail(
+ newerMessage) &&
+ e.part ==
+ controller.parts.length -
+ 1,
+ child:
+ MessagePopupHolder(
+ key: keys.length >
+ index
+ ? keys[
+ index]
+ : null,
+ controller:
+ controller,
+ cvController:
+ widget
+ .cvController,
+ part: e,
+ isEditing:
+ isEditing(
+ e.part),
+ child: GestureDetector(
+ behavior: HitTestBehavior.deferToChild,
+ onHorizontalDragUpdate: !canSwipeToReply || isEditing(e.part)
+ ? null
+ : (details) {
+ if (isInReplyScope) {
+ return;
+ }
+ final offset = replyOffsets[index];
+ offset.value += details.delta.dx * 0.5;
+ if (message.isFromMe!) {
+ offset.value = offset.value.clamp(-double.infinity, 0);
+ } else {
+ offset.value = offset.value.clamp(0, double.infinity);
+ }
+ if (!gaveHapticFeedback && offset.value.abs() >= SlideToReply.replyThreshold) {
+ HapticFeedback.lightImpact();
+ gaveHapticFeedback = true;
+ } else if (offset.value.abs() < SlideToReply.replyThreshold) {
+ gaveHapticFeedback = false;
+ }
+ },
+ onHorizontalDragEnd: !canSwipeToReply || isEditing(e.part)
+ ? null
+ : (details) {
+ if (isInReplyScope) {
+ return;
+ }
+ final offset = replyOffsets[index];
+ if (offset.value.abs() >= SlideToReply.replyThreshold) {
+ widget.cvController.replyToMessage = Tuple2(message, index);
+ }
+ offset.value = 0;
+ },
+ onHorizontalDragCancel: !canSwipeToReply || isEditing(e.part)
+ ? null
+ : () {
+ if (isInReplyScope) {
+ return;
+ }
+ replyOffsets[index].value = 0;
+ },
+ child: Builder(builder: (context) {
+ final rawMessageContent = message.hasApplePayloadData ||
+ message.isLegacyUrlPreview ||
+ message.isInteractive
+ ? InteractiveHolder(
+ parentController: controller,
+ message: e,
+ )
+ : e.attachments.isEmpty && (e.text != null || e.subject != null)
+ ? TextBubble(
+ parentController: controller,
+ message: e,
+ )
+ : e.attachments.isNotEmpty
+ ? AttachmentHolder(
+ parentController: controller,
+ message: e,
+ )
+ : const SizedBox.shrink();
+ final messageContent = e.isEdited
+ ? GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: controller.showEdits.toggle,
+ child: rawMessageContent,
+ )
+ : rawMessageContent;
+ var child =
+ ClipPath(
+ clipper:
+ TailClipper(
+ isFromMe:
+ message.isFromMe!,
+ showTail:
+ message.showTail(newerMessage) && e.part == controller.parts.length - 1,
+ connectLower: iOS
+ ? false
+ : (e.part != 0 && e.part != controller.parts.length - 1) || (e.part == 0 && controller.parts.length > 1),
+ connectUpper: iOS
+ ? false
+ : e.part != 0,
),
- )
- );
- }),
- ],
- ),
- );
+ child:
+ Stack(
+ alignment:
+ Alignment.centerRight,
+ children: [
+ message.isFromMe!
+ ? Obx(
+ () => isEditing(e.part) ? const SizedBox.shrink() : messageContent,
+ )
+ : messageContent,
+ if (message.isFromMe!)
+ Obx(() {
+ final editStuff = widget.cvController.editing.firstWhereOrNull((e2) => e2.item1.guid == message.guid! && e2.item2.part == e.part);
+ return AnimatedSize(
+ duration: const Duration(milliseconds: 250),
+ alignment: Alignment.centerRight,
+ curve: Curves.easeOutBack,
+ child: editStuff == null
+ ? const SizedBox.shrink()
+ : Material(
+ key: ValueKey("message-edit-${message.guid}-${e.part}"),
+ color: Colors.transparent,
+ child: MessageEditTapSurface(
+ focusNode: editStuff.item3.focusNode!,
+ child: Container(
+ decoration: BoxDecoration(
+ color: !message.isBigEmoji ? context.theme.colorScheme.primary : context.theme.colorScheme.background,
+ ),
+ constraints: BoxConstraints(
+ maxWidth: ns.width(context) * MessageWidgetController.maxBubbleSizeFactor - 40,
+ maxHeight: MediaQuery.sizeOf(context).height * 0.5,
+ minHeight: 40,
+ ),
+ padding: const EdgeInsets.only(right: 10).add(const EdgeInsets.all(5)),
+ child: Focus(
+ onKeyEvent: (_, ev) {
+ if (ev is! KeyDownEvent) {
+ if (ev.logicalKey == LogicalKeyboardKey.tab) {
+ // Absorb tab
+ return KeyEventResult.skipRemainingHandlers;
+ }
+ return KeyEventResult.ignored;
+ }
+ if (ev.logicalKey == LogicalKeyboardKey.enter && !HardwareKeyboard.instance.isShiftPressed) {
+ completeEdit(editStuff.item3.getFinalAnnotations(), e.part);
+ return KeyEventResult.handled;
+ }
+ if (ev.logicalKey == LogicalKeyboardKey.escape) {
+ widget.cvController.stopEditing(
+ message.guid!,
+ e.part,
+ restoreInputFocus: true,
+ );
+ return KeyEventResult.handled;
+ }
+ if (ev.logicalKey == LogicalKeyboardKey.tab) {
+ // Absorb tab
+ return KeyEventResult.skipRemainingHandlers;
+ }
+ return KeyEventResult.ignored;
+ },
+ child: CallbackShortcuts(
+ bindings: editStuff.item3.getShortcuts(),
+ child: TextField(
+ textCapitalization: TextCapitalization.sentences,
+ autocorrect: true,
+ controller: editStuff.item3,
+ focusNode: editStuff.item3.focusNode,
+ scrollPhysics: const CustomBouncingScrollPhysics(),
+ style: context.theme.extension()!.bubbleText.apply(
+ fontSizeFactor: message.isBigEmoji ? 3 : 1,
+ color: context.theme.colorScheme.onPrimary,
+ ),
+ keyboardType: TextInputType.multiline,
+ maxLines: null,
+ minLines: 1,
+ contextMenuBuilder: editStuff.item3.getContextMenuBuilder(),
+ autofocus: !(kIsDesktop || kIsWeb),
+ enableIMEPersonalizedLearning: !ss.settings.incognitoKeyboard.value,
+ textInputAction: ss.settings.sendWithReturn.value && !kIsWeb && !kIsDesktop ? TextInputAction.send : TextInputAction.newline,
+ cursorColor: context.theme.colorScheme.onPrimary,
+ cursorHeight: context.theme.extension()!.bubbleText.fontSize! * 1.25 * (message.isBigEmoji ? 3 : 1),
+ decoration: InputDecoration(
+ contentPadding: EdgeInsets.all(iOS ? 10 : 12.5),
+ isDense: true,
+ isCollapsed: true,
+ hintText: "Edited Message",
+ enabledBorder: OutlineInputBorder(
+ borderSide: BorderSide(color: context.theme.colorScheme.onPrimary, width: 1.5),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ border: OutlineInputBorder(
+ borderSide: BorderSide(color: context.theme.colorScheme.onPrimary, width: 1.5),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(color: context.theme.colorScheme.onPrimary, width: 1.5),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ fillColor: Colors.transparent,
+ hintStyle: context.theme.extension()!.bubbleText.copyWith(color: context.theme.colorScheme.outline),
+ prefixIconConstraints: const BoxConstraints(minHeight: 0, minWidth: 40),
+ prefixIcon: IconButton(
+ constraints: const BoxConstraints(maxWidth: 27),
+ padding: const EdgeInsets.only(left: 5),
+ visualDensity: VisualDensity.compact,
+ icon: Icon(
+ CupertinoIcons.xmark_circle_fill,
+ color: context.theme.colorScheme.onPrimary,
+ size: 22,
+ ),
+ onPressed: () {
+ widget.cvController.stopEditing(
+ message.guid!,
+ e.part,
+ restoreInputFocus: true,
+ );
+ },
+ iconSize: 22,
+ style: const ButtonStyle(
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ visualDensity: VisualDensity.compact,
+ ),
+ ),
+ suffixIconConstraints: const BoxConstraints(minHeight: 0, minWidth: 40),
+ suffixIcon: ValueListenableBuilder(
+ valueListenable: editStuff.item3,
+ builder: (context, value, _) {
+ return Padding(
+ padding: const EdgeInsets.all(3.0),
+ child: TextButton(
+ style: TextButton.styleFrom(
+ backgroundColor: Colors.transparent,
+ shape: const CircleBorder(),
+ padding: const EdgeInsets.all(0),
+ maximumSize: const Size(27, 27),
+ minimumSize: const Size(27, 27),
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ ),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 150),
+ constraints: const BoxConstraints(minHeight: 27, minWidth: 27),
+ decoration: BoxDecoration(
+ shape: iOS ? BoxShape.circle : BoxShape.rectangle,
+ color: !iOS
+ ? null
+ : editStuff.item3.text.isNotEmpty
+ ? Colors.white
+ : context.theme.colorScheme.outline,
+ ),
+ alignment: Alignment.center,
+ child: Icon(
+ iOS ? CupertinoIcons.arrow_up : Icons.send_outlined,
+ color: !iOS ? context.theme.extension()!.bubbleText.color : context.theme.colorScheme.bubble(context, chat.isIMessage),
+ size: iOS ? 18 : 26,
+ ),
+ ),
+ onPressed: () {
+ completeEdit(editStuff.item3.getFinalAnnotations(), e.part);
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ onTap: () {
+ HapticFeedback.selectionClick();
+ },
+ onSubmitted: (String value) {
+ completeEdit(editStuff.item3.getFinalAnnotations(), e.part);
+ },
+ ),
+ ),
+ ),
+ ),
+ ),
+ ));
+ }),
+ ],
+ ),
+ );
- return message.dateScheduled != null ? DottedBorder(
- customPath: (size) => TailClipper(
- isFromMe: message.isFromMe!,
- showTail: message.showTail(newerMessage) && e.part == controller.parts.length - 1,
- connectLower: iOS ? false : (e.part != 0 && e.part != controller.parts.length - 1)
- || (e.part == 0 && controller.parts.length > 1),
- connectUpper: iOS ? false : e.part != 0,
- ).getClip(size),
- color: context.theme.colorScheme.primaryContainer,
- strokeWidth: 2,
- dashPattern: [7, 4],
- child: child,
- ) : child;
- })
-
-
+ return message.dateScheduled !=
+ null
+ ? DottedBorder(
+ customPath: (size) => TailClipper(
+ isFromMe: message.isFromMe!,
+ showTail: message.showTail(newerMessage) && e.part == controller.parts.length - 1,
+ connectLower: iOS ? false : (e.part != 0 && e.part != controller.parts.length - 1) || (e.part == 0 && controller.parts.length > 1),
+ connectUpper: iOS ? false : e.part != 0,
+ ).getClip(size),
+ color: context.theme.colorScheme.primaryContainer,
+ strokeWidth: 2,
+ dashPattern: [
+ 7,
+ 4
+ ],
+ child: child,
+ )
+ : child;
+ })),
+ ),
+ ),
+ // show stickers on top
+ if ((messageParts
+ .length ==
+ 1
+ ? stickers
+ : stickersForPart(
+ e.part))
+ .isNotEmpty)
+ StickerHolder(
+ stickerMessages: messageParts
+ .length ==
+ 1
+ ? stickers
+ : stickersForPart(
+ e.part),
+ controller: widget
+ .cvController,
+ ),
+ // show reactions on top
+ if (message
+ .isFromMe!)
+ Positioned(
+ top: -14,
+ left: -20,
+ child:
+ ReactionHolder(
+ reactions: messageParts.length ==
+ 1
+ ? reactions
+ : reactionsForPart(
+ e.part),
+ message:
+ message,
+ ),
+ ),
+ if (!message
+ .isFromMe!)
+ Positioned(
+ top: -14,
+ right: -20,
+ child:
+ ReactionHolder(
+ reactions: messageParts.length ==
+ 1
+ ? reactions
+ : reactionsForPart(
+ e.part),
+ message:
+ message,
+ ),
+ ),
+ ],
),
- ),
+ ],
),
- // show stickers on top
- if ((messageParts.length == 1 ? stickers : stickersForPart(e.part)).isNotEmpty)
- StickerHolder(
- stickerMessages: messageParts.length == 1 ? stickers : stickersForPart(e.part),
- controller: widget.cvController,
- ),
- // show reactions on top
- if (message.isFromMe!)
- Positioned(
- top: -14,
- left: -20,
- child: ReactionHolder(
- reactions: messageParts.length == 1 ? reactions : reactionsForPart(e.part),
- message: message,
- ),
- ),
- if (!message.isFromMe!)
- Positioned(
- top: -14,
- right: -20,
- child: ReactionHolder(
- reactions: messageParts.length == 1 ? reactions : reactionsForPart(e.part),
- message: message,
- ),
- ),
- ],
- ),
- ],
+ // swipe to reply
+ if (canSwipeToReply &&
+ !message.isGroupEvent &&
+ !e.isUnsent)
+ Obx(() => SlideToReply(
+ width: replyOffsets[
+ index]
+ .value
+ .abs(),
+ isFromMe: message
+ .isFromMe!)),
+ ].conditionalReverse(
+ message.isFromMe!),
+ ),
),
- // swipe to reply
- if (canSwipeToReply && !message.isGroupEvent && !e.isUnsent)
- Obx(() => SlideToReply(width: replyOffsets[index].value.abs(), isFromMe: message.isFromMe!)),
- ].conditionalReverse(message.isFromMe!),
- ),
- ),
- ),
+ ),
+ ),
+ )),
),
- )),
+ ],
+ ),
+ // message properties (replies, edits, effect)
+ Padding(
+ padding: showAvatar ||
+ ss.settings.alwaysShowAvatars.value
+ ? EdgeInsets.only(
+ left: 35.0 *
+ ss.settings.avatarScale.value)
+ : EdgeInsets.zero,
+ child: MessageProperties(
+ globalKey: keys.length > index
+ ? keys[index]
+ : null,
+ parentController: controller,
+ part: e),
),
],
),
- // message properties (replies, edits, effect)
- Padding(
- padding: showAvatar || ss.settings.alwaysShowAvatars.value
- ? EdgeInsets.only(left: 35.0 * ss.settings.avatarScale.value) : EdgeInsets.zero,
- child: MessageProperties(
- globalKey: keys.length > index ? keys[index] : null,
- parentController: controller,
- part: e
- ),
- ),
- ],
- ),
- )),
+ )),
// delivered / read receipt
- Obx(() => DeliveredIndicator(parentController: controller, forceShow: tapped.value)),
+ Obx(() => DeliveredIndicator(
+ parentController: controller, forceShow: tapped.value)),
],
),
),
if (message.isFromMe! && !message.isGroupEvent)
- SelectCheckbox(message: message, controller: widget.cvController),
+ SelectCheckbox(
+ message: message, controller: widget.cvController),
Obx(() {
if (message.error > 0 || message.guid!.startsWith("error-")) {
int errorCode = message.error;
String errorText = "An unknown internal error occurred.";
if (errorCode == 22) {
- errorText = "The recipient is not registered with iMessage!";
+ errorText =
+ "The recipient is not registered with iMessage!";
} else if (message.guid!.startsWith("error-")) {
errorText = errorFromGuid(message.guid!);
}
return IconButton(
icon: Icon(
- iOS ? CupertinoIcons.exclamationmark_circle : Icons.error_outline,
+ iOS
+ ? CupertinoIcons.exclamationmark_circle
+ : Icons.error_outline,
color: context.theme.colorScheme.error,
),
onPressed: () {
@@ -782,15 +1139,19 @@ class _MessageHolderState extends CustomState[
TextButton(
- child: Text(
- "Retry",
- style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)
- ),
+ child: Text("Retry",
+ style: context.theme.textTheme.bodyLarge!
+ .copyWith(
+ color: Get.context!.theme
+ .colorScheme.primary)),
onPressed: () async {
// Remove the original message and notification
Navigator.of(context).pop();
@@ -822,10 +1183,11 @@ class _MessageHolderState extends CustomState latest = Chat.getMessages(chat, limit: 1);
+ List latest =
+ Chat.getMessages(chat, limit: 1);
chat.latestMessage = latest.first;
chat.save();
},
),
TextButton(
- child: Text(
- "Cancel",
- style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)
- ),
+ child: Text("Cancel",
+ style: context.theme.textTheme.bodyLarge!
+ .copyWith(
+ color: Get.context!.theme
+ .colorScheme.primary)),
onPressed: () async {
Navigator.of(context).pop();
await notif.clearFailedToSend(chat.id!);
@@ -860,7 +1224,8 @@ class _MessageHolderState extends CustomState createState() => _MessagePopupState();
}
-class _MessagePopupState extends OptimizedState with SingleTickerProviderStateMixin {
+class _MessagePopupState extends OptimizedState
+ with SingleTickerProviderStateMixin {
late final AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
@@ -83,15 +83,19 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
final double itemHeight = kIsDesktop || kIsWeb ? 56 : 48;
List reactions = [];
- late double messageOffset = Get.height - widget.childPosition.dy - widget.size.height;
+ late double messageOffset =
+ Get.height - widget.childPosition.dy - widget.size.height;
late double materialOffset = widget.childPosition.dy +
EdgeInsets.fromViewPadding(
View.of(context).viewInsets,
View.of(context).devicePixelRatio,
).bottom;
late int numberToShow = 5;
- late Chat? dmChat = chats.chats
- .firstWhereOrNull((chat) => !chat.isGroup && chat.participants.firstWhereOrNull((handle) => handle.address == message.handle?.address) != null);
+ late Chat? dmChat = chats.chats.firstWhereOrNull((chat) =>
+ !chat.isGroup &&
+ chat.participants.firstWhereOrNull(
+ (handle) => handle.address == message.handle?.address) !=
+ null);
String? selfReaction;
String? currentlySelectedReaction = "init";
@@ -105,13 +109,19 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
Message get message => widget.controller.message;
- bool get isSent => !message.guid!.startsWith('temp') && !message.guid!.startsWith('error');
+ bool get isSent =>
+ !message.guid!.startsWith('temp') && !message.guid!.startsWith('error');
bool get showDownload =>
- (isSent && part.attachments.isNotEmpty && part.attachments.where((element) => as.getContent(element) is PlatformFile).isNotEmpty) ||
+ (isSent &&
+ part.attachments.isNotEmpty &&
+ part.attachments
+ .where((element) => as.getContent(element) is PlatformFile)
+ .isNotEmpty) ||
isEmbeddedMedia;
- late bool isEmbeddedMedia = (message.balloonBundleId == "com.apple.Handwriting.HandwritingProvider" ||
+ late bool isEmbeddedMedia = (message.balloonBundleId ==
+ "com.apple.Handwriting.HandwritingProvider" ||
message.balloonBundleId == "com.apple.DigitalTouchBalloonProvider") &&
File(message.interactiveMediaPath!).existsSync();
@@ -129,7 +139,9 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
super.initState();
controller.forward();
if (iOS) {
- final remainingHeight = max(Get.height - Get.statusBarHeight - 135 - widget.size.height, itemHeight);
+ final remainingHeight = max(
+ Get.height - Get.statusBarHeight - 135 - widget.size.height,
+ itemHeight);
numberToShow = min(remainingHeight ~/ itemHeight, 5);
} else {
// Potentially make this dynamic in the future
@@ -139,17 +151,21 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
updateObx(() {
currentlySelectedReaction = null;
reactions = getUniqueReactionMessages(message.associatedMessages
- .where((e) => ReactionTypes.toList().contains(e.associatedMessageType?.replaceAll("-", "")) && (e.associatedMessagePart ?? 0) == part.part)
+ .where((e) =>
+ ReactionTypes.toList()
+ .contains(e.associatedMessageType?.replaceAll("-", "")) &&
+ (e.associatedMessagePart ?? 0) == part.part)
.toList());
final reaction = reactions.firstWhereOrNull((e) => e.isFromMe!);
final myReact = reaction?.associatedMessageType;
if (!(myReact?.contains("-") ?? true)) {
- selfReaction = myReact == "emoji" ? reaction!.associatedMessageEmoji : myReact;
+ selfReaction =
+ myReact == "emoji" ? reaction!.associatedMessageEmoji : myReact;
currentlySelectedReaction = selfReaction;
}
(() async {
- var reactions = await getReactionList();
+ var reactions = await getReactionList();
setState(() {
reactOptions = reactions;
if (reactions.length == 6) {
@@ -183,9 +199,13 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
Future> getReactionList() async {
final recentEmojis = await EmojiPickerUtils().getRecentEmojis();
- var reactions = ReactionTypes.toList().where((i) => ReactionTypes.reactionToEmoji.containsKey(i)).toList();
+ var reactions = ReactionTypes.toList()
+ .where((i) => ReactionTypes.reactionToEmoji.containsKey(i))
+ .toList();
- if (currentlySelectedReaction != "init" && currentlySelectedReaction != null && !reactions.contains(currentlySelectedReaction)) {
+ if (currentlySelectedReaction != "init" &&
+ currentlySelectedReaction != null &&
+ !reactions.contains(currentlySelectedReaction)) {
reactions.add(currentlySelectedReaction!); // add current reaction
}
@@ -202,14 +222,15 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
reactions.add(emoji);
}
-
-
return reactions;
}
void reactEmoji(String emoji) {
HapticFeedback.lightImpact();
- widget.sendTapback(selfReaction == emoji ? "-${ReactionTypes.EMOJI}" : ReactionTypes.EMOJI, emoji, part.part);
+ widget.sendTapback(
+ selfReaction == emoji ? "-${ReactionTypes.EMOJI}" : ReactionTypes.EMOJI,
+ emoji,
+ part.part);
popDetails();
}
@@ -232,47 +253,63 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
@override
Widget build(BuildContext context) {
- double narrowWidth = message.isFromMe! || !ss.settings.alwaysShowAvatars.value ? 330 : 360;
+ double narrowWidth =
+ message.isFromMe! || !ss.settings.alwaysShowAvatars.value ? 330 : 360;
bool narrowScreen = ns.width(widthContext) < narrowWidth;
return AnnotatedRegion(
value: SystemUiOverlayStyle(
- systemNavigationBarColor: ss.settings.immersiveMode.value ? Colors.transparent : context.theme.colorScheme.background, // navigation bar color
- systemNavigationBarIconBrightness: context.theme.colorScheme.brightness.opposite,
+ systemNavigationBarColor: ss.settings.immersiveMode.value
+ ? Colors.transparent
+ : context.theme.colorScheme.background, // navigation bar color
+ systemNavigationBarIconBrightness:
+ context.theme.colorScheme.brightness.opposite,
statusBarColor: Colors.transparent, // status bar color
statusBarIconBrightness: context.theme.colorScheme.brightness.opposite,
),
child: Theme(
data: context.theme.copyWith(
// in case some components still use legacy theming
- primaryColor: context.theme.colorScheme.bubble(context, chat.isIMessage),
+ primaryColor:
+ context.theme.colorScheme.bubble(context, chat.isIMessage),
colorScheme: context.theme.colorScheme.copyWith(
primary: context.theme.colorScheme.bubble(context, chat.isIMessage),
- onPrimary: context.theme.colorScheme.onBubble(context, chat.isIMessage),
- surface:
- ss.settings.monetTheming.value == Monet.full ? null : (context.theme.extensions[BubbleColors] as BubbleColors?)?.receivedBubbleColor,
+ onPrimary:
+ context.theme.colorScheme.onBubble(context, chat.isIMessage),
+ surface: ss.settings.monetTheming.value == Monet.full
+ ? null
+ : (context.theme.extensions[BubbleColors] as BubbleColors?)
+ ?.receivedBubbleColor,
onSurface: ss.settings.monetTheming.value == Monet.full
? null
- : (context.theme.extensions[BubbleColors] as BubbleColors?)?.onReceivedBubbleColor,
+ : (context.theme.extensions[BubbleColors] as BubbleColors?)
+ ?.onReceivedBubbleColor,
),
),
child: TitleBarWrapper(
child: Scaffold(
extendBodyBehindAppBar: true,
- backgroundColor: kIsDesktop && iOS && ss.settings.windowEffect.value != WindowEffect.disabled
+ backgroundColor: kIsDesktop &&
+ iOS &&
+ ss.settings.windowEffect.value != WindowEffect.disabled
? context.theme.colorScheme.properSurface.withOpacity(0.6)
: Colors.transparent,
appBar: iOS
? null
: AppBar(
- backgroundColor: context.theme.colorScheme.background.oppositeLightenOrDarken(5),
+ backgroundColor: context.theme.colorScheme.background
+ .oppositeLightenOrDarken(5),
systemOverlayStyle:
- context.theme.colorScheme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
+ context.theme.colorScheme.brightness ==
+ Brightness.dark
+ ? SystemUiOverlayStyle.light
+ : SystemUiOverlayStyle.dark,
automaticallyImplyLeading: false,
leadingWidth: 40,
toolbarHeight: kIsDesktop ? 80 : null,
leading: Padding(
- padding: EdgeInsets.only(top: kIsDesktop ? 20 : 0, left: 10.0),
+ padding: EdgeInsets.only(
+ top: kIsDesktop ? 20 : 0, left: 10.0),
child: BackButton(
color: context.theme.colorScheme.onBackground,
onPressed: () {
@@ -282,7 +319,7 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
),
),
actions: buildMaterialDetailsMenu(context),
- ),
+ ),
body: Stack(
fit: StackFit.expand,
children: [
@@ -290,13 +327,25 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
onTap: popDetails,
child: iOS
? (ss.settings.highPerfMode.value
- ? Container(color: context.theme.colorScheme.background.withOpacity(0.8))
+ ? Container(
+ color: context.theme.colorScheme.background
+ .withOpacity(0.8))
: BackdropFilter(
filter: ImageFilter.blur(
- sigmaX: kIsDesktop && ss.settings.windowEffect.value != WindowEffect.disabled ? 10 : 30,
- sigmaY: kIsDesktop && ss.settings.windowEffect.value != WindowEffect.disabled ? 10 : 30),
+ sigmaX: kIsDesktop &&
+ ss.settings.windowEffect.value !=
+ WindowEffect.disabled
+ ? 10
+ : 30,
+ sigmaY: kIsDesktop &&
+ ss.settings.windowEffect.value !=
+ WindowEffect.disabled
+ ? 10
+ : 30),
child: Container(
- color: context.theme.colorScheme.properSurface.withOpacity(0.3),
+ color: context
+ .theme.colorScheme.properSurface
+ .withOpacity(0.3),
),
))
: null,
@@ -311,12 +360,17 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
tween: Tween(begin: 0.8, end: 1),
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 500),
- child: ConstrainedBox(constraints: BoxConstraints(maxWidth: widget.size.width), child: widget.child),
+ child: ConstrainedBox(
+ constraints:
+ BoxConstraints(maxWidth: widget.size.width),
+ child: widget.child),
builder: (context, size, child) {
return Transform.scale(
scale: size.clamp(1, double.infinity),
child: child,
- alignment: message.isFromMe! ? Alignment.centerRight : Alignment.centerLeft,
+ alignment: message.isFromMe!
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
);
},
),
@@ -330,27 +384,52 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
duration: const Duration(milliseconds: 500),
curve: Sprung.underDamped,
alignment: Alignment.center,
- child: reactions.isNotEmpty ? ReactionDetails(reactions: reactions) : const SizedBox.shrink(),
+ child: reactions.isNotEmpty
+ ? ReactionDetails(reactions: reactions)
+ : const SizedBox.shrink(),
),
),
- if (ss.settings.enablePrivateAPI.value && isSent && minSierra && message.dateScheduled == null)
+ if (ss.settings.enablePrivateAPI.value &&
+ isSent &&
+ minSierra &&
+ message.dateScheduled == null)
Positioned(
- bottom: (iOS ? itemHeight * numberToShow + 35 + widget.size.height : context.height - materialOffset)
- .clamp(0, context.height - (narrowScreen ? 200 : 125)),
- right: message.isFromMe! ? max(15, widget.size.width - emojiPickerSize + 65) : null,
- left: !message.isFromMe! ? max(widget.childPosition.dx + 10, widget.childPosition.dx + widget.size.width - emojiPickerSize + 65) : null,
+ bottom: (iOS
+ ? itemHeight * numberToShow +
+ 35 +
+ widget.size.height
+ : context.height - materialOffset)
+ .clamp(
+ 0, context.height - (narrowScreen ? 200 : 125)),
+ right: message.isFromMe!
+ ? max(15, widget.size.width - emojiPickerSize + 65)
+ : null,
+ left: !message.isFromMe!
+ ? max(
+ widget.childPosition.dx + 10,
+ widget.childPosition.dx +
+ widget.size.width -
+ emojiPickerSize +
+ 65)
+ : null,
child: AnimatedSize(
curve: Curves.easeInOut,
- alignment: message.isFromMe! ? Alignment.centerRight : Alignment.centerLeft,
+ alignment: message.isFromMe!
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
duration: const Duration(milliseconds: 250),
child: currentlySelectedReaction == "init"
? const SizedBox(height: 80)
: ClipShadowPath(
shadow: iOS
? BoxShadow(
- color: context.theme.colorScheme.properSurface.withAlpha(iOS ? 150 : 255).lightenOrDarken(iOS ? 0 : 10))
+ color: context
+ .theme.colorScheme.properSurface
+ .withAlpha(iOS ? 150 : 255)
+ .lightenOrDarken(iOS ? 0 : 10))
: BoxShadow(
- color: context.theme.colorScheme.shadow,
+ color:
+ context.theme.colorScheme.shadow,
blurRadius: 2,
),
clipper: ReactionPickerClipper(
@@ -358,86 +437,167 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
isFromMe: message.isFromMe!,
),
child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
+ filter: ImageFilter.blur(
+ sigmaX: 15, sigmaY: 15),
child: Container(
- padding: const EdgeInsets.all(5).add(const EdgeInsets.only(bottom: 15)),
- color: context.theme.colorScheme.properSurface.lightenOrDarken(iOS ? 0 : 10),
+ padding: const EdgeInsets.all(5).add(
+ const EdgeInsets.only(bottom: 15)),
+ color: context
+ .theme.colorScheme.properSurface
+ .lightenOrDarken(iOS ? 0 : 10),
width: emojiPickerSize.toDouble(),
child: ShaderMask(
shaderCallback: (Rect rect) {
return LinearGradient(
- begin: Alignment.centerLeft,
- end: Alignment.centerRight,
- colors: [Colors.transparent, emojiMode == 2 ? Colors.purple : Colors.transparent],
- stops: [0.9, 1.0], // 10% purple, 80% transparent, 10% purple
- ).createShader(rect);
+ begin: Alignment.centerLeft,
+ end: Alignment.centerRight,
+ colors: [
+ Colors.transparent,
+ emojiMode == 2
+ ? Colors.purple
+ : Colors.transparent
+ ],
+ stops: [
+ 0.9,
+ 1.0
+ ], // 10% purple, 80% transparent, 10% purple
+ ).createShader(rect);
},
blendMode: BlendMode.dstOut,
- child:
- SingleChildScrollView(
- scrollDirection: Axis.horizontal,
- padding: emojiMode == 2 ? const EdgeInsets.only(right: 25) : EdgeInsets.zero,
- child: Row(
- children: reactOptions
- .map((e) {
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ padding: emojiMode == 2
+ ? const EdgeInsets.only(right: 25)
+ : EdgeInsets.zero,
+ child: Row(
+ children: reactOptions.map((e) {
return Padding(
- padding: iOS ? const EdgeInsets.all(5.0) : const EdgeInsets.symmetric(horizontal: 5),
+ padding: iOS
+ ? const EdgeInsets.all(5.0)
+ : const EdgeInsets
+ .symmetric(
+ horizontal: 5),
child: Material(
- color: currentlySelectedReaction == e ? context.theme.colorScheme.primary : Colors.transparent,
- borderRadius: BorderRadius.circular(20),
+ color:
+ currentlySelectedReaction ==
+ e
+ ? context
+ .theme
+ .colorScheme
+ .primary
+ : Colors.transparent,
+ borderRadius:
+ BorderRadius.circular(20),
child: SizedBox(
width: iOS ? 35 : null,
height: iOS ? 35 : null,
child: InkWell(
- borderRadius: BorderRadius.circular(20),
+ borderRadius:
+ BorderRadius.circular(
+ 20),
onTap: () {
- if (currentlySelectedReaction == e) {
- currentlySelectedReaction = null;
+ if (currentlySelectedReaction ==
+ e) {
+ currentlySelectedReaction =
+ null;
} else {
- currentlySelectedReaction = e;
+ currentlySelectedReaction =
+ e;
}
setState(() {});
- if (ReactionTypes.toList().contains(e)) {
- HapticFeedback.lightImpact();
- widget.sendTapback(selfReaction == e ? "-$e" : e, null, part.part);
+ if (ReactionTypes
+ .toList()
+ .contains(e)) {
+ HapticFeedback
+ .lightImpact();
+ widget.sendTapback(
+ selfReaction == e
+ ? "-$e"
+ : e,
+ null,
+ part.part);
popDetails();
} else {
- if (selfReaction != e) {
+ if (selfReaction !=
+ e) {
(() async {
// Add an emoji to recently used list or increase its counter
- Emoji? emoji = emojiMap[e];
- if (emoji == null) {
+ Emoji? emoji =
+ emojiMap[e];
+ if (emoji ==
+ null) {
outerLoop:
- for (var category in defaultEmojiSet) {
- for (var myEmoji in category.emoji) {
- if (myEmoji.emoji == e) {
- emojiMap[e] = myEmoji;
- emoji = myEmoji;
+ for (var category
+ in defaultEmojiSet) {
+ for (var myEmoji
+ in category
+ .emoji) {
+ if (myEmoji
+ .emoji ==
+ e) {
+ emojiMap[
+ e] =
+ myEmoji;
+ emoji =
+ myEmoji;
break outerLoop;
}
}
}
}
- if (emoji != null) await EmojiPickerUtils().addEmojiToRecentlyUsed(key: GlobalKey(), emoji: emoji);
+ if (emoji != null)
+ await EmojiPickerUtils()
+ .addEmojiToRecentlyUsed(
+ key:
+ GlobalKey(),
+ emoji:
+ emoji);
})();
}
reactEmoji(e);
}
},
child: Padding(
- padding: EdgeInsets.symmetric(horizontal: 6.5, vertical: iOS ? 4.5 : 6.5).add(EdgeInsets.only(right: e == "emphasize" ? 2.5 : 0)),
+ padding: EdgeInsets
+ .symmetric(
+ horizontal:
+ 6.5,
+ vertical: iOS
+ ? 4.5
+ : 6.5)
+ .add(EdgeInsets.only(
+ right: e ==
+ "emphasize"
+ ? 2.5
+ : 0)),
child: Center(
- child: Builder(builder: (context) {
+ child: Builder(
+ builder:
+ (context) {
final text = Text(
- ReactionTypes.reactionToEmoji[e] ?? e ?? "X",
- style: const TextStyle(fontSize: 18, fontFamily: 'Apple Color Emoji'),
- textAlign: TextAlign.center,
+ ReactionTypes
+ .reactionToEmoji[
+ e] ??
+ e ??
+ "X",
+ style: const TextStyle(
+ fontSize: 18,
+ fontFamily:
+ 'Apple Color Emoji'),
+ textAlign:
+ TextAlign
+ .center,
);
// rotate thumbs down to match iOS
- if (e == "dislike") {
+ if (e ==
+ "dislike") {
return Transform(
- transform: Matrix4.identity()..rotateY(pi),
- alignment: FractionalOffset.center,
+ transform: Matrix4
+ .identity()
+ ..rotateY(pi),
+ alignment:
+ FractionalOffset
+ .center,
child: text,
);
}
@@ -451,147 +611,235 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
);
}).toList(),
),
- ),
+ ),
),
),
),
),
),
),
- if (ss.settings.enablePrivateAPI.value && isSent && minSierra && message.dateScheduled == null)
+ if (ss.settings.enablePrivateAPI.value &&
+ isSent &&
+ minSierra &&
+ message.dateScheduled == null)
Positioned(
- bottom: (iOS ? itemHeight * numberToShow + 5 + widget.size.height : context.height - materialOffset - 30)
- .clamp(0, context.height - (narrowScreen ? 200 : 125)),
- right: message.isFromMe! ? widget.size.width + 10 + (iOS ? 0 : 60) : null,
- left: !message.isFromMe! ? widget.childPosition.dx + widget.size.width + 10 + (iOS ? 0 : 60) : null,
+ bottom: (iOS
+ ? itemHeight * numberToShow +
+ 5 +
+ widget.size.height
+ : context.height - materialOffset - 30)
+ .clamp(
+ 0, context.height - (narrowScreen ? 200 : 125)),
+ right: message.isFromMe!
+ ? widget.size.width + 10 + (iOS ? 0 : 60)
+ : null,
+ left: !message.isFromMe!
+ ? widget.childPosition.dx +
+ widget.size.width +
+ 10 +
+ (iOS ? 0 : 60)
+ : null,
child: AnimatedSize(
curve: Curves.easeInOut,
- alignment: message.isFromMe! ? Alignment.centerRight : Alignment.centerLeft,
+ alignment: message.isFromMe!
+ ? Alignment.centerRight
+ : Alignment.centerLeft,
duration: const Duration(milliseconds: 100),
-
child: currentlySelectedReaction == "init"
? const SizedBox(height: 80)
: ClipPath(
- clipper: ReactionClipper(isFromMe: message.isFromMe!),
- child: Material(
- color: context.theme.colorScheme.properSurface,
- child: Container(
- width: iosSize,
- height: iosSize,
- alignment: message.isFromMe! ? Alignment.topRight : Alignment.topLeft,
- child: InkWell(
- borderRadius: BorderRadius.circular(20),
- onTap: () {
-
-
- Widget content = Container(
- width: 512,
- child: Theme(
- data: context.theme.copyWith(canvasColor: Colors.transparent),
- child: EmojiPicker(
- scrollController: ScrollController(),
- config: Config(
- height: 512,
- checkPlatformCompatibility: true,
- emojiViewConfig: EmojiViewConfig(
- emojiSizeMax: 28,
- backgroundColor: Colors.transparent,
- columns: min(ns.width(context), 512) ~/ 56,
- noRecents: Text("No Recents", style: context.textTheme.headlineMedium!.copyWith(color: context.theme.colorScheme.outline))
- ),
- swapCategoryAndBottomBar: true,
- skinToneConfig: const SkinToneConfig(enabled: false),
- categoryViewConfig: const CategoryViewConfig(
- backgroundColor: Colors.transparent,
- dividerColor: Colors.transparent,
- ),
- bottomActionBarConfig: BottomActionBarConfig(
- customBottomActionBar: (Config config, EmojiViewState state, VoidCallback showSearchView) {
- return Container(
- margin: const EdgeInsets.only(top: 10),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const SizedBox(width: 8),
- Expanded(
- child: Material(
- child: InkWell(
- onTap: showSearchView,
- child: Padding(
- padding: const EdgeInsets.all(12),
- child: Row(children: [
- Icon(
- iOS ? cupertino.CupertinoIcons.search : Icons.search,
- color: context.theme.colorScheme.outline,
- ),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- "Search...",
- style: context.theme.textTheme.bodyLarge!.copyWith(
- color: context.theme.colorScheme.outline,
+ clipper: ReactionClipper(
+ isFromMe: message.isFromMe!),
+ child: Material(
+ color:
+ context.theme.colorScheme.properSurface,
+ child: Container(
+ width: iosSize,
+ height: iosSize,
+ alignment: message.isFromMe!
+ ? Alignment.topRight
+ : Alignment.topLeft,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(20),
+ onTap: () {
+ Widget content = Container(
+ width: 512,
+ child: Theme(
+ data: context.theme.copyWith(
+ canvasColor:
+ Colors.transparent),
+ child: EmojiPicker(
+ scrollController:
+ ScrollController(),
+ config: Config(
+ height: 512,
+ checkPlatformCompatibility:
+ true,
+ emojiViewConfig: EmojiViewConfig(
+ emojiSizeMax: 28,
+ backgroundColor:
+ Colors.transparent,
+ columns: min(
+ ns.width(
+ context),
+ 512) ~/
+ 56,
+ noRecents: Text(
+ "No Recents",
+ style: context
+ .textTheme
+ .headlineMedium!
+ .copyWith(
+ color: context
+ .theme
+ .colorScheme
+ .outline))),
+ swapCategoryAndBottomBar:
+ true,
+ skinToneConfig:
+ const SkinToneConfig(
+ enabled: false),
+ categoryViewConfig:
+ const CategoryViewConfig(
+ backgroundColor:
+ Colors.transparent,
+ dividerColor:
+ Colors.transparent,
+ ),
+ bottomActionBarConfig:
+ BottomActionBarConfig(
+ customBottomActionBar:
+ (Config config,
+ EmojiViewState
+ state,
+ VoidCallback
+ showSearchView) {
+ return Container(
+ margin:
+ const EdgeInsets
+ .only(
+ top: 10),
+ child: Row(
+ mainAxisSize:
+ MainAxisSize
+ .min,
+ children: [
+ const SizedBox(
+ width: 8),
+ Expanded(
+ child: Material(
+ child:
+ InkWell(
+ onTap:
+ showSearchView,
+ child:
+ Padding(
+ padding: const EdgeInsets
+ .all(
+ 12),
+ child: Row(
+ children: [
+ Icon(
+ iOS ? cupertino.CupertinoIcons.search : Icons.search,
+ color: context.theme.colorScheme.outline,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ "Search...",
+ style: context.theme.textTheme.bodyLarge!.copyWith(
+ color: context.theme.colorScheme.outline,
+ ),
+ ),
+ ),
+ ]),
+ ),
+ ),
),
),
- ),
- ]),
- ),
- ),
- ),
- ),
- Padding(
- padding: const EdgeInsets.all(12),
- child: IconButton(
- icon: Icon(
- iOS ? cupertino.CupertinoIcons.xmark : Icons.close,
- color: context.theme.colorScheme.outline,
- ),
- onPressed: () {
- Get.back();
+ Padding(
+ padding:
+ const EdgeInsets
+ .all(
+ 12),
+ child:
+ IconButton(
+ icon: Icon(
+ iOS
+ ? cupertino
+ .CupertinoIcons
+ .xmark
+ : Icons
+ .close,
+ color: context
+ .theme
+ .colorScheme
+ .outline,
+ ),
+ onPressed:
+ () {
+ Get.back();
+ },
+ ),
+ ),
+ const SizedBox(
+ width: 8),
+ ],
+ ),
+ );
},
),
+ searchViewConfig:
+ SearchViewConfig(
+ backgroundColor:
+ Colors.transparent,
+ buttonIconColor: context
+ .theme
+ .colorScheme
+ .outline,
+ ),
),
- const SizedBox(width: 8),
- ],
+ onEmojiSelected:
+ (cat, emoji) {
+ Get.back();
+ reactEmoji(emoji.emoji);
+ },
+ ),
+ ));
+ Get.dialog(
+ AlertDialog(
+ backgroundColor: context.theme
+ .colorScheme.properSurface,
+ content: content,
),
- );
- },
- ),
- searchViewConfig: SearchViewConfig(
- backgroundColor: Colors.transparent,
- buttonIconColor: context.theme.colorScheme.outline,
+ name: 'Popup Menu');
+ },
+ child: const SizedBox(
+ width: iosSize * 0.8,
+ height: iosSize * 0.8,
+ child: Center(
+ child: Icon(
+ cupertino.CupertinoIcons.smiley,
+ size: 20),
+ ),
),
),
- onEmojiSelected: (cat, emoji) {
- Get.back();
- reactEmoji(emoji.emoji);
- },
),
- )
- );
- Get.dialog(
- AlertDialog(
- backgroundColor: context.theme.colorScheme.properSurface,
- content: content,
- ),
- name: 'Popup Menu');
- },
- child: const SizedBox(
- width: iosSize*0.8,
- height: iosSize*0.8,
- child: Center(
- child: Icon(cupertino.CupertinoIcons.smiley, size: 20),
- ),
- ),
- ),
- ),
- )
- ),
+ )),
),
),
if (iOS)
Positioned(
- right: message.isFromMe! ? max(15, widget.size.width - maxMenuWidth) : null,
- left: !message.isFromMe! ? max(widget.childPosition.dx + 10, widget.childPosition.dx + widget.size.width - maxMenuWidth) : null,
+ right: message.isFromMe!
+ ? max(15, widget.size.width - maxMenuWidth)
+ : null,
+ left: !message.isFromMe!
+ ? max(
+ widget.childPosition.dx + 10,
+ widget.childPosition.dx +
+ widget.size.width -
+ maxMenuWidth)
+ : null,
bottom: 30,
child: TweenAnimationBuilder(
tween: Tween(begin: 0.8, end: 1),
@@ -600,7 +848,8 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
child: FadeTransition(
opacity: CurvedAnimation(
parent: controller,
- curve: const Interval(0.0, .9, curve: Curves.ease),
+ curve:
+ const Interval(0.0, .9, curve: Curves.ease),
reverseCurve: Curves.easeInCubic,
),
child: Column(
@@ -619,10 +868,16 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
},
),
),
- if (!iOS && ss.settings.enablePrivateAPI.value && minBigSur && chat.isIMessage && isSent)
+ if (!iOS &&
+ ss.settings.enablePrivateAPI.value &&
+ minBigSur &&
+ chat.isIMessage &&
+ isSent)
Positioned(
left: !message.isFromMe!
- ? widget.childPosition.dx + widget.size.width + (reactions.isNotEmpty ? 20 : 5)
+ ? widget.childPosition.dx +
+ widget.size.width +
+ (reactions.isNotEmpty ? 20 : 5)
: widget.childPosition.dx - 55,
top: materialOffset,
child: Material(
@@ -634,7 +889,8 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: reply,
- child: const Center(child: Icon(Icons.reply, size: 20)),
+ child: const Center(
+ child: Icon(Icons.reply, size: 20)),
),
),
),
@@ -664,10 +920,13 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
}
if (content is PlatformFile) {
popDetails();
- await as.saveToDisk(content, isDocument: part.attachments.first.mimeStart != "image" && part.attachments.first.mimeStart != "video");
+ await as.saveToDisk(content,
+ isDocument: part.attachments.first.mimeStart != "image" &&
+ part.attachments.first.mimeStart != "video");
}
} catch (ex, trace) {
- Logger.error("Error downloading attachment: ${ex.toString()}", error: ex, trace: trace);
+ Logger.error("Error downloading attachment: ${ex.toString()}",
+ error: ex, trace: trace);
showSnackbar("Save Error", ex.toString());
}
}
@@ -679,7 +938,8 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
}
Future openAttachmentWeb() async {
- await launchUrlString("${part.attachments.first.webUrl!}?guid=${ss.settings.guidAuthKey}");
+ await launchUrlString(
+ "${part.attachments.first.webUrl!}?guid=${ss.settings.guidAuthKey}");
popDetails();
}
@@ -696,8 +956,10 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
context: context,
builder: (context) => AlertDialog(
backgroundColor: context.theme.colorScheme.properSurface,
- title: Text("Copy Selection", style: context.theme.textTheme.titleLarge),
- content: SelectableText(part.fullText, style: context.theme.extension()!.bubbleText),
+ title:
+ Text("Copy Selection", style: context.theme.textTheme.titleLarge),
+ content: SelectableText(part.fullText,
+ style: context.theme.extension()!.bubbleText),
),
);
}
@@ -717,43 +979,50 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
context: context,
builder: (context) => AlertDialog(
backgroundColor: context.theme.colorScheme.properSurface,
- title: Text("Downloading attachment${length > 1 ? "s" : ""}...", style: context.theme.textTheme.titleLarge),
- content: Column(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [
- Obx(
- () => Text(
- '${progress.value != null && attachmentObs.value != null ? (progress.value! * attachmentObs.value!.totalBytes!).getFriendlySize() : ""} / ${(attachmentObs.value!.totalBytes!.toDouble()).getFriendlySize()} (${((progress.value ?? 0) * 100).floor()}%)',
- style: context.theme.textTheme.bodyLarge),
- ),
- const SizedBox(height: 10.0),
- Obx(
- () => ClipRRect(
- borderRadius: BorderRadius.circular(20),
- child: LinearProgressIndicator(
- backgroundColor: context.theme.colorScheme.outline,
- valueColor: AlwaysStoppedAnimation(Get.context!.theme.colorScheme.primary),
- value: progress.value,
- minHeight: 5,
+ title: Text("Downloading attachment${length > 1 ? "s" : ""}...",
+ style: context.theme.textTheme.titleLarge),
+ content: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Obx(
+ () => Text(
+ '${progress.value != null && attachmentObs.value != null ? (progress.value! * attachmentObs.value!.totalBytes!).getFriendlySize() : ""} / ${(attachmentObs.value!.totalBytes!.toDouble()).getFriendlySize()} (${((progress.value ?? 0) * 100).floor()}%)',
+ style: context.theme.textTheme.bodyLarge),
),
- ),
- ),
- const SizedBox(
- height: 15.0,
- ),
- Obx(() => Text(
- progress.value == 1
- ? "Download Complete!"
- : "You can close this dialog. The attachment(s) will continue to download in the background.",
- maxLines: 2,
- textAlign: TextAlign.center,
- style: context.theme.textTheme.bodyLarge,
- )),
- ]),
+ const SizedBox(height: 10.0),
+ Obx(
+ () => ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: LinearProgressIndicator(
+ backgroundColor: context.theme.colorScheme.outline,
+ valueColor: AlwaysStoppedAnimation(
+ Get.context!.theme.colorScheme.primary),
+ value: progress.value,
+ minHeight: 5,
+ ),
+ ),
+ ),
+ const SizedBox(
+ height: 15.0,
+ ),
+ Obx(() => Text(
+ progress.value == 1
+ ? "Download Complete!"
+ : "You can close this dialog. The attachment(s) will continue to download in the background.",
+ maxLines: 2,
+ textAlign: TextAlign.center,
+ style: context.theme.textTheme.bodyLarge,
+ )),
+ ]),
actions: [
Obx(
() => downloadingAttachments.value
? Container(height: 0, width: 0)
: TextButton(
- child: Text("Close", style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)),
+ child: Text("Close",
+ style: context.theme.textTheme.bodyLarge!.copyWith(
+ color: Get.context!.theme.colorScheme.primary)),
onPressed: () async {
Get.closeAllSnackbars();
Navigator.of(context).pop();
@@ -768,13 +1037,18 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
for (Attachment? element in toDownload) {
attachmentObs.value = element;
final file = await backend.downloadAttachment(element!,
- original: true, onReceiveProgress: (count, total) => progress.value = kIsWeb ? (count / total) : (count / element.totalBytes!));
- await as.saveToDisk(file, isDocument: element.mimeStart != "image" && element.mimeStart != "video");
+ original: true,
+ onReceiveProgress: (count, total) => progress.value =
+ kIsWeb ? (count / total) : (count / element.totalBytes!));
+ await as.saveToDisk(file,
+ isDocument:
+ element.mimeStart != "image" && element.mimeStart != "video");
}
progress.value = 1;
downloadingAttachments.value = false;
} catch (ex, trace) {
- Logger.error("Failed to download original attachment!", error: ex, trace: trace);
+ Logger.error("Failed to download original attachment!",
+ error: ex, trace: trace);
showSnackbar("Download Error", ex.toString());
}
}
@@ -783,50 +1057,58 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
final RxBool downloadingAttachments = true.obs;
final RxnInt progress = RxnInt();
final Rxn attachmentObs = Rxn();
- final toDownload = part.attachments.where((element) => element.hasLivePhoto);
+ final toDownload =
+ part.attachments.where((element) => element.hasLivePhoto);
final length = toDownload.length;
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: context.theme.colorScheme.properSurface,
- title: Text("Downloading live photo${length > 1 ? "s" : ""}...", style: context.theme.textTheme.titleLarge),
- content: Column(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [
- Obx(
- () => Text(
- progress.value?.toDouble().getFriendlySize() ?? "",
- style: context.theme.textTheme.bodyLarge,
- ),
- ),
- const SizedBox(height: 10.0),
- Obx(
- () => ClipRRect(
- borderRadius: BorderRadius.circular(20),
- child: LinearProgressIndicator(
- backgroundColor: context.theme.colorScheme.outline,
- valueColor: AlwaysStoppedAnimation(Get.context!.theme.colorScheme.primary),
- value: downloadingAttachments.value ? null : 1,
- minHeight: 5,
+ title: Text("Downloading live photo${length > 1 ? "s" : ""}...",
+ style: context.theme.textTheme.titleLarge),
+ content: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Obx(
+ () => Text(
+ progress.value?.toDouble().getFriendlySize() ?? "",
+ style: context.theme.textTheme.bodyLarge,
+ ),
),
- ),
- ),
- const SizedBox(
- height: 15.0,
- ),
- Obx(() => Text(
- !downloadingAttachments.value
- ? "Download Complete!"
- : "You can close this dialog. The live photo(s) will continue to download in the background.",
- maxLines: 2,
- textAlign: TextAlign.center,
- style: context.theme.textTheme.bodyLarge,
- )),
- ]),
+ const SizedBox(height: 10.0),
+ Obx(
+ () => ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: LinearProgressIndicator(
+ backgroundColor: context.theme.colorScheme.outline,
+ valueColor: AlwaysStoppedAnimation(
+ Get.context!.theme.colorScheme.primary),
+ value: downloadingAttachments.value ? null : 1,
+ minHeight: 5,
+ ),
+ ),
+ ),
+ const SizedBox(
+ height: 15.0,
+ ),
+ Obx(() => Text(
+ !downloadingAttachments.value
+ ? "Download Complete!"
+ : "You can close this dialog. The live photo(s) will continue to download in the background.",
+ maxLines: 2,
+ textAlign: TextAlign.center,
+ style: context.theme.textTheme.bodyLarge,
+ )),
+ ]),
actions: [
Obx(
() => downloadingAttachments.value
? Container(height: 0, width: 0)
: TextButton(
- child: Text("Close", style: context.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)),
+ child: Text("Close",
+ style: context.theme.textTheme.bodyLarge!.copyWith(
+ color: Get.context!.theme.colorScheme.primary)),
onPressed: () async {
Get.closeAllSnackbars();
Navigator.of(context).pop();
@@ -841,7 +1123,8 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
for (Attachment? element in toDownload) {
attachmentObs.value = element;
final nameSplit = element!.transferName!.split(".");
- await backend.downloadLivePhoto(element, "${nameSplit.take(nameSplit.length - 1).join(".")}.mov",
+ await backend.downloadLivePhoto(
+ element, "${nameSplit.take(nameSplit.length - 1).join(".")}.mov",
onReceiveProgress: (count, total) => progress.value = count);
}
downloadingAttachments.value = false;
@@ -867,8 +1150,10 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
void createContact() async {
popDetails();
- await mcs
- .invokeMethod("open-contact-form", {'address': message.handle!.address, 'address_type': message.handle!.address.isEmail ? 'email' : 'phone'});
+ await mcs.invokeMethod("open-contact-form", {
+ 'address': message.handle!.address,
+ 'address_type': message.handle!.address.isEmail ? 'email' : 'phone'
+ });
}
void showThread() {
@@ -876,7 +1161,8 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
if (message.threadOriginatorGuid != null) {
final mwc = getActiveMwc(message.threadOriginatorGuid!);
if (mwc == null) return showSnackbar("Error", "Failed to find thread!");
- showReplyThread(context, mwc.message, mwc.parts[message.normalizedThreadPart], service, cvController);
+ showReplyThread(context, mwc.message,
+ mwc.parts[message.normalizedThreadPart], service, cvController);
} else {
showReplyThread(context, message, part, service, cvController);
}
@@ -888,7 +1174,10 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
popDetails();
ns.pushAndRemoveUntil(
context,
- ChatCreator(initialSelected: [SelectedContact(displayName: handle.displayName, address: handle.address)]),
+ ChatCreator(initialSelected: [
+ SelectedContact(
+ displayName: handle.displayName, address: handle.address)
+ ]),
(route) => route.isFirst,
);
}
@@ -936,7 +1225,10 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
}
void share() {
- if (part.attachments.isNotEmpty && !message.isLegacyUrlPreview && !kIsWeb && !kIsDesktop) {
+ if (part.attachments.isNotEmpty &&
+ !message.isLegacyUrlPreview &&
+ !kIsWeb &&
+ !kIsDesktop) {
for (Attachment? element in part.attachments) {
Share.file(
"${element!.mimeType!.split("/")[0].capitalizeFirst} shared from OpenBubbles: ${element.transferName}",
@@ -960,138 +1252,168 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
final TextEditingController participantController = TextEditingController();
Uint8List? attachment;
showDialog(
- context: context,
- builder: (_) {
- return AlertDialog(
- actions: [
- TextButton(
- child: Text("Cancel", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)),
- onPressed: () => Get.back(),
- ),
- TextButton(
- child: Text("Screenshot", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)),
- onPressed: () async {
- final res = await picker.FilePicker.platform.pickFiles(withData: true, type: picker.FileType.custom, allowedExtensions: ['png', 'jpg', 'jpeg']);
- if (res == null || res.count == 0) return;
- attachment = await File(res.files[0].path!).readAsBytes();
- showSnackbar("Notice", "Screenshot added");
- },
- ),
- TextButton(
- child: Text("OK", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)),
- onPressed: () async {
- if (participantController.text == "") return;
-
- showDialog(
- context: context,
- builder: (BuildContext context) {
- return AlertDialog(
- backgroundColor: context.theme.colorScheme.properSurface,
- title: Text(
- "Uploading log...",
- style: context.theme.textTheme.titleLarge,
- ),
- content: Container(
- height: 70,
- child: Center(
- child: CircularProgressIndicator(
- backgroundColor: context.theme.colorScheme.properSurface,
- valueColor: AlwaysStoppedAnimation(context.theme.colorScheme.primary),
+ context: context,
+ builder: (_) {
+ return AlertDialog(
+ actions: [
+ TextButton(
+ child: Text("Cancel",
+ style: context.theme.textTheme.bodyLarge!
+ .copyWith(color: context.theme.colorScheme.primary)),
+ onPressed: () => Get.back(),
+ ),
+ TextButton(
+ child: Text("Screenshot",
+ style: context.theme.textTheme.bodyLarge!
+ .copyWith(color: context.theme.colorScheme.primary)),
+ onPressed: () async {
+ final res = await picker.FilePicker.platform.pickFiles(
+ withData: true,
+ type: picker.FileType.custom,
+ allowedExtensions: ['png', 'jpg', 'jpeg']);
+ if (res == null || res.count == 0) return;
+ attachment = await File(res.files[0].path!).readAsBytes();
+ showSnackbar("Notice", "Screenshot added");
+ },
+ ),
+ TextButton(
+ child: Text("OK",
+ style: context.theme.textTheme.bodyLarge!
+ .copyWith(color: context.theme.colorScheme.primary)),
+ onPressed: () async {
+ if (participantController.text == "") return;
+
+ showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return AlertDialog(
+ backgroundColor:
+ context.theme.colorScheme.properSurface,
+ title: Text(
+ "Uploading log...",
+ style: context.theme.textTheme.titleLarge,
),
- ),
- ),
- );
+ content: Container(
+ height: 70,
+ child: Center(
+ child: CircularProgressIndicator(
+ backgroundColor:
+ context.theme.colorScheme.properSurface,
+ valueColor: AlwaysStoppedAnimation(
+ context.theme.colorScheme.primary),
+ ),
+ ),
+ ),
+ );
+ });
+
+ //
+ var file = Directory(Platform.isAndroid
+ ? "${fs.appDocDir.path}/../files/logs"
+ : "${fs.appDocDir.path}/logs");
+ final List entities =
+ await file.list().toList();
+ var current = entities.indexWhere(
+ (element) => element.path.endsWith("CURRENT.log"));
+ var item = entities.removeAt(current);
+ var end = await File(item.path).readAsBytes();
+ var b = BytesBuilder();
+ if (entities.isNotEmpty) {
+ var next = await File(entities.first.path).readAsBytes();
+ b.add(next);
}
- );
-
- //
- var file = Directory(Platform.isAndroid ? "${fs.appDocDir.path}/../files/logs" : "${fs.appDocDir.path}/logs");
- final List entities = await file.list().toList();
- var current = entities.indexWhere((element) => element.path.endsWith("CURRENT.log"));
- var item = entities.removeAt(current);
- var end = await File(item.path).readAsBytes();
- var b = BytesBuilder();
- if (entities.isNotEmpty) {
- var next = await File(entities.first.path).readAsBytes();
- b.add(next);
- }
- b.add(end);
- var total = b.toBytes();
-
- var encoder = const JsonEncoder.withIndent(" ");
- var messageMeta = encoder.convert(message.toMap(includeObjects: true));
- var chatMeta = encoder.convert(chat.toMap());
-
- // stop stupid automatic cralwers from spamming the webhook
- var url = dotenv.get('REPORT_ISSUE_WEBHOOK');
-
- try {
- final response = await http.dio.post(
+ b.add(end);
+ var total = b.toBytes();
+
+ var encoder = const JsonEncoder.withIndent(" ");
+ var messageMeta =
+ encoder.convert(message.toMap(includeObjects: true));
+ var chatMeta = encoder.convert(chat.toMap());
+
+ // stop stupid automatic cralwers from spamming the webhook
+ var url = dotenv.get('REPORT_ISSUE_WEBHOOK');
+
+ try {
+ final response = await http.dio.post(
url,
data: FormData.fromMap({
- "content": "Handle: ${(await api.getHandles(state: pushService.state!.client)).first} \nDesc: ${participantController.text}",
+ "content":
+ "Handle: ${(await api.getHandles(state: pushService.state!.client)).first} \nDesc: ${participantController.text}",
"username": ss.settings.userName.value,
- "files[0]": MultipartFile.fromBytes(total, filename: "rustpush-logs.log"),
- "files[1]": MultipartFile.fromString(messageMeta, filename: "message.json"),
- "files[2]": MultipartFile.fromString(chatMeta, filename: "chat.json"),
+ "files[0]": MultipartFile.fromBytes(total,
+ filename: "rustpush-logs.log"),
+ "files[1]": MultipartFile.fromString(messageMeta,
+ filename: "message.json"),
+ "files[2]": MultipartFile.fromString(chatMeta,
+ filename: "chat.json"),
if (attachment != null)
- "files[3]": MultipartFile.fromBytes(attachment!, filename: "screenshot.png")
+ "files[3]": MultipartFile.fromBytes(attachment!,
+ filename: "screenshot.png")
}),
- );
+ );
- if (response.statusCode == 200) {
- Get.back();
- Get.back();
- showSnackbar("Notice", "Logs sent! Thank you!");
- } else {
+ if (response.statusCode == 200) {
+ Get.back();
+ Get.back();
+ showSnackbar("Notice", "Logs sent! Thank you!");
+ } else {
+ Get.back();
+ Logger.error(response.toString());
+ showSnackbar("Error", "There was an issue sending logs");
+ }
+ } catch (e, s) {
Get.back();
- Logger.error(response.toString());
- showSnackbar("Error", "There was an issue sending logs");
+ Logger.error("failed", error: e, trace: s);
+ showSnackbar("Error", "There was an issue sending logs $e");
}
- } catch(e, s) {
- Get.back();
- Logger.error("failed", error: e, trace: s);
- showSnackbar("Error", "There was an issue sending logs $e");
- }
- },
- ),
- ],
- content: Column(children: [
- const Text("Logs will be sent to developer for review. Logs contain personal identifiers and 48 hours of message and chat history. Do not submit logs containing sensitive chats or messages. Your logs will be shared with Discord for storage subject to their Privacy Policy. We may contact you on iMessage for further information."),
- const SizedBox(height: 16,),
- TextField(
- controller: participantController,
- decoration: const InputDecoration(
- labelText: "Description",
- border: OutlineInputBorder(),
+ },
),
- keyboardType: TextInputType.multiline,
- maxLines: null,
- )
- ],
- mainAxisSize: MainAxisSize.min,),
- title: Text("Report issue", style: context.theme.textTheme.titleLarge),
- backgroundColor: context.theme.colorScheme.properSurface,
- );
- }
- );
+ ],
+ content: Column(
+ children: [
+ const Text(
+ "Logs will be sent to developer for review. Logs contain personal identifiers and 48 hours of message and chat history. Do not submit logs containing sensitive chats or messages. Your logs will be shared with Discord for storage subject to their Privacy Policy. We may contact you on iMessage for further information."),
+ const SizedBox(
+ height: 16,
+ ),
+ TextField(
+ controller: participantController,
+ decoration: const InputDecoration(
+ labelText: "Description",
+ border: OutlineInputBorder(),
+ ),
+ keyboardType: TextInputType.multiline,
+ maxLines: null,
+ )
+ ],
+ mainAxisSize: MainAxisSize.min,
+ ),
+ title:
+ Text("Report issue", style: context.theme.textTheme.titleLarge),
+ backgroundColor: context.theme.colorScheme.properSurface,
+ );
+ });
}
Future remindLater() async {
if (Platform.isAndroid) {
bool denied = await Permission.scheduleExactAlarm.isDenied;
;
- bool permanentlyDenied = await Permission.scheduleExactAlarm.isPermanentlyDenied;
+ bool permanentlyDenied =
+ await Permission.scheduleExactAlarm.isPermanentlyDenied;
if (denied && !permanentlyDenied) {
await Permission.scheduleExactAlarm.request();
} else if (permanentlyDenied) {
- showSnackbar("Error", "You must enable the manage alarm permission to use this feature");
+ showSnackbar("Error",
+ "You must enable the manage alarm permission to use this feature");
return;
}
}
final finalDate = await showTimeframePicker("Select Reminder Time", context,
- presetsAhead: true, additionalTimeframes: {"3 Hours": 3, "6 Hours": 6}, useTodayYesterday: true);
+ presetsAhead: true,
+ additionalTimeframes: {"3 Hours": 3, "6 Hours": 6},
+ useTodayYesterday: true);
if (finalDate != null) {
if (!finalDate.isAfter(DateTime.now().toLocal())) {
showSnackbar("Error", "Select a date in the future");
@@ -1112,14 +1434,19 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
ah.handleUpdatedMessage(chat, updatedMessage, null);
}
- void edit() {
+ void edit() async {
+ final popupRoute = ModalRoute.of(context);
popDetails();
- final FocusNode? node = kIsDesktop || kIsWeb ? FocusNode() : null;
-
- var controller = MentionTextEditingController(text: "", focusNode: node);
- controller.importMessagePart(part);
-
- cvController.editing.add(Tuple3(message, part, controller));
+ await popupRoute?.completed;
+ final editController = cvController.startEditing(message, part);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ final focusNode = editController.focusNode;
+ if (focusNode == null || !focusNode.canRequestFocus) return;
+ focusNode.requestFocus();
+ if (!(kIsDesktop || kIsWeb)) {
+ SystemChannels.textInput.invokeMethod('TextInput.show');
+ }
+ });
}
void delete() {
@@ -1146,16 +1473,20 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
const encoder = JsonEncoder.withIndent(" ");
Map map = message.toMap(includeObjects: true);
if (map["dateCreated"] is int) {
- map["dateCreated"] = DateFormat("MMMM d, yyyy h:mm:ss a").format(DateTime.fromMillisecondsSinceEpoch(map["dateCreated"]));
+ map["dateCreated"] = DateFormat("MMMM d, yyyy h:mm:ss a")
+ .format(DateTime.fromMillisecondsSinceEpoch(map["dateCreated"]));
}
if (map["dateDelivered"] is int) {
- map["dateDelivered"] = DateFormat("MMMM d, yyyy h:mm:ss a").format(DateTime.fromMillisecondsSinceEpoch(map["dateDelivered"]));
+ map["dateDelivered"] = DateFormat("MMMM d, yyyy h:mm:ss a")
+ .format(DateTime.fromMillisecondsSinceEpoch(map["dateDelivered"]));
}
if (map["dateRead"] is int) {
- map["dateRead"] = DateFormat("MMMM d, yyyy h:mm:ss a").format(DateTime.fromMillisecondsSinceEpoch(map["dateRead"]));
+ map["dateRead"] = DateFormat("MMMM d, yyyy h:mm:ss a")
+ .format(DateTime.fromMillisecondsSinceEpoch(map["dateRead"]));
}
if (map["dateEdited"] is int) {
- map["dateEdited"] = DateFormat("MMMM d, yyyy h:mm:ss a").format(DateTime.fromMillisecondsSinceEpoch(map["dateEdited"]));
+ map["dateEdited"] = DateFormat("MMMM d, yyyy h:mm:ss a")
+ .format(DateTime.fromMillisecondsSinceEpoch(map["dateEdited"]));
}
String str = encoder.convert(map);
showDialog(
@@ -1171,7 +1502,9 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
height: context.height * 1 / 4,
child: Container(
padding: const EdgeInsets.all(10.0),
- decoration: BoxDecoration(color: context.theme.colorScheme.background, borderRadius: const BorderRadius.all(Radius.circular(10))),
+ decoration: BoxDecoration(
+ color: context.theme.colorScheme.background,
+ borderRadius: const BorderRadius.all(Radius.circular(10))),
child: SingleChildScrollView(
child: SelectableText(
str,
@@ -1182,7 +1515,9 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
),
actions: [
TextButton(
- child: Text("Close", style: context.theme.textTheme.bodyLarge!.copyWith(color: context.theme.colorScheme.primary)),
+ child: Text("Close",
+ style: context.theme.textTheme.bodyLarge!
+ .copyWith(color: context.theme.colorScheme.primary)),
onPressed: () => Navigator.of(context).pop(),
),
],
@@ -1191,147 +1526,173 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
}
get _allActions {
- final canEdit = (message.dateCreated?.toUtc().isWithin(DateTime.now().toUtc(), minutes: 15) ?? false)
- || (message.dateCreated?.toUtc().isAfter(DateTime.now().toUtc()) ?? false);
+ final canEdit = (message.dateCreated
+ ?.toUtc()
+ .isWithin(DateTime.now().toUtc(), minutes: 15) ??
+ false) ||
+ (message.dateCreated?.toUtc().isAfter(DateTime.now().toUtc()) ?? false);
return [
- if (ss.settings.enablePrivateAPI.value && minBigSur && chat.isIMessage && isSent)
- DetailsMenuActionWidget(
- onTap: reply,
- action: DetailsMenuAction.Reply,
- ),
- if (showDownload)
- DetailsMenuActionWidget(
- onTap: download,
- action: DetailsMenuAction.Save,
- ),
- if ((part.text?.hasUrl ?? false) && !kIsWeb && !kIsDesktop && !ls.isBubble)
- DetailsMenuActionWidget(
- onTap: openLink,
- action: DetailsMenuAction.OpenInBrowser,
- ),
- if (showDownload && kIsWeb && part.attachments.firstOrNull?.webUrl != null)
- DetailsMenuActionWidget(
- onTap: openAttachmentWeb,
- action: DetailsMenuAction.OpenInNewTab,
- ),
- if (!isNullOrEmptyString(part.fullText))
- DetailsMenuActionWidget(
- onTap: copyText,
- action: DetailsMenuAction.CopyText,
- ),
- if (showDownload &&
- supportsOriginalDownload &&
- part.attachments
- .where((element) =>
- (element.uti?.contains("heic") ?? false) ||
- (element.uti?.contains("heif") ?? false) ||
- (element.uti?.contains("quicktime") ?? false) ||
- (element.uti?.contains("coreaudio") ?? false) ||
- (element.uti?.contains("tiff") ?? false))
- .isNotEmpty)
- DetailsMenuActionWidget(
- onTap: downloadOriginal,
- action: DetailsMenuAction.SaveOriginal,
- ),
- if (showDownload && part.attachments.where((e) => e.hasLivePhoto).isNotEmpty)
- DetailsMenuActionWidget(
- onTap: downloadLivePhoto,
- action: DetailsMenuAction.SaveLivePhoto,
- ),
- if (chat.isGroup && !message.isFromMe! && dmChat != null && !ls.isBubble)
- DetailsMenuActionWidget(
- onTap: openDm,
- action: DetailsMenuAction.OpenDirectMessage,
- ),
- if (message.threadOriginatorGuid != null || service.struct.threads(message.guid!, part.part, returnOriginator: false).isNotEmpty)
- DetailsMenuActionWidget(
- onTap: showThread,
- action: DetailsMenuAction.ViewThread,
- ),
- if ((part.attachments.isNotEmpty && !kIsWeb && !kIsDesktop) || (!kIsWeb && !kIsDesktop && !isNullOrEmpty(part.text)))
- DetailsMenuActionWidget(
- onTap: share,
- action: DetailsMenuAction.Share,
- ),
- if (showDownload)
- DetailsMenuActionWidget(
- onTap: redownload,
- action: DetailsMenuAction.ReDownloadFromServer,
- ),
- if (!kIsWeb && !kIsDesktop)
- DetailsMenuActionWidget(
- onTap: remindLater,
- action: DetailsMenuAction.RemindLater,
- ),
- if (!kIsWeb && !kIsDesktop)
- DetailsMenuActionWidget(
- onTap: reportIssue,
- action: DetailsMenuAction.ReportIssue,
- ),
- if (message.attachments.isNotEmpty && ss.settings.cloudSyncingEnabled.value)
- DetailsMenuActionWidget(
- onTap: uploadAttachment,
- action: DetailsMenuAction.UploadAttachment,
- ),
- if (!kIsWeb && !kIsDesktop && !message.isFromMe! && message.handle != null && message.handle!.contact == null)
- DetailsMenuActionWidget(
- onTap: createContact,
- action: DetailsMenuAction.CreateContact,
- ),
- if (backend.canEditUnsend() && message.isFromMe! && !message.guid!.startsWith("temp") && message.dateScheduled == null)
- DetailsMenuActionWidget(
- onTap: unsend,
- action: DetailsMenuAction.UndoSend,
- ),
- if (backend.canEditUnsend() &&
- message.isFromMe! &&
- !message.guid!.startsWith("temp") &&
- (part.text?.isNotEmpty ?? false))
- DetailsMenuActionWidget(
- onTap: edit,
- customTitle: canEdit ? 'Edit' : 'Edit (too old)',
- shouldDisableBtn: !canEdit,
- action: DetailsMenuAction.Edit,
- ),
- if (!ls.isBubble && !message.isInteractive)
- DetailsMenuActionWidget(
- onTap: forward,
- action: DetailsMenuAction.Forward,
- ),
- if (chat.isGroup && !message.isFromMe! && dmChat == null && !ls.isBubble)
- DetailsMenuActionWidget(
- onTap: newConvo,
- action: DetailsMenuAction.StartConversation,
- ),
- if (!isNullOrEmptyString(part.fullText) && (kIsDesktop || kIsWeb))
- DetailsMenuActionWidget(
- onTap: copySelection,
- action: DetailsMenuAction.CopySelection,
- ),
+ if (ss.settings.enablePrivateAPI.value &&
+ minBigSur &&
+ chat.isIMessage &&
+ isSent)
+ DetailsMenuActionWidget(
+ onTap: reply,
+ action: DetailsMenuAction.Reply,
+ ),
+ if (showDownload)
+ DetailsMenuActionWidget(
+ onTap: download,
+ action: DetailsMenuAction.Save,
+ ),
+ if ((part.text?.hasUrl ?? false) &&
+ !kIsWeb &&
+ !kIsDesktop &&
+ !ls.isBubble)
DetailsMenuActionWidget(
- onTap: delete,
- action: DetailsMenuAction.Delete,
+ onTap: openLink,
+ action: DetailsMenuAction.OpenInBrowser,
),
+ if (showDownload &&
+ kIsWeb &&
+ part.attachments.firstOrNull?.webUrl != null)
DetailsMenuActionWidget(
- onTap: toggleBookmark,
- action: DetailsMenuAction.Bookmark,
- customTitle: message.isBookmarked ? "Remove Bookmark" : "Add Bookmark",
+ onTap: openAttachmentWeb,
+ action: DetailsMenuAction.OpenInNewTab,
),
+ if (!isNullOrEmptyString(part.fullText))
DetailsMenuActionWidget(
- onTap: selectMultiple,
- action: DetailsMenuAction.SelectMultiple,
+ onTap: copyText,
+ action: DetailsMenuAction.CopyText,
),
+ if (showDownload &&
+ supportsOriginalDownload &&
+ part.attachments
+ .where((element) =>
+ (element.uti?.contains("heic") ?? false) ||
+ (element.uti?.contains("heif") ?? false) ||
+ (element.uti?.contains("quicktime") ?? false) ||
+ (element.uti?.contains("coreaudio") ?? false) ||
+ (element.uti?.contains("tiff") ?? false))
+ .isNotEmpty)
DetailsMenuActionWidget(
- onTap: messageInfo,
- action: DetailsMenuAction.MessageInfo,
+ onTap: downloadOriginal,
+ action: DetailsMenuAction.SaveOriginal,
),
- ].sorted((a, b) => ss.settings.detailsMenuActions.indexOf(a.action).compareTo(ss.settings.detailsMenuActions.indexOf(b.action)));
+ if (showDownload &&
+ part.attachments.where((e) => e.hasLivePhoto).isNotEmpty)
+ DetailsMenuActionWidget(
+ onTap: downloadLivePhoto,
+ action: DetailsMenuAction.SaveLivePhoto,
+ ),
+ if (chat.isGroup && !message.isFromMe! && dmChat != null && !ls.isBubble)
+ DetailsMenuActionWidget(
+ onTap: openDm,
+ action: DetailsMenuAction.OpenDirectMessage,
+ ),
+ if (message.threadOriginatorGuid != null ||
+ service.struct
+ .threads(message.guid!, part.part, returnOriginator: false)
+ .isNotEmpty)
+ DetailsMenuActionWidget(
+ onTap: showThread,
+ action: DetailsMenuAction.ViewThread,
+ ),
+ if ((part.attachments.isNotEmpty && !kIsWeb && !kIsDesktop) ||
+ (!kIsWeb && !kIsDesktop && !isNullOrEmpty(part.text)))
+ DetailsMenuActionWidget(
+ onTap: share,
+ action: DetailsMenuAction.Share,
+ ),
+ if (showDownload)
+ DetailsMenuActionWidget(
+ onTap: redownload,
+ action: DetailsMenuAction.ReDownloadFromServer,
+ ),
+ if (!kIsWeb && !kIsDesktop)
+ DetailsMenuActionWidget(
+ onTap: remindLater,
+ action: DetailsMenuAction.RemindLater,
+ ),
+ if (!kIsWeb && !kIsDesktop)
+ DetailsMenuActionWidget(
+ onTap: reportIssue,
+ action: DetailsMenuAction.ReportIssue,
+ ),
+ if (message.attachments.isNotEmpty &&
+ ss.settings.cloudSyncingEnabled.value &&
+ legacyCloudKitMutationsEnabled)
+ DetailsMenuActionWidget(
+ onTap: uploadAttachment,
+ action: DetailsMenuAction.UploadAttachment,
+ ),
+ if (!kIsWeb &&
+ !kIsDesktop &&
+ !message.isFromMe! &&
+ message.handle != null &&
+ message.handle!.contact == null)
+ DetailsMenuActionWidget(
+ onTap: createContact,
+ action: DetailsMenuAction.CreateContact,
+ ),
+ if (backend.canEditUnsend() &&
+ message.isFromMe! &&
+ !message.guid!.startsWith("temp") &&
+ message.dateScheduled == null)
+ DetailsMenuActionWidget(
+ onTap: unsend,
+ action: DetailsMenuAction.UndoSend,
+ ),
+ if (backend.canEditUnsend() &&
+ message.isFromMe! &&
+ !message.guid!.startsWith("temp") &&
+ (part.text?.isNotEmpty ?? false))
+ DetailsMenuActionWidget(
+ onTap: edit,
+ customTitle: canEdit ? 'Edit' : 'Edit (too old)',
+ shouldDisableBtn: !canEdit,
+ action: DetailsMenuAction.Edit,
+ ),
+ if (!ls.isBubble && !message.isInteractive)
+ DetailsMenuActionWidget(
+ onTap: forward,
+ action: DetailsMenuAction.Forward,
+ ),
+ if (chat.isGroup && !message.isFromMe! && dmChat == null && !ls.isBubble)
+ DetailsMenuActionWidget(
+ onTap: newConvo,
+ action: DetailsMenuAction.StartConversation,
+ ),
+ if (!isNullOrEmptyString(part.fullText) && (kIsDesktop || kIsWeb))
+ DetailsMenuActionWidget(
+ onTap: copySelection,
+ action: DetailsMenuAction.CopySelection,
+ ),
+ DetailsMenuActionWidget(
+ onTap: delete,
+ action: DetailsMenuAction.Delete,
+ ),
+ DetailsMenuActionWidget(
+ onTap: toggleBookmark,
+ action: DetailsMenuAction.Bookmark,
+ customTitle: message.isBookmarked ? "Remove Bookmark" : "Add Bookmark",
+ ),
+ DetailsMenuActionWidget(
+ onTap: selectMultiple,
+ action: DetailsMenuAction.SelectMultiple,
+ ),
+ DetailsMenuActionWidget(
+ onTap: messageInfo,
+ action: DetailsMenuAction.MessageInfo,
+ ),
+ ].sorted((a, b) => ss.settings.detailsMenuActions
+ .indexOf(a.action)
+ .compareTo(ss.settings.detailsMenuActions.indexOf(b.action)));
}
double maxMenuWidth = 300;
Widget buildDetailsMenu(BuildContext context) {
-
List allActions = _allActions;
return ClipRRect(
@@ -1344,7 +1705,9 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
- children: allActions.cast().sublist(0, numberToShow - 1)
+ children: allActions
+ .cast()
+ .sublist(0, numberToShow - 1)
..add(
CustomDetailsMenuActionWidget(
onTap: () async {
@@ -1356,11 +1719,13 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
Get.dialog(
ss.settings.skin.value == Skins.iOS
? CupertinoAlertDialog(
- backgroundColor: context.theme.colorScheme.properSurface,
+ backgroundColor:
+ context.theme.colorScheme.properSurface,
content: content,
)
: AlertDialog(
- backgroundColor: context.theme.colorScheme.properSurface,
+ backgroundColor:
+ context.theme.colorScheme.properSurface,
content: content,
),
name: 'Popup Menu');
@@ -1383,42 +1748,49 @@ class _MessagePopupState extends OptimizedState with SingleTickerP
...allActions.slice(0, numberToShow - 1).map((action) {
bool isDisabled = false;
if (action.action == DetailsMenuAction.Edit) {
- isDisabled = !((message.dateCreated?.toUtc().isWithin(DateTime.now().toUtc(), minutes: 15) ?? false));
+ isDisabled = !((message.dateCreated
+ ?.toUtc()
+ .isWithin(DateTime.now().toUtc(), minutes: 15) ??
+ false));
}
-
- Color color = isDisabled ? context.theme.colorScheme.properOnSurface.withOpacity(0.5) : context.theme.colorScheme.properOnSurface;
+
+ Color color = isDisabled
+ ? context.theme.colorScheme.properOnSurface.withOpacity(0.5)
+ : context.theme.colorScheme.properOnSurface;
return Padding(
- padding: EdgeInsets.only(top: kIsDesktop ? 20 : 0),
- child: IconButton(
- icon: Icon(action.nonIosIcon, color: color),
- onPressed: isDisabled ? null : action.onTap,
- tooltip: action.title,
- )
- );
+ padding: EdgeInsets.only(top: kIsDesktop ? 20 : 0),
+ child: IconButton(
+ icon: Icon(action.nonIosIcon, color: color),
+ onPressed: isDisabled ? null : action.onTap,
+ tooltip: action.title,
+ ));
}),
Padding(
- padding: EdgeInsets.only(top: kIsDesktop ? 20 : 0),
- child: PopupMenuButton(
- color: context.theme.colorScheme.properSurface,
- shape: ss.settings.skin.value != Skins.Material ? const RoundedRectangleBorder(
- borderRadius: BorderRadius.all(Radius.circular(20.0)),
- ) : null,
- onSelected: (int value) {
- allActions[value + numberToShow - 1].onTap?.call();
- },
- itemBuilder: (context) {
- return allActions.slice(numberToShow - 1).mapIndexed((index, action) {
- return PopupMenuItem(
- value: index,
- child: Text(
- action.title,
- style: context.textTheme.bodyLarge!.apply(color: context.theme.colorScheme.properOnSurface),
- ),
- );
- }).toList();
- }
- )
- )
+ padding: EdgeInsets.only(top: kIsDesktop ? 20 : 0),
+ child: PopupMenuButton(
+ color: context.theme.colorScheme.properSurface,
+ shape: ss.settings.skin.value != Skins.Material
+ ? const RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(Radius.circular(20.0)),
+ )
+ : null,
+ onSelected: (int value) {
+ allActions[value + numberToShow - 1].onTap?.call();
+ },
+ itemBuilder: (context) {
+ return allActions
+ .slice(numberToShow - 1)
+ .mapIndexed((index, action) {
+ return PopupMenuItem(
+ value: index,
+ child: Text(
+ action.title,
+ style: context.textTheme.bodyLarge!.apply(
+ color: context.theme.colorScheme.properOnSurface),
+ ),
+ );
+ }).toList();
+ }))
];
}
}
@@ -1442,14 +1814,16 @@ class ReactionDetails extends StatelessWidget {
child: Container(
alignment: Alignment.center,
height: 120,
- color: context.theme.colorScheme.properSurface.withAlpha(ss.settings.skin.value == Skins.iOS ? 150 : 255),
+ color: context.theme.colorScheme.properSurface
+ .withAlpha(ss.settings.skin.value == Skins.iOS ? 150 : 255),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: ListView.separated(
shrinkWrap: true,
physics: ThemeSwitcher.getScrollPhysics(),
scrollDirection: Axis.horizontal,
- findChildIndexCallback: (key) => findChildIndexByKey(reactions, key, (item) => item.guid),
+ findChildIndexCallback: (key) =>
+ findChildIndexByKey(reactions, key, (item) => item.guid),
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final message = reactions[index];
@@ -1458,7 +1832,8 @@ class ReactionDetails extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Padding(
- padding: const EdgeInsets.symmetric(horizontal: 25.0, vertical: 10),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 25.0, vertical: 10),
child: ContactAvatarWidget(
handle: message.handle,
borderThickness: 0.1,
@@ -1470,8 +1845,11 @@ class ReactionDetails extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
- message.isFromMe! ? ss.settings.userName.value : (message.handle?.displayName ?? "Unknown"),
- style: context.theme.textTheme.bodySmall!.copyWith(color: context.theme.colorScheme.properOnSurface),
+ message.isFromMe!
+ ? ss.settings.userName.value
+ : (message.handle?.displayName ?? "Unknown"),
+ style: context.theme.textTheme.bodySmall!.copyWith(
+ color: context.theme.colorScheme.properOnSurface),
),
),
if (ss.settings.hideNamesForReactions.value)
@@ -1483,7 +1861,9 @@ class ReactionDetails extends StatelessWidget {
width: 28,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(100),
- color: message.isFromMe! ? context.theme.colorScheme.primary : context.theme.colorScheme.properSurface,
+ color: message.isFromMe!
+ ? context.theme.colorScheme.primary
+ : context.theme.colorScheme.properSurface,
boxShadow: [
BoxShadow(
blurRadius: 1.0,
@@ -1493,21 +1873,31 @@ class ReactionDetails extends StatelessWidget {
),
child: Center(
child: Builder(builder: (context) {
- if (message.associatedMessageType == ReactionTypes.STICKERBACK) {
- var image = cvc(message.chat.target!).stickerData[message.guid]?[message.attachments[0]?.guid]?.$1;
- return image != null ? Padding(
- padding: const EdgeInsets.all(5),
- child: Image.memory(
- image,
- gaplessPlayback: true,
- cacheHeight: 200,
- filterQuality: FilterQuality.none,
- ),
- ) : const SizedBox.shrink();
+ if (message.associatedMessageType ==
+ ReactionTypes.STICKERBACK) {
+ var image = cvc(message.chat.target!)
+ .stickerData[message.guid]
+ ?[message.attachments[0]?.guid]
+ ?.$1;
+ return image != null
+ ? Padding(
+ padding: const EdgeInsets.all(5),
+ child: Image.memory(
+ image,
+ gaplessPlayback: true,
+ cacheHeight: 200,
+ filterQuality: FilterQuality.none,
+ ),
+ )
+ : const SizedBox.shrink();
}
final text = Text(
- ReactionTypes.reactionToEmoji[message.associatedMessageType] ?? message.associatedMessageEmoji ?? "X",
- style: const TextStyle(fontSize: 18, fontFamily: 'Apple Color Emoji'),
+ ReactionTypes.reactionToEmoji[
+ message.associatedMessageType] ??
+ message.associatedMessageEmoji ??
+ "X",
+ style: const TextStyle(
+ fontSize: 18, fontFamily: 'Apple Color Emoji'),
textAlign: TextAlign.center,
);
// rotate thumbs down to match iOS
diff --git a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart
index 5f4d73957d..bdfa199705 100644
--- a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart
+++ b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart
@@ -5,17 +5,16 @@ import 'dart:ui';
import 'package:async_task/async_task_extension.dart';
import 'package:audio_waveforms/audio_waveforms.dart';
-import 'package:bluebubbles/app/components/custom_text_editing_controllers.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/misc/tail_clipper.dart';
import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart';
import 'package:bluebubbles/helpers/helpers.dart';
import 'package:bluebubbles/database/models.dart';
import 'package:bluebubbles/services/rustpush/rustpush_service.dart';
import 'package:bluebubbles/services/services.dart';
+import 'package:bluebubbles/utils/attachment_mime_utils.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
-import 'package:mime_type/mime_type.dart';
import 'package:simple_animations/simple_animations.dart';
import 'package:tuple/tuple.dart';
@@ -76,6 +75,7 @@ class _SendAnimationState
final file = attachments[i];
String data = await DefaultAssetBundle.of(Get.context!).loadString("assets/rustpush/uti-map.json");
final utiMap = jsonDecode(data);
+ final attachmentMimeType = resolveAttachmentMimeType(file.name, file.path);
final message = Message(
text: "",
@@ -85,8 +85,8 @@ class _SendAnimationState
attachments: [
Attachment(
isOutgoing: true,
- mimeType: mime(file.path ?? file.name),
- uti: utiMap[mime(file.path ?? file.name)] ?? "public.data",
+ mimeType: attachmentMimeType,
+ uti: utiMap[attachmentMimeType] ?? "public.data",
bytes: file.bytes,
transferName: file.name,
totalBytes: file.size,
@@ -148,7 +148,7 @@ class _SendAnimationState
],
);
_message.generateTempGuid();
- outq.queue(OutgoingItem(
+ await outq.queue(OutgoingItem(
type: QueueType.sendMessage,
chat: controller.chat,
message: _message,
@@ -253,4 +253,4 @@ class _SendAnimationState
),
);
}
-}
\ No newline at end of file
+}
diff --git a/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart b/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart
index 47252f5d05..66e83df3d0 100644
--- a/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart
+++ b/lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart
@@ -6,7 +6,6 @@ import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:bluebubbles/app/components/custom/custom_bouncing_scroll_physics.dart';
import 'package:bluebubbles/app/components/custom_text_editing_controllers.dart';
import 'package:bluebubbles/app/layouts/conversation_details/dialogs/timeframe_picker.dart';
-import 'package:bluebubbles/app/layouts/conversation_view/dialogs/custom_mention_dialog.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/media_picker/text_field_attachment_picker.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/send_animation.dart';
import 'package:bluebubbles/app/layouts/conversation_view/widgets/text_field/picked_attachments_holder.dart';
@@ -40,7 +39,6 @@ import 'package:pasteboard/pasteboard.dart';
import 'package:path/path.dart' hide context;
import 'package:permission_handler/permission_handler.dart';
import 'package:supercharged/supercharged.dart';
-import 'package:tuple/tuple.dart';
import 'package:universal_io/io.dart';
class ConversationTextField extends CustomStateful {
@@ -343,17 +341,42 @@ class ConversationTextFieldState extends CustomState attachment.path != null)
+ .map((attachment) => attachment.path!)
+ .toList();
+ chat.save(
+ updateTextFieldText: true,
+ updateTextFieldAnnotations: true,
+ updateTextFieldAttachments: true,
);
+
+ try {
+ await controller.send(
+ controller.pickedApp.value?.$1 != null ? [controller.pickedApp.value!.$1!] : controller.pickedAttachments,
+ controller.textController.getFinalAnnotations(),
+ controller.subjectTextController.text,
+ controller.replyToMessage?.item1.threadOriginatorGuid ?? controller.replyToMessage?.item1.guid,
+ controller.replyToMessage?.item2,
+ effect,
+ controller.pickedApp.value?.$2,
+ false,
+ controller.scheduledDate.value
+ );
+ } catch (error, stack) {
+ Logger.error(
+ "Failed to prepare outgoing message; composer preserved",
+ error: error,
+ trace: stack,
+ );
+ showSnackbar("Message not sent", "Your text was preserved. Please try again.");
+ return;
+ }
controller.pickedApp.value = null;
controller.pickedAttachments.clear();
controller.textController.clear();
@@ -851,7 +874,8 @@ class TextFieldComponentState extends State {
final txtController = controller?.textController ?? textController;
final subjController = controller?.subjectTextController ?? subjectTextController;
return Focus(
- onKeyEvent: (_, ev) => handleKey(_, ev, context, isChatCreator),
+ onKeyEvent: (node, ev) =>
+ handleKey(node, ev, context, isChatCreator),
child: Padding(
padding: const EdgeInsets.only(right: 5.0),
child: ValueListenableBuilder(
@@ -1143,13 +1167,8 @@ class TextFieldComponentState extends State {
final parts = mwc(message).parts;
final part = parts.filter((p) => p.text?.isNotEmpty ?? false).lastOrNull;
if (part != null) {
- final FocusNode? node = kIsDesktop || kIsWeb ? FocusNode() : null;
-
- var e = MentionTextEditingController(text: "", focusNode: node);
- e.importMessagePart(part);
-
- controller!.editing.add(Tuple3(message, part, e));
- node?.requestFocus();
+ final editController = controller!.startEditing(message, part);
+ editController.focusNode?.requestFocus();
return KeyEventResult.handled;
}
}
diff --git a/lib/app/layouts/findmy/findmy_page.dart b/lib/app/layouts/findmy/findmy_page.dart
index 0dbf01cf7c..76376a7c62 100644
--- a/lib/app/layouts/findmy/findmy_page.dart
+++ b/lib/app/layouts/findmy/findmy_page.dart
@@ -4,6 +4,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:bitsdojo_window/bitsdojo_window.dart';
+import 'package:collection/collection.dart';
import 'package:bluebubbles/app/components/avatars/contact_avatar_widget.dart';
import 'package:bluebubbles/app/layouts/findmy/findmy_location_clipper.dart';
import 'package:bluebubbles/app/layouts/findmy/findmy_pin_clipper.dart';
@@ -29,12 +30,46 @@ import 'package:flutter_map_marker_popup/flutter_map_marker_popup.dart';
import 'package:get/get.dart' hide Response;
import 'package:latlong2/latlong.dart';
import 'package:maps_launcher/maps_launcher.dart';
+import 'package:permission_handler/permission_handler.dart';
import 'package:sliding_up_panel2/sliding_up_panel2.dart';
import 'package:tuple/tuple.dart';
import 'package:universal_io/io.dart';
import 'package:bluebubbles/src/rust/api/api.dart' as api;
import 'package:url_launcher/url_launcher.dart';
+@visibleForTesting
+bool canPlayFindMySound({required String? deviceId, required bool isCloudManaged}) {
+ return deviceId != null && deviceId.isNotEmpty && isCloudManaged;
+}
+
+@visibleForTesting
+bool canPlayNearbyFindMySound({required bool isAccessory, required bool isAndroid}) {
+ return isAccessory && isAndroid;
+}
+
+@visibleForTesting
+bool canScanNearbyFindMyTrackers({required bool isAndroid}) => isAndroid;
+
+@visibleForTesting
+String nearbyTrackerSignalLabel(Map tracker) {
+ final signal = tracker["signal"]?.toString() ?? "unknown";
+ final rssi = tracker["rssi"];
+ final readableSignal = signal.isEmpty ? "Unknown" : "${signal[0].toUpperCase()}${signal.substring(1)}";
+ return rssi is num ? "$readableSignal signal (${rssi.toInt()} dBm)" : "$readableSignal signal";
+}
+
+@visibleForTesting
+String findMyCloudFailureMessage(Object error) {
+ final message = error.toString().toLowerCase();
+ if (message.contains("relay device offline")) {
+ return "Your relay device is offline. Cloud Find My will resume when it reconnects.";
+ }
+ if (error is TimeoutException || message.contains("timeoutexception")) {
+ return "Cloud Find My timed out. Check the relay connection and try again.";
+ }
+ return "Cloud Find My is unavailable right now.";
+}
+
class FindMyPage extends StatefulWidget {
FindMyPage({super.key, this.defaultFriend});
@@ -67,6 +102,13 @@ class _FindMyPageState extends OptimizedState with SingleTickerProvi
bool refreshing2 = false;
bool canRefresh = false;
bool isInClique = true;
+ final Set soundingDevices = {};
+ bool nearbyAccessoryBusy = false;
+ bool locationsRequestInFlight = false;
+ bool currentLocationRequestInFlight = false;
+ String? cloudFindMyError;
+ DateTime? automaticCloudRetryAfter;
+ Completer? fmipRequest;
Map<(double, double), Address> cachedAddresses = {};
@@ -78,6 +120,209 @@ class _FindMyPageState extends OptimizedState with SingleTickerProvi
api.FindMyFriendsClientDefaultAnisetteProvider? fmfClient;
api.FindMyPhoneClientDefaultAnisetteProvider? fmipClient;
+ Future withFmipLock(Future Function() operation) async {
+ while (fmipRequest != null) {
+ await fmipRequest!.future;
+ }
+ final request = Completer();
+ fmipRequest = request;
+ try {
+ return await operation();
+ } finally {
+ request.complete();
+ if (identical(fmipRequest, request)) fmipRequest = null;
+ }
+ }
+
+ Future playSound(FindMyDevice device) async {
+ final deviceId = device.id;
+ if (deviceId == null || deviceId.isEmpty || fmipClient == null || soundingDevices.contains(deviceId)) return;
+
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text("Play Sound?"),
+ content: Text(
+ "${ss.settings.redactedMode.value ? "This device" : (device.name ?? "This device")} will play a Find My alert.",
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text("Cancel"),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text("Play Sound"),
+ ),
+ ],
+ ),
+ );
+ if (confirmed != true || !mounted) return;
+
+ setState(() => soundingDevices.add(deviceId));
+ try {
+ await withFmipLock(() => api.playFindMySound(
+ config: pushService.state!.osConfig,
+ client: fmipClient!,
+ deviceId: deviceId,
+ ));
+ if (mounted) showSnackbar("Find My", "Sound request sent.");
+ } catch (e, s) {
+ Logger.warn("Find My Play Sound request failed", error: e, trace: s);
+ if (mounted) showSnackbar("Error", "Could not play sound on this device.");
+ } finally {
+ if (mounted) setState(() => soundingDevices.remove(deviceId));
+ }
+ }
+
+ String nearbyTrackerLabel(String protocol) {
+ switch (protocol) {
+ case "dult":
+ return "Compatible tracker";
+ case "find_my":
+ return "Find My accessory";
+ case "airtag":
+ return "AirTag";
+ default:
+ return "Nearby tracker";
+ }
+ }
+
+ Future playNearbyAccessorySound() async {
+ if (nearbyAccessoryBusy || !Platform.isAndroid) return;
+
+ final permissions = await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
+ if (permissions.values.any((status) => !status.isGranted)) {
+ if (mounted) {
+ final permanentlyDenied = permissions.values.any((status) => status.isPermanentlyDenied);
+ showSnackbar(
+ "Bluetooth required",
+ permanentlyDenied
+ ? "Enable Nearby devices for OpenBubbles in Android settings."
+ : "Allow nearby-device access to find and play a tracker sound.",
+ );
+ }
+ return;
+ }
+
+ if (mounted) setState(() => nearbyAccessoryBusy = true);
+ try {
+ final raw = await mcs.invokeMethod("scanNearbyFindMyAccessories", {"scanDurationMs": 8000});
+ final trackers = (raw as List?)
+ ?.whereType