diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6882a3be --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# patch-package patches must keep the patched files' line endings byte for byte +# (react-native-android-widget ships CRLF sources). +patches/*.patch -text diff --git a/.github/actions/e2e-ios-leg/action.yml b/.github/actions/e2e-ios-leg/action.yml new file mode 100644 index 00000000..6be8ec0e --- /dev/null +++ b/.github/actions/e2e-ios-leg/action.yml @@ -0,0 +1,92 @@ +name: iOS simulator test leg +description: Boot a simulator, install the app and run one suite of Maestro flows + +inputs: + suite: + description: Folder under .maestro (signin or features) + required: true + name: + description: Leg name, used for the artifact + required: true + app_run_id: + description: Take the simulator app from this run instead of the current one + required: false + default: "" + flows: + description: Comma list of flow names to run instead of the whole suite + required: false + default: "" + playwright: + description: Install Playwright for the helper's webmail pairing + required: false + default: "false" + +runs: + using: composite + steps: + - name: Download the simulator app (this run) + if: inputs.app_run_id == '' + uses: actions/download-artifact@v4 + with: + name: simulator-app + path: ${{ runner.temp }} + + - name: Download the simulator app (earlier run) + if: inputs.app_run_id != '' + uses: actions/download-artifact@v4 + with: + name: simulator-app + run-id: ${{ inputs.app_run_id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }} + + - name: Unpack the simulator app + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/app" + tar -C "$RUNNER_TEMP/app" -xzf "$RUNNER_TEMP/simulator-app.tar.gz" + echo "APP=$RUNNER_TEMP/app/BulwarkMobile.app" >> "$GITHUB_ENV" + + - name: Boot a simulator + shell: bash + run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP" + + - name: Install Maestro + shell: bash + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Install Playwright for the helper + if: inputs.playwright == 'true' + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/pw" + cd "$RUNNER_TEMP/pw" + npm init -y >/dev/null + npm install --no-audit --no-fund playwright@1.59.1 >/dev/null + npx playwright install chromium >/dev/null + echo "PLAYWRIGHT_DIR=$RUNNER_TEMP/pw/node_modules" >> "$GITHUB_ENV" + + - name: Start the test helper + shell: bash + run: | + mkdir -p "$OUT/helper" + HELPER_SHOTS="$OUT/helper" nohup node scripts/e2e/helper.mjs > "$OUT/helper/helper.log" 2>&1 & + for i in $(seq 1 20); do + curl -fs http://127.0.0.1:8787/health >/dev/null && break + sleep 0.5 + done + curl -fs http://127.0.0.1:8787/health + + - name: Run the flows + shell: bash + run: bash scripts/e2e/run-flows.sh "${{ inputs.suite }}" "${{ inputs.flows }}" + + - name: Upload screenshots, recording and logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-ios-${{ inputs.name }} + path: e2e-output/ + if-no-files-found: ignore diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 00000000..1920d8ae --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,349 @@ +name: iOS simulator tests + +# Builds the app for the iOS Simulator (no signing needed) and runs the +# Maestro flows in .maestro/ on iPhone and iPad simulators: +# +# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad +# - features: the app feature by feature (.maestro/features), on iPhone and +# iPad in light and dark, plus the iPad in landscape (split layout) +# +# Each leg signs in with its own account on the test server (e2e-* accounts +# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox. +# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do +# itself: TOTP codes, fresh webmail pairing links, mail sent by userb. +# +# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit +# report. They are public; see scripts/e2e/run-flows.sh for how secrets are +# kept out of them. + +on: + workflow_dispatch: + inputs: + app_run_id: + description: "Reuse the simulator app from this earlier run instead of building (run id)" + type: string + required: false + legs: + description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)" + type: string + default: all + flows: + description: "Only these flows (comma list of file names without .yaml); empty runs all" + type: string + required: false + perf_base_run_id: + description: "perf leg: compare against the simulator app of this run (run id)" + type: string + required: false + perf_rounds: + description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)" + type: string + default: "5" + required: false + release: + types: [published] + +concurrency: + # Per branch and set of legs: a perf run doesn't cancel a feature run. + group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }} + cancel-in-progress: true + +env: + EXPO_NO_TELEMETRY: "1" + MAESTRO_CLI_NO_ANALYTICS: "1" + # The XCTest driver takes minutes to come up on a cold runner. + MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000" + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + signin: ${{ steps.plan.outputs.signin }} + features: ${{ steps.plan.outputs.features }} + steps: + - id: plan + env: + LEGS: ${{ inputs.legs || 'all' }} + run: | + node -e ' + const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean); + const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name); + const signin = ["phone", "tablet"] + .map((device) => ({ name: `signin-${device}`, device })) + .filter((l) => want(l.name, "signin")); + const features = [ + { name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" }, + { name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" }, + { name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" }, + { name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" }, + { name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" }, + ].filter((l) => want(l.name, "features")); + // Push on iOS (.maestro/push): only on request, the app needs the + // notification extension (plugins/ios-push). + if (legs.includes("push")) { + features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" }); + } + const fs = require("fs"); + fs.appendFileSync(process.env.GITHUB_OUTPUT, + `signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`); + console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) }); + ' + + build: + if: inputs.app_run_id == '' + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Cache CocoaPods + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: pods-${{ runner.os }}- + + - name: Install JS dependencies + run: npm ci + + - name: Generate the iOS project + env: + # Simulator device tokens belong to the APNs sandbox. + APS_ENVIRONMENT: development + run: npx expo prebuild --platform ios --clean + + - name: Build for the simulator + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # The bundle times each module's evaluation for the perf leg, and + # Metro writes which id is which module (metro.config.js; a branch + # without that support ignores both). + BULWARK_MODULE_TIMINGS: '1' + BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json + run: | + WORKSPACE=$(ls -d ios/*.xcworkspace | head -1) + SCHEME=$(basename "$WORKSPACE" .xcworkspace) + mkdir -p e2e-build + # Release bundles the JS into the app, so no Metro server is needed. + # arm64 only: the runner is Apple silicon, and a second simulator + # architecture would double the build time. + if ! xcodebuild build \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ + -configuration Release \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + ARCHS=arm64 \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + PROVISIONING_PROFILE_SPECIFIER= \ + > e2e-build/xcodebuild.log 2>&1; then + grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true + tail -n 80 e2e-build/xcodebuild.log + exit 1 + fi + # Signed ad hoc ("Sign to Run Locally"): no certificate needed, and + # Xcode embeds the simulator entitlements the keychain needs. An + # unsigned build has none, and every expo-secure-store call fails + # with "A required entitlement isn't present". + APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app + echo "Simulated entitlements:" + xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true + codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true + # Packed as a tar: artifacts drop the executable bit, which would + # leave a reused app unable to launch. + tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app + if [ -f "$RUNNER_TEMP/module-map.json" ]; then + tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json + else + echo "no module map (branch without module timings)" + fi + gzip -f "$RUNNER_TEMP/simulator-app.tar" + ls -la "$RUNNER_TEMP/simulator-app.tar.gz" + + - name: Upload the simulator app + uses: actions/upload-artifact@v4 + with: + name: simulator-app + path: ${{ runner.temp }}/simulator-app.tar.gz + if-no-files-found: error + + - name: Upload the build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log + path: e2e-build/ + + signin: + needs: [plan, build] + if: >- + always() && !cancelled() && + (needs.build.result == 'success' || needs.build.result == 'skipped') && + fromJson(needs.plan.outputs.signin).include[0] != null + runs-on: macos-latest + timeout-minutes: 150 + permissions: + contents: read + actions: read + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.signin) }} + env: + OUT: ${{ github.workspace }}/e2e-output + DEVICE: ${{ matrix.device }} + APPEARANCE: light + ORIENTATION: portrait + E2E_SERVER: ${{ vars.E2E_SERVER }} + E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org + E2E_ACCOUNT: e2e-login-${{ matrix.device }} + E2E_ACCOUNT2: e2e-second-${{ matrix.device }} + E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }} + E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} + E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }} + E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/e2e-ios-leg + with: + suite: signin + app_run_id: ${{ inputs.app_run_id }} + flows: ${{ inputs.flows }} + playwright: "true" + name: ${{ matrix.name }} + + features: + needs: [plan, build] + if: >- + always() && !cancelled() && + (needs.build.result == 'success' || needs.build.result == 'skipped') && + fromJson(needs.plan.outputs.features).include[0] != null + runs-on: macos-latest + timeout-minutes: 120 + permissions: + contents: read + actions: read + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.plan.outputs.features) }} + env: + OUT: ${{ github.workspace }}/e2e-output + DEVICE: ${{ matrix.device }} + APPEARANCE: ${{ matrix.appearance }} + ORIENTATION: ${{ matrix.orientation }} + E2E_SERVER: ${{ vars.E2E_SERVER }} + E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org + E2E_ACCOUNT: ${{ matrix.account }} + E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} + E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }} + E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/e2e-ios-leg + with: + suite: ${{ matrix.suite || 'features' }} + app_run_id: ${{ inputs.app_run_id }} + flows: ${{ inputs.flows }} + playwright: "false" + name: ${{ matrix.name }} + + # Part 2 of docs/ios-test-2026-10.md: timing marks from the app + # (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app + # of this run (or app_run_id) with the app of perf_base_run_id, alternating + # them on one simulator so both sides share the runner. + perf: + needs: [plan, build] + if: >- + always() && !cancelled() && + (needs.build.result == 'success' || needs.build.result == 'skipped') && + contains(inputs.legs, 'perf') + runs-on: macos-latest + timeout-minutes: 120 + permissions: + contents: read + actions: read + env: + OUT: ${{ github.workspace }}/e2e-output + DEVICE: phone + APPEARANCE: light + ORIENTATION: portrait + E2E_SERVER: ${{ vars.E2E_SERVER }} + E2E_ACCOUNT: e2e-bulk + E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} + steps: + - uses: actions/checkout@v4 + + - name: Download the app under test (this run) + if: inputs.app_run_id == '' + uses: actions/download-artifact@v4 + with: + name: simulator-app + path: ${{ runner.temp }}/cand + - name: Download the app under test (earlier run) + if: inputs.app_run_id != '' + uses: actions/download-artifact@v4 + with: + name: simulator-app + run-id: ${{ inputs.app_run_id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/cand + - name: Download the baseline app + if: inputs.perf_base_run_id != '' + uses: actions/download-artifact@v4 + with: + name: simulator-app + run-id: ${{ inputs.perf_base_run_id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/base + + - name: Unpack + run: | + for side in cand base; do + [ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue + mkdir -p "$RUNNER_TEMP/$side/app" + tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz" + done + echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV" + + - name: Boot a simulator + run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP" + + - name: Install Maestro + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Sign in once + run: | + mkdir -p "$OUT/screenshots" + cd "$OUT/screenshots" + # Warm-up for the driver, then the sign-in (keychain survives reinstalls). + maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml" + maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \ + -e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml" + + - name: Measure + run: | + APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app") + if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then + APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}") + fi + node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \ + --flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT" + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-ios-perf + path: e2e-output/ + if-no-files-found: ignore diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml index 387bf24e..72f794e0 100644 --- a/.github/workflows/release-android.yml +++ b/.github/workflows/release-android.yml @@ -1,4 +1,4 @@ -name: Release Android APK +name: Release Android on: release: @@ -23,6 +23,8 @@ jobs: timeout-minutes: 45 permissions: contents: write + id-token: write + attestations: write steps: # Fail in seconds instead of after a 20-minute build. There is no # unsigned or debug-signed fallback: anything signed with the debug key @@ -135,7 +137,7 @@ jobs: ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew - ./gradlew assembleRelease --no-daemon + ./gradlew assembleGithubRelease --no-daemon - name: Put apksigner on the PATH run: | @@ -186,7 +188,7 @@ jobs: # Oldest signer first: apksigner uses it for v2, and the rotated key # for v3 from API 28. Without --rotation-min-sdk-version it would # only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key. - APK=android/app/build/outputs/apk/release/app-release.apk + APK=android/app/build/outputs/apk/github/release/app-github-release.apk apksigner sign \ --v4-signing-enabled false \ --ks android/app/debug.keystore --ks-key-alias androiddebugkey \ @@ -208,7 +210,7 @@ jobs: ROTATED: ${{ steps.rotation.outputs.rotated }} run: | set -euo pipefail - APK=android/app/build/outputs/apk/release/app-release.apk + APK=android/app/build/outputs/apk/github/release/app-github-release.apk apksigner verify --verbose --print-certs "$APK" # build-tools 36 prints "Signer #1 certificate SHA-256 digest: …", # 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …". @@ -246,11 +248,12 @@ jobs: run: | set -euo pipefail VERSION=$(cat VERSION | tr -d '[:space:]') - SRC=android/app/build/outputs/apk/release/app-release.apk + SRC=android/app/build/outputs/apk/github/release/app-github-release.apk DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk" cp "$SRC" "$DEST" + sha256sum "$DEST" > "$DEST.sha256" - MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt + MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt MAPPING="" if [ -f "$MAPPING_SRC" ]; then MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt" @@ -266,10 +269,18 @@ jobs: { echo "files<> "$GITHUB_OUTPUT" + # Ties the APK to this commit and workflow run, which the signing key + # alone does not: `gh attestation verify --repo bulwarkmail/native`. + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: ${{ steps.artifact.outputs.path }} + - uses: actions/upload-artifact@v4 with: name: ${{ steps.artifact.outputs.path }} @@ -288,12 +299,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # A manual run with no tag only builds: it must not replace the APK + # of the latest release with one from another commit. if [ "${{ github.event_name }}" = "release" ]; then TAG="${{ github.event.release.tag_name }}" - elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then - TAG="${{ github.event.inputs.attach_to_release_tag }}" else - TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true) + TAG="${{ github.event.inputs.attach_to_release_tag }}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" if [ -n "$TAG" ]; then @@ -308,3 +319,150 @@ jobs: with: tag_name: ${{ steps.target_tag.outputs.tag }} files: ${{ steps.artifact.outputs.files }} + + # The Google Play bundle (docs/android-release.md, "Google Play"). It is + # signed with the release key, which Play knows as the upload key; Play + # re-signs what it delivers with its own app signing key. A separate job so + # the GitHub APK isn't held up by the three-ABI native build. + play-bundle: + runs-on: ubuntu-latest + timeout-minutes: 75 + permissions: + contents: read + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + steps: + - name: Require the release signing secrets + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + missing="" + [ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64" + [ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD" + [ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS" + [ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD" + if [ -n "$missing" ]; then + echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md." + exit 1 + fi + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools + + - name: Install JS dependencies + run: npm ci + + - name: Decode and check the upload key + id: key + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + set -euo pipefail + KEYSTORE="$RUNNER_TEMP/release.keystore" + CERT="$RUNNER_TEMP/release-cert.der" + echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE" + keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \ + -storepass:env KEYSTORE_PASSWORD -file "$CERT" + CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1) + if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then + echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key." + exit 1 + fi + echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT" + echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT" + + # Play splits a bundle per ABI, so the extra architectures cost a phone + # nothing and let 32-bit ARM phones and x86_64 Chromebooks install it. + - name: Build the Play bundle + working-directory: android + env: + ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + chmod +x ./gradlew + ./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64 + + # Play rejects a bundle signed with another key than the registered + # upload key, and its policies reject a self-updater and the media + # permission. Catch all three here instead of in the Play Console. + - name: Check the bundle + env: + RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }} + run: | + set -euo pipefail + AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab + SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f') + echo "Bundle signer SHA-256: $SIGNER" + if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then + echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'." + exit 1 + fi + MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n') + if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then + echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile." + exit 1 + fi + for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do + if grep -q "android.permission.$permission" <<< "$MANIFEST"; then + echo "::error title=Permission not allowed on Play::The Play bundle declares $permission." + exit 1 + fi + done + + - name: Name the bundle and R8 mapping with version + id: artifact + run: | + set -euo pipefail + VERSION=$(cat VERSION | tr -d '[:space:]') + AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab" + MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt" + cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB" + cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING" + echo "aab=$AAB" >> "$GITHUB_OUTPUT" + echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.aab }} + path: | + ${{ steps.artifact.outputs.aab }} + ${{ steps.artifact.outputs.mapping }} + if-no-files-found: error + + # Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API + # uploads after the first bundle was uploaded by hand in the Console). + # Every release goes out to the internal and closed (beta) testers right + # away; closed testing may wait for Google's review first. + - name: Upload to Google Play (internal and closed testing) + if: env.PLAY_SERVICE_ACCOUNT_JSON != '' + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: org.bulwarkmail.mobile + releaseFiles: ${{ steps.artifact.outputs.aab }} + mappingFile: ${{ steps.artifact.outputs.mapping }} + releaseName: ${{ steps.artifact.outputs.version }} + tracks: internal,beta + status: completed diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index 33d903cc..4c708490 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -25,6 +25,9 @@ jobs: timeout-minutes: 90 permissions: contents: write + outputs: + version: ${{ steps.export.outputs.version }} + uploaded: ${{ steps.upload.outputs.done }} env: EXPO_NO_TELEMETRY: "1" # App Store Connect refuses a CFBundleVersion it has already seen for this @@ -56,9 +59,6 @@ jobs: - name: Install JS dependencies run: npm ci - - name: Generate the iOS project - run: npx expo prebuild --platform ios --clean - - name: Check for signing credentials id: creds env: @@ -66,14 +66,69 @@ jobs: ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} TEAM: ${{ secrets.APPLE_TEAM_ID }} PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }} run: | if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then + if [ -z "$NSE_PROFILE" ]; then + # The notification service extension (plugins/ios-push) is its + # own App ID and needs its own App Store profile. + echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed." + exit 1 + fi echo "signed=true" >> "$GITHUB_OUTPUT" else echo "signed=false" >> "$GITHUB_OUTPUT" echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced." fi + - name: Install the provisioning profiles + if: steps.creds.outputs.signed == 'true' + id: profiles + env: + APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }} + run: | + # Xcode moved the profile directory in 16; write both so this does not + # silently stop working on a runner image bump. + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles + + # One App Store profile per target: the app and its notification + # service extension (plugins/ios-push). + for side in app nse; do + if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi + PROFILE="$RUNNER_TEMP/$side.mobileprovision" + echo "$DATA" | base64 -d > "$PROFILE" + + # A .mobileprovision is a CMS-signed plist, so it has to be decoded + # before anything inside it can be read. + PLIST=$(security cms -D -i "$PROFILE") + UUID=$(echo "$PLIST" | plutil -extract UUID raw -) + NAME=$(echo "$PLIST" | plutil -extract Name raw -) + # application-identifier is "TEAMID.bundle.id" - deriving the bundle + # ID from the profile keeps ExportOptions honest if app.config.js + # changes. + APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -) + BUNDLE_ID="${APP_ID#*.}" + EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -) + + cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision + cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision + + echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT" + echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT" + echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT" + echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY" + done + + - name: Generate the iOS project + env: + # plugins/ios-push names each target's profile: the command line + # can't, it would give every target the same one. + IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }} + IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }} + run: npx expo prebuild --platform ios --clean + - name: Resolve workspace and scheme id: xcode run: | @@ -138,38 +193,6 @@ jobs: echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 - - name: Install the provisioning profile - if: steps.creds.outputs.signed == 'true' - id: profile - env: - PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} - run: | - PROFILE="$RUNNER_TEMP/profile.mobileprovision" - echo "$PROFILE_BASE64" | base64 -d > "$PROFILE" - - # A .mobileprovision is a CMS-signed plist, so it has to be decoded - # before anything inside it can be read. - PLIST=$(security cms -D -i "$PROFILE") - UUID=$(echo "$PLIST" | plutil -extract UUID raw -) - NAME=$(echo "$PLIST" | plutil -extract Name raw -) - # application-identifier is "TEAMID.bundle.id" - deriving the bundle ID - # from the profile keeps ExportOptions honest if app.config.js changes. - APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -) - BUNDLE_ID="${APP_ID#*.}" - EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -) - - # Xcode moved the profile directory in 16; write both so this does not - # silently stop working on a runner image bump. - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles - cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision - cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision - - echo "uuid=$UUID" >> "$GITHUB_OUTPUT" - echo "name=$NAME" >> "$GITHUB_OUTPUT" - echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT" - echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY" - - name: Archive if: steps.creds.outputs.signed == 'true' env: @@ -189,8 +212,7 @@ jobs: -archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM="$TEAM_ID" \ - CODE_SIGN_IDENTITY="Apple Distribution" \ - PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}" + CODE_SIGN_IDENTITY="Apple Distribution" # Signing failures report a symptom ("no profiles found") rather than the # setting that caused it, and a re-run costs ~30 minutes. Dump the @@ -212,8 +234,10 @@ jobs: id: export env: TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - PROFILE_NAME: ${{ steps.profile.outputs.name }} - BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }} + PROFILE_NAME: ${{ steps.profiles.outputs.app_name }} + BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }} + NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }} + NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }} run: | cat > "$RUNNER_TEMP/ExportOptions.plist" < @@ -227,6 +251,7 @@ jobs: provisioningProfiles ${BUNDLE_ID}${PROFILE_NAME} + ${NSE_BUNDLE_ID}${NSE_PROFILE_NAME} uploadSymbols destinationexport @@ -254,6 +279,7 @@ jobs: if-no-files-found: error - name: Upload to TestFlight + id: upload # The IPA is not attached to the GitHub release: without an Apple device # provisioned for it, it is not installable, and TestFlight is the only # distribution channel for it. @@ -271,4 +297,35 @@ jobs: --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" xcrun altool --upload-app --type ios --file "$IPA" \ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + echo "done=true" >> "$GITHUB_OUTPUT" echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight." + + # Puts the upload on the public TestFlight link (README): waits for App Store + # Connect to process it, adds it to the external group and submits it for + # beta review. On Linux, because most of this job is waiting. + public-testflight: + needs: build + if: needs.build.outputs.uploaded == 'true' + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install the App Store Connect API key + env: + ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + run: | + mkdir -p ~/private_keys + echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 + chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 + + - name: Add the build to the public group and submit it for beta review + env: + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}" diff --git a/.maestro/features/01-folders.yaml b/.maestro/features/01-folders.yaml new file mode 100644 index 00000000..05925db9 --- /dev/null +++ b/.maestro/features/01-folders.yaml @@ -0,0 +1,36 @@ +# Mailbox list and folders: the drawer (or docked sidebar) lists the role +# folders and custom ones; opening a folder shows its messages. +appId: org.bulwarkmail.mobile +name: Folders +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- takeScreenshot: folders-01-inbox +- assertVisible: ".*Your verification code.*" +- runFlow: ../subflows/open-drawer.yaml +- takeScreenshot: folders-02-drawer +- assertVisible: "Inbox([ ,].*)?" +- assertVisible: "Sent([ ,].*)?" +- assertVisible: "Drafts([ ,].*)?" +- assertVisible: "Archive([ ,].*)?" +# Parent folders read "Collapse, Projects" (the chevron comes first). +- assertVisible: ".*Projects.*" +- tapOn: "Archive([ ,].*)?" +- extendedWaitUntil: + visible: ".*Invoice 2025-12.*" + timeout: 20000 +- takeScreenshot: folders-03-archive +- runFlow: ../subflows/open-drawer.yaml +# Projects/Alpha holds the seeded thread. +- runFlow: + when: + notVisible: "Alpha([ ,].*)?" + commands: + - tapOn: "Expand, Projects.*" +- tapOn: "Alpha([ ,].*)?" +- extendedWaitUntil: + visible: ".*Project Alpha kickoff.*" + timeout: 20000 +- takeScreenshot: folders-04-alpha +- runFlow: ../subflows/go-to-inbox.yaml diff --git a/.maestro/features/02-read-messages.yaml b/.maestro/features/02-read-messages.yaml new file mode 100644 index 00000000..15e76c40 --- /dev/null +++ b/.maestro/features/02-read-messages.yaml @@ -0,0 +1,75 @@ +# Opening messages: plain text, HTML with blocked remote content, a thread, +# and a PDF attachment in the in-app preview. +appId: org.bulwarkmail.mobile +name: Read messages +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml + +# Plain text +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: Grüße aus München +- extendedWaitUntil: + visible: ".*Jürgen.*" + timeout: 10000 +- takeScreenshot: read-01-plain +- runFlow: ../subflows/close-message.yaml + +# HTML newsletter +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: Weekly digest +- extendedWaitUntil: + visible: ".*5 new articles.*|Weekly digest" + timeout: 20000 +- takeScreenshot: read-02-html +- runFlow: + when: + visible: "Load images" + commands: + - tapOn: "Load images" + - takeScreenshot: read-03-html-images +- runFlow: ../subflows/close-message.yaml + +# Attachment and its preview +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: Q3 report +- extendedWaitUntil: + visible: ".*q3-report.pdf.*" + timeout: 20000 +- takeScreenshot: read-04-attachment +# The message's attachment chip, labelled with the file name alone (on the +# iPad the list row next to it mentions the name in its longer label). +- tapOn: "q3-report.pdf" +- extendedWaitUntil: + visible: "Share" + timeout: 30000 +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: read-05-attachment-preview +- assertVisible: "q3-report.pdf" +- tapOn: "Close" +# Back on the message (the iPad pane has a quick reply instead of Reply All). +- extendedWaitUntil: + notVisible: "Share" + timeout: 10000 +- assertVisible: ".*q3-report.pdf.*" +- runFlow: ../subflows/close-message.yaml + +# A thread (Projects/Alpha) +- runFlow: + file: ../subflows/go-to-folder.yaml + env: + FOLDER: Alpha +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: Project Alpha kickoff +- takeScreenshot: read-06-thread +- runFlow: ../subflows/go-to-inbox.yaml diff --git a/.maestro/features/03-compose-send.yaml b/.maestro/features/03-compose-send.yaml new file mode 100644 index 00000000..771f4f0e --- /dev/null +++ b/.maestro/features/03-compose-send.yaml @@ -0,0 +1,101 @@ +# Compose a new message and send it to userb; userb's mailbox (checked over +# JMAP) must get it. +appId: org.bulwarkmail.mobile +name: Compose and send +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()} +- tapOn: "Compose" +- extendedWaitUntil: + visible: "New Message" + timeout: 15000 +- tapOn: "Recipient email addresses" +- inputText: "userb@example.org" +- pressKey: Enter +- tapOn: + text: "Subject" + index: 1 +- inputText: ${output.subject} +- runFlow: + file: ../subflows/compose-body.yaml + env: + FROM_SUBJECT: "yes" + BODY: "Hello from the iOS simulator test." +- takeScreenshot: compose-01-filled + +# The attach sheet offers its options one by one (it used to be a single +# accessibility element, and picking one opened no picker on iOS). +- tapOn: "Attach" +- extendedWaitUntil: + visible: "Photos & Videos" + timeout: 10000 +- assertVisible: "Camera" +- assertVisible: "Files" +- takeScreenshot: compose-02-attach-sheet +- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'} +# The system photo picker runs in another process: XCTest sees none of its +# elements, but taps by position reach it. Pick the first photo (the app +# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in +# landscape lays the picker out differently, so it only closes the sheet. +- runFlow: + when: + true: ${!output.pickPhoto} + commands: + - tapOn: "Cancel" +- runFlow: + when: + true: ${output.pickPhoto} + commands: + - tapOn: "Photos & Videos" + - extendedWaitUntil: + notVisible: "Draft saved|New Message" + timeout: 10000 + optional: true + - waitForAnimationToEnd: + timeout: 5000 + - takeScreenshot: compose-03-photo-picker + - runFlow: + when: + true: ${DEVICE == 'tablet'} + commands: + - tapOn: + point: "44%,33%" + - waitForAnimationToEnd: + timeout: 2000 + - tapOn: + point: "90%,26%" + - runFlow: + when: + true: ${DEVICE != 'tablet'} + commands: + - tapOn: + point: "16%,25%" + - waitForAnimationToEnd: + timeout: 2000 + - tapOn: + point: "90%,13%" +- extendedWaitUntil: + visible: "New Message" + timeout: 20000 +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: compose-04-with-photo + +- tapOn: "Send" +- extendedWaitUntil: + visible: "Email sent successfully" + timeout: 20000 + optional: true +- runFlow: + file: ../subflows/assert-delivered.yaml + env: + SUBJECT: ${output.subject} +- runFlow: + when: + true: ${output.pickPhoto} + commands: + - assertTrue: + condition: ${output.helper.attachments.length >= 1} + label: "${output.subject} carries the picked photo" diff --git a/.maestro/features/04-incoming-reply-forward.yaml b/.maestro/features/04-incoming-reply-forward.yaml new file mode 100644 index 00000000..d59c3f23 --- /dev/null +++ b/.maestro/features/04-incoming-reply-forward.yaml @@ -0,0 +1,72 @@ +# Incoming mail while the app is open (sent by userb over JMAP), then reply +# and forward it; userb receives both, the forward with the attachment. +appId: org.bulwarkmail.mobile +name: Incoming, reply, forward +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()} +- runScript: + file: ../scripts/helper.js + env: + CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1 +# Arrives without a pull to refresh (push over EventSource). +- extendedWaitUntil: + visible: ".*${output.incoming}.*" + timeout: 90000 +- takeScreenshot: incoming-01-arrived +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: ${output.incoming} +- takeScreenshot: incoming-02-open + +# Reply +- tapOn: + text: "Reply" + index: 0 +- extendedWaitUntil: + visible: "Re: ${output.incoming}" + timeout: 15000 +- runFlow: + file: ../subflows/compose-body.yaml + env: + BODY: "Reply from the iOS simulator test." +- takeScreenshot: incoming-03-reply +- tapOn: "Send" +# The toast is gone after a few seconds; delivery is checked below. +- extendedWaitUntil: + visible: "Email sent successfully" + timeout: 20000 + optional: true +- runFlow: + file: ../subflows/assert-delivered.yaml + env: + SUBJECT: "Re: ${output.incoming}" + +# Forward with the attachment +- extendedWaitUntil: + visible: "Forward" + timeout: 15000 +- tapOn: + text: "Forward" + index: 0 +- extendedWaitUntil: + visible: "Fwd: ${output.incoming}" + timeout: 15000 +- tapOn: "Recipient email addresses" +- inputText: "userb@example.org" +- pressKey: Enter +- takeScreenshot: incoming-04-forward +- tapOn: "Send" +- extendedWaitUntil: + visible: "Email sent successfully" + timeout: 20000 + optional: true +- runFlow: + file: ../subflows/assert-delivered.yaml + env: + SUBJECT: "Fwd: ${output.incoming}" + ATTACHMENT: e2e-attachment.pdf +- runFlow: ../subflows/close-message.yaml diff --git a/.maestro/features/05-search.yaml b/.maestro/features/05-search.yaml new file mode 100644 index 00000000..c190cb14 --- /dev/null +++ b/.maestro/features/05-search.yaml @@ -0,0 +1,27 @@ +# Search: a hit, a miss, clearing. +appId: org.bulwarkmail.mobile +name: Search +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- tapOn: "Search mail..." +- inputText: "verification" +- pressKey: Enter +- extendedWaitUntil: + visible: ".*Your verification code.*" + timeout: 30000 +- assertNotVisible: ".*Weekly digest.*" +- takeScreenshot: search-01-hit +- tapOn: "Clear search" +- tapOn: "Search mail..." +- inputText: "zzqx-no-such-mail" +- pressKey: Enter +- extendedWaitUntil: + visible: "No results found" + timeout: 30000 +- takeScreenshot: search-02-miss +- tapOn: "Clear" +- extendedWaitUntil: + visible: ".*Weekly digest.*" + timeout: 30000 diff --git a/.maestro/features/06-swipe-undo.yaml b/.maestro/features/06-swipe-undo.yaml new file mode 100644 index 00000000..e19a97c2 --- /dev/null +++ b/.maestro/features/06-swipe-undo.yaml @@ -0,0 +1,41 @@ +# Swipe actions with undo: swipe left archives (undo brings it back), swipe +# right toggles read. +appId: org.bulwarkmail.mobile +name: Swipe actions and undo +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- scrollUntilVisible: + element: ".*Grüße aus München.*" + direction: DOWN + centerElement: true + timeout: 20000 +- swipe: + from: ".*Grüße aus München.*" + direction: LEFT + duration: 400 +# The undo bar shows for 5 s; when it goes, the Compose button slides into +# its place, and a late tap on UNDO opens the composer. So tap at once and +# check the rest afterwards. +- tapOn: + text: "UNDO" + waitToSettleTimeoutMs: 500 +- extendedWaitUntil: + visible: ".*Grüße aus München.*" + timeout: 15000 +- takeScreenshot: swipe-02-undone + +- swipe: + from: ".*Grüße aus München.*" + direction: RIGHT + duration: 400 +- waitForAnimationToEnd: + timeout: 3000 +- takeScreenshot: swipe-03-read-toggled +- swipe: + from: ".*Grüße aus München.*" + direction: RIGHT + duration: 400 +- waitForAnimationToEnd: + timeout: 3000 diff --git a/.maestro/features/07-move-delete-tag.yaml b/.maestro/features/07-move-delete-tag.yaml new file mode 100644 index 00000000..38dfc5d9 --- /dev/null +++ b/.maestro/features/07-move-delete-tag.yaml @@ -0,0 +1,85 @@ +# Message actions with undo: move to a folder, delete to Trash, select and +# star from the list. +appId: org.bulwarkmail.mobile +name: Move, delete, select +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml + +# Move via More → Move to... +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: Weekly digest +- tapOn: "more" +- extendedWaitUntil: + visible: "More actions" + timeout: 10000 +- takeScreenshot: actions-01-more +- tapOn: "Move to..." +- extendedWaitUntil: + visible: "Move to..." + timeout: 10000 +- tapOn: + text: "Projects" + # The undo bar shows for 5 s: don't spend them waiting for the screen to settle. + waitToSettleTimeoutMs: 500 +- takeScreenshot: actions-02-moved +# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer). +- tapOn: + text: "UNDO" + # The undo bar shows for 5 s: don't spend them waiting for the screen to settle. + waitToSettleTimeoutMs: 500 +- runFlow: ../subflows/close-message.yaml +- extendedWaitUntil: + visible: ".*Weekly digest.*" + timeout: 15000 + +# Delete (to Trash) and undo +- runFlow: + file: ../subflows/open-message.yaml + env: + SUBJECT: Weekly digest +- takeScreenshot: actions-03a-before-delete +- tapOn: + text: "Delete" + # The undo bar shows for 5 s: don't spend them waiting for the screen to settle. + waitToSettleTimeoutMs: 500 +- takeScreenshot: actions-03-deleted +- tapOn: + text: "UNDO" + # The undo bar shows for 5 s: don't spend them waiting for the screen to settle. + waitToSettleTimeoutMs: 500 +- runFlow: ../subflows/close-message.yaml +- extendedWaitUntil: + visible: ".*Weekly digest.*" + timeout: 15000 + +# Select from the list and star, then unstar. On a phone the row can be +# below the fold (earlier runs left mail on top). +- scrollUntilVisible: + element: ".*Grüße aus München.*" + direction: DOWN + centerElement: true + timeout: 20000 +- longPressOn: ".*Grüße aus München.*" +- extendedWaitUntil: + visible: "Clear selection" + timeout: 10000 +- takeScreenshot: actions-04-selected +# Star (or Unstar, when an earlier run left it starred), then toggle back. +- tapOn: "Star|Unstar" +# A bulk action ends selection mode by itself. +- runFlow: + when: + visible: "Clear selection" + commands: + - tapOn: "Clear selection" +- longPressOn: ".*Grüße aus München.*" +- tapOn: "Unstar|Star" +- runFlow: + when: + visible: "Clear selection" + commands: + - tapOn: "Clear selection" diff --git a/.maestro/features/08-scheduled-send.yaml b/.maestro/features/08-scheduled-send.yaml new file mode 100644 index 00000000..b6eab573 --- /dev/null +++ b/.maestro/features/08-scheduled-send.yaml @@ -0,0 +1,58 @@ +# Scheduled send: schedule a message for later, find it under Scheduled, +# cancel it. +appId: org.bulwarkmail.mobile +name: Scheduled send +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()} +- tapOn: "Compose" +- extendedWaitUntil: + visible: "New Message" + timeout: 15000 +- tapOn: "Recipient email addresses" +- inputText: "userb@example.org" +- pressKey: Enter +- tapOn: + text: "Subject" + index: 1 +- inputText: ${output.subject} +- runFlow: + file: ../subflows/compose-body.yaml + env: + FROM_SUBJECT: "yes" + BODY: "Scheduled from the iOS simulator test." +- tapOn: "Schedule send" +- extendedWaitUntil: + visible: "Tomorrow morning.*" + timeout: 10000 +- takeScreenshot: scheduled-01-options +- tapOn: "Tomorrow morning.*" +- extendedWaitUntil: + visible: "Scheduled" + timeout: 30000 +- takeScreenshot: scheduled-02-confirmed +- tapOn: "OK" + +- runFlow: + file: ../subflows/go-to-folder.yaml + env: + FOLDER: Scheduled +- extendedWaitUntil: + visible: ".*${output.subject}.*" + timeout: 30000 +- takeScreenshot: scheduled-03-list +- tapOn: ".*${output.subject}.*" +- tapOn: "Cancel send" +- extendedWaitUntil: + visible: "Keep scheduled" + timeout: 10000 +- tapOn: + text: "Cancel send" + below: "Keep scheduled|.*will not be delivered.*" +- extendedWaitUntil: + notVisible: ".*${output.subject}.*" + timeout: 30000 +- takeScreenshot: scheduled-04-cancelled +- runFlow: ../subflows/go-to-inbox.yaml diff --git a/.maestro/features/09-calendar.yaml b/.maestro/features/09-calendar.yaml new file mode 100644 index 00000000..a975fd1e --- /dev/null +++ b/.maestro/features/09-calendar.yaml @@ -0,0 +1,126 @@ +# Calendar: the views, create an event with a reminder, edit it, delete it. +appId: org.bulwarkmail.mobile +name: Calendar +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000} +- tapOn: + id: tab-calendar +# The first visit asks for notification permission (event reminders). +- extendedWaitUntil: + visible: ".*Would Like to Send You Notifications.*|Go to today" + timeout: 30000 +- runFlow: ../subflows/allow-notifications.yaml +- extendedWaitUntil: + visible: "Go to today" + timeout: 30000 +- takeScreenshot: calendar-01-month + +- tapOn: "Open menu" +- tapOn: "Schedule" +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: calendar-02-schedule +- tapOn: "Open menu" +- tapOn: "Day" +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: calendar-03-day +- tapOn: "Open menu" +- tapOn: "Week" +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: calendar-04-week + +# Create +- tapOn: "New event" +- extendedWaitUntil: + visible: "Save" + timeout: 10000 +- tapOn: "Title" +- inputText: ${output.event} +# Close the keyboard first: a tap while it slides away lands on a form that +# is still moving. +- pressKey: Enter +- waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: "Add reminder" + direction: DOWN + timeout: 10000 +- repeat: + while: + notVisible: "15 minutes before" + times: 2 + commands: + - tapOn: "Add reminder" + - waitForAnimationToEnd: + timeout: 3000 +- scrollUntilVisible: + element: "15 minutes before" + direction: DOWN + timeout: 10000 +- tapOn: "15 minutes before" +- takeScreenshot: calendar-05-editor +- tapOn: "Save" +# The editor closes (its Save goes; the FAB is labelled "New event" too). +- extendedWaitUntil: + notVisible: "Save" + timeout: 20000 + +# Find it in the schedule and open it +- tapOn: "Open menu" +- tapOn: "Schedule" +- tapOn: "Go to today" +- scrollUntilVisible: + element: ".*${output.event}.*" + direction: DOWN + timeout: 30000 +- tapOn: ".*${output.event}.*" +- extendedWaitUntil: + visible: "Edit" + timeout: 10000 +- takeScreenshot: calendar-06-detail +- assertVisible: ".*15 minutes before.*" + +# Edit: add a location (an empty field, so no cursor games in the title) +- tapOn: "Edit" +- extendedWaitUntil: + visible: "Edit event" + timeout: 10000 +- scrollUntilVisible: + element: "Place or address" + direction: DOWN + timeout: 10000 +- tapOn: "Place or address" +- inputText: "Room 42" +- tapOn: "Save" +- extendedWaitUntil: + visible: ".*Room 42.*" + timeout: 20000 +- takeScreenshot: calendar-07-edited + +# Delete (the detail sheet is still open after saving) +- runFlow: + when: + notVisible: "Delete" + commands: + - tapOn: ".*${output.event}.*" +- extendedWaitUntil: + visible: "Delete" + timeout: 10000 +- tapOn: "Delete" +- extendedWaitUntil: + visible: "Delete this event.*" + timeout: 10000 +- tapOn: + text: "Delete" + below: "Delete this event.*" +- extendedWaitUntil: + notVisible: ".*${output.event}.*" + timeout: 20000 +- takeScreenshot: calendar-08-deleted +- tapOn: + id: tab-mail diff --git a/.maestro/features/10-contacts.yaml b/.maestro/features/10-contacts.yaml new file mode 100644 index 00000000..2d516cb1 --- /dev/null +++ b/.maestro/features/10-contacts.yaml @@ -0,0 +1,70 @@ +# Contacts: the list, a contact's detail, create one and delete it again. +appId: org.bulwarkmail.mobile +name: Contacts +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- evalScript: ${output.last = 'Tester' + Date.now() % 100000} +- tapOn: + id: tab-contacts +- extendedWaitUntil: + visible: "All Contacts" + timeout: 30000 +- takeScreenshot: contacts-01-list +- tapOn: ".*Alice.*" +- extendedWaitUntil: + visible: "Email" + timeout: 10000 +- takeScreenshot: contacts-02-detail +- runFlow: + when: + visible: "Back" + commands: + - tapOn: "Back" + +- tapOn: "New Contact" +- extendedWaitUntil: + visible: "Save" + timeout: 10000 +- runFlow: + file: ../subflows/type.yaml + env: + FIELD: contact-first-name + TEXT: E2E +# Return moves to Middle name, then Last name: tapping the field would hit +# the keyboard covering it. +- pressKey: Enter +- pressKey: Enter +- runFlow: + file: ../subflows/type.yaml + env: + FIELD: contact-last-name + TAP: "no" + TEXT: ${output.last} +- takeScreenshot: contacts-03-form +- tapOn: "Save" +- extendedWaitUntil: + visible: ".*${output.last}.*" + timeout: 20000 +- takeScreenshot: contacts-04-created + +- runFlow: + when: + notVisible: "More actions" + commands: + - tapOn: ".*E2E ${output.last}.*" +- tapOn: "More actions" +- tapOn: "Delete" +- extendedWaitUntil: + visible: "Delete contact" + timeout: 10000 +- tapOn: + text: "Delete" + below: "Are you sure.*" +- extendedWaitUntil: + notVisible: ".*E2E ${output.last}.*" + timeout: 20000 +- takeScreenshot: contacts-05-deleted +- tapOn: + id: tab-mail diff --git a/.maestro/features/11-files.yaml b/.maestro/features/11-files.yaml new file mode 100644 index 00000000..55ccb38e --- /dev/null +++ b/.maestro/features/11-files.yaml @@ -0,0 +1,65 @@ +# Files: open a folder, preview a text and a PDF file, create and delete a +# folder. +appId: org.bulwarkmail.mobile +name: Files +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000} +- tapOn: + id: tab-files +- extendedWaitUntil: + visible: ".*Documents.*" + timeout: 30000 +- takeScreenshot: files-01-root +- tapOn: ".*Documents.*" +- extendedWaitUntil: + visible: ".*readme.txt.*" + timeout: 20000 +- tapOn: ".*readme.txt.*" +- extendedWaitUntil: + visible: "readme.txt" + timeout: 20000 +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: files-02-text-preview +- tapOn: "Close" +- tapOn: ".*q3-report.pdf.*" +- extendedWaitUntil: + visible: "q3-report.pdf" + timeout: 20000 +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: files-03-pdf-preview +- tapOn: "Close" +- tapOn: "Back" + +- tapOn: "New Folder" +- tapOn: "Folder name" +- inputText: ${output.folder} +- tapOn: "Create" +- extendedWaitUntil: + visible: ".*${output.folder}.*" + timeout: 20000 +- takeScreenshot: files-04-folder-created +- longPressOn: ".*${output.folder}.*" +- repeat: + while: + notVisible: "Delete this item.*" + times: 3 + commands: + - tapOn: "Delete" + - waitForAnimationToEnd: + timeout: 3000 +- extendedWaitUntil: + visible: "Delete this item.*" + timeout: 10000 +- tapOn: + text: "Delete" + below: "Delete this item.*" +- extendedWaitUntil: + notVisible: ".*${output.folder}.*" + timeout: 20000 +- tapOn: + id: tab-mail diff --git a/.maestro/features/12-settings.yaml b/.maestro/features/12-settings.yaml new file mode 100644 index 00000000..7d50cc4f --- /dev/null +++ b/.maestro/features/12-settings.yaml @@ -0,0 +1,38 @@ +# Every settings page opens and shows its content. +appId: org.bulwarkmail.mobile +name: Settings pages +tags: + - features +--- +- runFlow: ../subflows/signed-in.yaml +- tapOn: + id: tab-settings +- extendedWaitUntil: + visible: "Search settings" + timeout: 20000 +- takeScreenshot: settings-00-list +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "Folders", PROOF: ".*Inbox.*"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content", PROOF: "External Content"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Calendar", PROOF: "Default view"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Contacts", PROOF: "Group by first letter"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "The default light and dark palettes."}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}} +- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}} +- tapOn: + id: tab-mail diff --git a/.maestro/launch.yaml b/.maestro/launch.yaml new file mode 100644 index 00000000..cf402f84 --- /dev/null +++ b/.maestro/launch.yaml @@ -0,0 +1,30 @@ +# Fresh install: the app starts and shows the sign-in choices, and typing +# into a field works. Needs no server, so it runs before every suite as the +# warm-up for the XCTest driver. It types only a made-up address, so its +# debug log is safe to print. +appId: org.bulwarkmail.mobile +name: Launch to sign-in +tags: + - smoke +--- +- launchApp: + clearState: true + clearKeychain: true +# A cold start of a release build on a CI simulator can be slow. +- extendedWaitUntil: + visible: "Enter server details manually" + timeout: 120000 +- takeScreenshot: warmup-01-sign-in-choices +- tapOn: "Enter server details manually" +- extendedWaitUntil: + visible: + id: login-server-input + timeout: 10000 +- runFlow: subflows/dismiss-keyboard-tip.yaml +- runFlow: + file: subflows/type.yaml + env: + FIELD: login-server-input + TAP: "no" + TEXT: typing-probe.example.net +- takeScreenshot: warmup-02-typed diff --git a/.maestro/perf/1-open-message.yaml b/.maestro/perf/1-open-message.yaml new file mode 100644 index 00000000..d89b3c38 --- /dev/null +++ b/.maestro/perf/1-open-message.yaml @@ -0,0 +1,27 @@ +# Perf: open a message and go back, five times. The app is already running +# with -perfMarks YES (no launchApp here: it would drop the launch argument). +appId: org.bulwarkmail.mobile +name: Perf open message +--- +- extendedWaitUntil: + visible: + id: email-row + timeout: 60000 +- repeat: + times: 5 + commands: + - tapOn: + id: email-row + index: 1 + - extendedWaitUntil: + visible: "Reply All" + timeout: 30000 + - waitForAnimationToEnd: + timeout: 3000 + - runFlow: + when: + visible: "Back" + commands: + - tapOn: "Back" + - waitForAnimationToEnd: + timeout: 3000 diff --git a/.maestro/perf/2-tabs.yaml b/.maestro/perf/2-tabs.yaml new file mode 100644 index 00000000..07be4454 --- /dev/null +++ b/.maestro/perf/2-tabs.yaml @@ -0,0 +1,33 @@ +# Perf: switch through the tabs, three times. +appId: org.bulwarkmail.mobile +name: Perf tab switches +--- +- repeat: + times: 3 + commands: + - tapOn: + id: tab-calendar + - runFlow: + when: + visible: ".*Would Like to Send You Notifications.*" + commands: + - tapOn: "Allow" + - waitForAnimationToEnd: + timeout: 3000 + - tapOn: + id: tab-contacts + - waitForAnimationToEnd: + timeout: 3000 + - takeScreenshot: perf-contacts + - tapOn: + id: tab-files + - waitForAnimationToEnd: + timeout: 3000 + - tapOn: + id: tab-settings + - waitForAnimationToEnd: + timeout: 3000 + - tapOn: + id: tab-mail + - waitForAnimationToEnd: + timeout: 3000 diff --git a/.maestro/perf/3-compose.yaml b/.maestro/perf/3-compose.yaml new file mode 100644 index 00000000..49651559 --- /dev/null +++ b/.maestro/perf/3-compose.yaml @@ -0,0 +1,23 @@ +# Perf: open the composer and close it untouched, five times. +appId: org.bulwarkmail.mobile +name: Perf compose open +--- +- tapOn: + id: tab-mail +- repeat: + times: 5 + commands: + - tapOn: "Compose" + - extendedWaitUntil: + visible: "New Message" + timeout: 20000 + - waitForAnimationToEnd: + timeout: 3000 + - tapOn: "Close" + - waitForAnimationToEnd: + timeout: 3000 + - runFlow: + when: + visible: "Discard" + commands: + - tapOn: "Discard" diff --git a/.maestro/perf/4-scroll.yaml b/.maestro/perf/4-scroll.yaml new file mode 100644 index 00000000..422b81a9 --- /dev/null +++ b/.maestro/perf/4-scroll.yaml @@ -0,0 +1,28 @@ +# Perf: fling through the long inbox (thousands of messages), then back up. +appId: org.bulwarkmail.mobile +name: Perf list scroll +--- +- tapOn: + id: tab-mail +- extendedWaitUntil: + visible: + id: email-row + timeout: 30000 +- repeat: + times: 8 + commands: + - swipe: + start: 50%,80% + end: 50%,20% + duration: 250 + - waitForAnimationToEnd: + timeout: 4000 +- repeat: + times: 8 + commands: + - swipe: + start: 50%,20% + end: 50%,80% + duration: 250 + - waitForAnimationToEnd: + timeout: 4000 diff --git a/.maestro/perf/sign-in.yaml b/.maestro/perf/sign-in.yaml new file mode 100644 index 00000000..99b41936 --- /dev/null +++ b/.maestro/perf/sign-in.yaml @@ -0,0 +1,11 @@ +# Performance runs: sign in once (ACCOUNT, the bulk account with thousands of +# messages). Later installs of other builds keep the data and the keychain. +appId: org.bulwarkmail.mobile +name: Perf sign-in +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/password-sign-in.yaml + env: + LOGIN: ${ACCOUNT}@example.org + INBOX_TEXT: ".*Bulk.*" diff --git a/.maestro/push/01-notification.yaml b/.maestro/push/01-notification.yaml new file mode 100644 index 00000000..7e5ee481 --- /dev/null +++ b/.maestro/push/01-notification.yaml @@ -0,0 +1,79 @@ +# Push on iOS: the relay's payload carries only ids and a placeholder text. +# A tap opens the message, with the app running and from a cold start. The +# helper sends the message as userb and pushes the relay's payload with +# simctl, so this needs no APNs key. +# +# simctl push hands the payload to the simulator as a local notification +# request: the notification service extension (plugins/ios-push), which +# puts sender and subject in, never runs that way (the log shows no +# NotificationService process). So the banner shows the placeholder here; +# the extension needs a real APNs push to be tested. +appId: org.bulwarkmail.mobile +name: Push notification on iOS +tags: + - push +--- +- runFlow: ../subflows/signed-in.yaml +# Without notification permission iOS drops the push silently, and the +# launch-time grant doesn't reach it on these simulators: let the app ask +# (the Calendar tab does, for event reminders) and allow. +- tapOn: + id: tab-calendar +- extendedWaitUntil: + visible: ".*Would Like to Send You Notifications.*|Go to today" + timeout: 30000 +- runFlow: + when: + visible: ".*Would Like to Send You Notifications.*" + commands: + - tapOn: + text: "Allow" + optional: true +- tapOn: + id: tab-mail +- waitForAnimationToEnd: + timeout: 3000 +- pressKey: Home + +# 1. The banner shows (and what it says). +- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()} +- runScript: + file: ../scripts/helper.js + env: + CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)} +- extendedWaitUntil: + visible: ".*${output.subject0}.*|.*New message.*" + timeout: 30000 +- takeScreenshot: push-01-banner +# Let it go, so the next tap can't hit this one. +- extendedWaitUntil: + notVisible: ".*${output.subject0}.*|.*New message.*" + timeout: 20000 + +# 2. A tap opens the message, app in the background. The banner shows for a +# few seconds and finding it takes Maestro about as long; taps on the lock +# screen list do nothing on these simulators. So tap where the banner +# appears, right after the push (push-and-tap.yaml). +- evalScript: ${output.subject = 'E2E push ' + Date.now()} +- runFlow: + file: ../subflows/push-and-tap.yaml + env: + SUBJECT: ${output.subject} +- extendedWaitUntil: + visible: "Reply All" + timeout: 60000 +- assertVisible: ".*${output.subject}.*" +- takeScreenshot: push-02-opened-warm + +# 3. The same from a cold start: the app isn't running. +- stopApp +- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()} +- runFlow: + file: ../subflows/push-and-tap.yaml + env: + SUBJECT: ${output.subject2} +- extendedWaitUntil: + visible: "Reply All" + timeout: 90000 +- assertVisible: ".*${output.subject2}.*" +- takeScreenshot: push-04-opened-cold diff --git a/.maestro/scripts/helper.js b/.maestro/scripts/helper.js new file mode 100644 index 00000000..b170a6c4 --- /dev/null +++ b/.maestro/scripts/helper.js @@ -0,0 +1,8 @@ +// Calls the test helper (scripts/e2e/helper.mjs) on the runner. +// env CALL: path and query, e.g. "/totp" or "/pair?user=x&deliver=openurl". +// The answer lands in output.helper. +const response = http.get(HELPER + CALL); +if (!response.ok) { + throw new Error('helper ' + CALL.split('?')[0] + ' failed: ' + response.status + ' ' + response.body); +} +output.helper = json(response.body); diff --git a/.maestro/signin/01-password.yaml b/.maestro/signin/01-password.yaml new file mode 100644 index 00000000..f5aa3656 --- /dev/null +++ b/.maestro/signin/01-password.yaml @@ -0,0 +1,32 @@ +# Password sign-in as the first account, sign out, sign in again. +appId: org.bulwarkmail.mobile +name: Password, sign out, sign in again +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- takeScreenshot: password-01-choices +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${SERVER} +- takeScreenshot: password-02-confirm +- tapOn: "Sign in with a password instead" +- runFlow: + file: ../subflows/password-form.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: password-03-inbox + +- runFlow: ../subflows/sign-out.yaml +- extendedWaitUntil: + visible: "Enter server details manually" + timeout: 30000 +- takeScreenshot: password-04-signed-out + +- runFlow: + file: ../subflows/password-sign-in.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- takeScreenshot: password-05-signed-in-again diff --git a/.maestro/signin/02-password-errors.yaml b/.maestro/signin/02-password-errors.yaml new file mode 100644 index 00000000..b80936cb --- /dev/null +++ b/.maestro/signin/02-password-errors.yaml @@ -0,0 +1,58 @@ +# A wrong password and an unreachable server: the app says what went wrong +# and stays on the form. +appId: org.bulwarkmail.mobile +name: Password errors +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${SERVER} +- tapOn: "Sign in with a password instead" +- runFlow: + file: ../subflows/password-form.yaml + env: + LOGIN: ${ACCOUNT}@example.org + PASSWORD: wrong-password-123 +- extendedWaitUntil: + visible: "That didn't work" + timeout: 30000 +- takeScreenshot: errors-01-wrong-password +- assertVisible: + id: login-password-input + +# Back to the server step, then a host that doesn't resolve. +- tapOn: "Back" +- tapOn: "Back" +- runFlow: + file: ../subflows/type.yaml + env: + FIELD: login-server-input + TAP: "no" + ERASE: "yes" + TEXT: mail.does-not-exist.invalid +- tapOn: "Continue" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 +- tapOn: "Sign in with a password instead" +- extendedWaitUntil: + visible: + id: login-password-input + timeout: 10000 +- runFlow: + file: ../subflows/type-secret.yaml + env: + FIELD: login-password-input + # The email is filled in, so the password field focuses itself. + TAP: "no" + TEXT: ${PASSWORD} +- pressKey: Enter +- extendedWaitUntil: + visible: "Can't reach mail.does-not-exist.invalid|No mail server at mail.does-not-exist.invalid|Sign-in failed" + timeout: 60000 +- takeScreenshot: errors-02-unreachable +- assertVisible: + id: login-password-input diff --git a/.maestro/signin/03-totp-password.yaml b/.maestro/signin/03-totp-password.yaml new file mode 100644 index 00000000..b892ee69 --- /dev/null +++ b/.maestro/signin/03-totp-password.yaml @@ -0,0 +1,24 @@ +# A TOTP account signs in with a password: Stalwart answers 402 for the right +# password, and the app asks for the code. +appId: org.bulwarkmail.mobile +name: Password with TOTP +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${SERVER} +- tapOn: "Sign in with a password instead" +- runFlow: + file: ../subflows/password-form.yaml + env: + LOGIN: ${TOTP_ACCOUNT}@example.org +- extendedWaitUntil: + visible: "Authentication code" + timeout: 30000 +- takeScreenshot: totp-01-code-asked +- runFlow: ../subflows/two-factor.yaml +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: totp-02-inbox diff --git a/.maestro/signin/04-oauth.yaml b/.maestro/signin/04-oauth.yaml new file mode 100644 index 00000000..1a4a29eb --- /dev/null +++ b/.maestro/signin/04-oauth.yaml @@ -0,0 +1,20 @@ +# OAuth with Stalwart's own authorization server: the confirm step's +# Continue opens an ASWebAuthenticationSession on Stalwart's sign-in page. +appId: org.bulwarkmail.mobile +name: OAuth (Stalwart) +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${SERVER} +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- runFlow: + file: ../subflows/stalwart-login-page.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: oauth-01-inbox diff --git a/.maestro/signin/05-oauth-cancel.yaml b/.maestro/signin/05-oauth-cancel.yaml new file mode 100644 index 00000000..fe757e43 --- /dev/null +++ b/.maestro/signin/05-oauth-cancel.yaml @@ -0,0 +1,39 @@ +# Backing out of OAuth: Cancel on the system alert, and closing the sheet +# halfway. Both leave the user on the confirm step without an error. +appId: org.bulwarkmail.mobile +name: OAuth cancelled +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${SERVER} +- tapOn: "Continue" +- extendedWaitUntil: + visible: ".*Wants to Use.*to Sign In.*" + timeout: 30000 +- tapOn: + text: "Cancel" + below: ".*Wants to Use.*to Sign In.*" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 +- assertNotVisible: "Sign-in failed" +- assertNotVisible: "Sign-in was interrupted" +- takeScreenshot: oauth-cancel-01-alert-cancelled + +# Accept the alert, then close the sheet on Stalwart's page. +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- extendedWaitUntil: + visible: "you@example.com" + timeout: 60000 +- tapOn: "Cancel" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 +- assertNotVisible: "Sign-in failed" +- assertNotVisible: "Sign-in was interrupted" +- takeScreenshot: oauth-cancel-02-sheet-closed diff --git a/.maestro/signin/06-webmail.yaml b/.maestro/signin/06-webmail.yaml new file mode 100644 index 00000000..6814d955 --- /dev/null +++ b/.maestro/signin/06-webmail.yaml @@ -0,0 +1,20 @@ +# Sign in via webmail: the confirm step on a Bulwark webmail address hands +# off to the webmail's sign-in page in an auth session. +appId: org.bulwarkmail.mobile +name: Webmail hand-off +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${WEBMAIL} +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- runFlow: + file: ../subflows/webmail-login-page.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: webmail-01-inbox diff --git a/.maestro/signin/07-webmail-totp.yaml b/.maestro/signin/07-webmail-totp.yaml new file mode 100644 index 00000000..a0d3e579 --- /dev/null +++ b/.maestro/signin/07-webmail-totp.yaml @@ -0,0 +1,24 @@ +# Webmail hand-off for a TOTP account: the webmail asks for the code; if it +# hands back the password, the app finishes with a code of its own. Uses the +# beta webmail: the stable one still has 06's session in the browser, which +# strands the hand-off (see 20-webmail-session.yaml). +appId: org.bulwarkmail.mobile +name: Webmail hand-off with TOTP +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${WEBMAIL_BETA} +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- runFlow: + file: ../subflows/webmail-login-page.yaml + env: + LOGIN: ${TOTP_ACCOUNT}@example.org + TOTP: "yes" +- runFlow: ../subflows/finish-two-factor-if-asked.yaml +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: webmail-totp-01-inbox diff --git a/.maestro/signin/08-link-password.yaml b/.maestro/signin/08-link-password.yaml new file mode 100644 index 00000000..90615e17 --- /dev/null +++ b/.maestro/signin/08-link-password.yaml @@ -0,0 +1,17 @@ +# Sign-in link from another app (simctl openurl), password account. The +# helper signs in to the test webmail, creates a fresh "Link mobile app" code +# and opens its bulwarkmail://pair link on the simulator. +appId: org.bulwarkmail.mobile +name: Sign-in link from another app +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair?user=${ACCOUNT}&deliver=openurl +- runFlow: ../subflows/confirm-link.yaml +- runFlow: ../subflows/finish-two-factor-if-asked.yaml +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: link-password-01-inbox diff --git a/.maestro/signin/09-paste-password.yaml b/.maestro/signin/09-paste-password.yaml new file mode 100644 index 00000000..d74671f3 --- /dev/null +++ b/.maestro/signin/09-paste-password.yaml @@ -0,0 +1,16 @@ +# "Paste sign-in link" with a fresh pairing link on the clipboard (simctl +# pbcopy), password account. +appId: org.bulwarkmail.mobile +name: Paste sign-in link +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair?user=${ACCOUNT}&deliver=pbcopy +- runFlow: ../subflows/paste-link.yaml +- runFlow: ../subflows/finish-two-factor-if-asked.yaml +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: paste-password-01-inbox diff --git a/.maestro/signin/10-link-totp.yaml b/.maestro/signin/10-link-totp.yaml new file mode 100644 index 00000000..a50bc8ee --- /dev/null +++ b/.maestro/signin/10-link-totp.yaml @@ -0,0 +1,15 @@ +# Sign-in link from another app for a TOTP account. +appId: org.bulwarkmail.mobile +name: Sign-in link, TOTP account +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair?user=${TOTP_ACCOUNT}&deliver=openurl +- runFlow: ../subflows/confirm-link.yaml +- runFlow: ../subflows/finish-two-factor-if-asked.yaml +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: link-totp-01-inbox diff --git a/.maestro/signin/11-paste-totp.yaml b/.maestro/signin/11-paste-totp.yaml new file mode 100644 index 00000000..8fc369e6 --- /dev/null +++ b/.maestro/signin/11-paste-totp.yaml @@ -0,0 +1,15 @@ +# "Paste sign-in link" for a TOTP account. +appId: org.bulwarkmail.mobile +name: Paste sign-in link, TOTP account +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair?user=${TOTP_ACCOUNT}&deliver=pbcopy +- runFlow: ../subflows/paste-link.yaml +- runFlow: ../subflows/finish-two-factor-if-asked.yaml +- runFlow: ../subflows/inbox.yaml +- takeScreenshot: paste-totp-01-inbox diff --git a/.maestro/signin/12-link-expired.yaml b/.maestro/signin/12-link-expired.yaml new file mode 100644 index 00000000..a6ea5299 --- /dev/null +++ b/.maestro/signin/12-link-expired.yaml @@ -0,0 +1,21 @@ +# An expired code (codes live 2 minutes): the helper creates one now and +# opens it after 130 s. The app says so and stays on the sign-in screen. +appId: org.bulwarkmail.mobile +name: Expired sign-in code +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair-later?user=${ACCOUNT}&deliver=openurl&delay=130 +- runFlow: + file: ../subflows/confirm-link.yaml + env: + LINK_WAIT: 200000 +- extendedWaitUntil: + visible: "That code has expired.*" + timeout: 30000 +- takeScreenshot: link-expired-01-notice +- assertVisible: "Enter server details manually" diff --git a/.maestro/signin/13-link-used.yaml b/.maestro/signin/13-link-used.yaml new file mode 100644 index 00000000..798bc0eb --- /dev/null +++ b/.maestro/signin/13-link-used.yaml @@ -0,0 +1,44 @@ +# A code that was already used: sign in with it, sign out, open the same link +# again (same app process, then after a restart, which asks the server). +appId: org.bulwarkmail.mobile +name: Used sign-in code +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair?user=${ACCOUNT}&deliver=openurl +- runFlow: ../subflows/confirm-link.yaml +- runFlow: ../subflows/inbox.yaml +- runFlow: ../subflows/sign-out.yaml +- extendedWaitUntil: + visible: "Enter server details manually" + timeout: 30000 + +- runScript: + file: ../scripts/helper.js + env: + CALL: /redeliver?user=${ACCOUNT}&deliver=openurl +- runFlow: ../subflows/confirm-link.yaml +- extendedWaitUntil: + visible: "That code was already used|That code has expired or was already used" + timeout: 30000 +- takeScreenshot: link-used-01-same-process + +- stopApp +- launchApp +- extendedWaitUntil: + visible: "Enter server details manually" + timeout: 60000 +- runScript: + file: ../scripts/helper.js + env: + CALL: /redeliver?user=${ACCOUNT}&deliver=openurl +- runFlow: ../subflows/confirm-link.yaml +- extendedWaitUntil: + visible: "That code was already used|That code has expired or was already used" + timeout: 30000 +- takeScreenshot: link-used-02-after-restart +- assertVisible: "Enter server details manually" diff --git a/.maestro/signin/14-scanner-camera.yaml b/.maestro/signin/14-scanner-camera.yaml new file mode 100644 index 00000000..94b0c4f8 --- /dev/null +++ b/.maestro/signin/14-scanner-camera.yaml @@ -0,0 +1,32 @@ +# The QR scanner with camera access denied, then allowed (the simulator has +# no camera, so nothing can be scanned). Neither may crash the app. +appId: org.bulwarkmail.mobile +name: Scanner without camera +tags: + - signin +--- +- runFlow: + file: ../subflows/start.yaml + env: + CAMERA: deny +- tapOn: "Scan a sign-in code.*" +- extendedWaitUntil: + visible: "Camera access is.*|Scanning a sign-in QR code uses the camera.*" + timeout: 15000 +- takeScreenshot: scanner-01-denied +- tapOn: "Close" +- assertVisible: "Enter server details manually" + +- runFlow: + file: ../subflows/start.yaml + env: + CAMERA: allow +- tapOn: "Scan a sign-in code.*" +- extendedWaitUntil: + visible: "Sign-in code" + timeout: 15000 +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: scanner-02-allowed +- tapOn: "Close" +- assertVisible: "Enter server details manually" diff --git a/.maestro/signin/15-email-discovery.yaml b/.maestro/signin/15-email-discovery.yaml new file mode 100644 index 00000000..52170516 --- /dev/null +++ b/.maestro/signin/15-email-discovery.yaml @@ -0,0 +1,23 @@ +# "Use my email address": example.org has no autodiscovery, so the app moves +# on to the server step and names the domain. +appId: org.bulwarkmail.mobile +name: Email address discovery +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- tapOn: "Use my email address.*" +- extendedWaitUntil: + visible: "What's your email address." + timeout: 10000 +- waitForAnimationToEnd: + timeout: 3000 +- inputText: ${ACCOUNT}@example.org +- assertVisible: ${ACCOUNT}@example.org +- tapOn: "Continue" +- extendedWaitUntil: + visible: "We couldn't find a server for example.org.*" + timeout: 60000 +- takeScreenshot: discovery-01-not-found +- assertVisible: + id: login-server-input diff --git a/.maestro/signin/16-add-password.yaml b/.maestro/signin/16-add-password.yaml new file mode 100644 index 00000000..6f11a60f --- /dev/null +++ b/.maestro/signin/16-add-password.yaml @@ -0,0 +1,29 @@ +# Second account by password via "Another account on ", then switch +# between the two. +appId: org.bulwarkmail.mobile +name: Add account, password +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/password-sign-in.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: ../subflows/open-add-account.yaml +- takeScreenshot: add-password-01-choices +- tapOn: "Another account on .*" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 +- tapOn: "Sign in with a password instead" +- runFlow: + file: ../subflows/password-form.yaml + env: + LOGIN: ${ACCOUNT2}@example.org +- runFlow: ../subflows/inbox.yaml +- runFlow: + file: ../subflows/check-active-account.yaml + env: + ACTIVE: ${ACCOUNT2}@example.org +- takeScreenshot: add-password-02-two-accounts diff --git a/.maestro/signin/17-add-oauth.yaml b/.maestro/signin/17-add-oauth.yaml new file mode 100644 index 00000000..024cc747 --- /dev/null +++ b/.maestro/signin/17-add-oauth.yaml @@ -0,0 +1,41 @@ +# Second account by OAuth. Adding uses an ephemeral auth session, so the +# first account's Stalwart sign-in must not be reused. +appId: org.bulwarkmail.mobile +name: Add account, OAuth +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${SERVER} +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- runFlow: + file: ../subflows/stalwart-login-page.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: ../subflows/inbox.yaml + +- runFlow: ../subflows/open-add-account.yaml +- tapOn: "Another account on .*" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 +- tapOn: "Continue" +- runFlow: + when: + visible: ".*Wants to Use.*to Sign In.*" + commands: + - runFlow: ../subflows/allow-auth-session.yaml +- runFlow: + file: ../subflows/stalwart-login-page.yaml + env: + LOGIN: ${ACCOUNT2}@example.org +- runFlow: ../subflows/inbox.yaml +- runFlow: + file: ../subflows/check-active-account.yaml + env: + ACTIVE: ${ACCOUNT2}@example.org +- takeScreenshot: add-oauth-01-two-accounts diff --git a/.maestro/signin/18-add-webmail.yaml b/.maestro/signin/18-add-webmail.yaml new file mode 100644 index 00000000..5ea8ebc1 --- /dev/null +++ b/.maestro/signin/18-add-webmail.yaml @@ -0,0 +1,43 @@ +# Second account via the webmail hand-off, on top of a password account. +appId: org.bulwarkmail.mobile +name: Add account, webmail +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/password-sign-in.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: ../subflows/open-add-account.yaml +- tapOn: "An account somewhere else.*" +- extendedWaitUntil: + visible: "What's the other address." + timeout: 10000 +- tapOn: "I know my server address" +- runFlow: + file: ../subflows/type.yaml + env: + FIELD: login-server-input + TAP: "no" + TEXT: ${WEBMAIL} +- tapOn: "Continue" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 +- tapOn: "Continue" +- runFlow: + when: + visible: ".*Wants to Use.*to Sign In.*" + commands: + - runFlow: ../subflows/allow-auth-session.yaml +- runFlow: + file: ../subflows/webmail-login-page.yaml + env: + LOGIN: ${ACCOUNT2}@example.org +- runFlow: ../subflows/inbox.yaml +- runFlow: + file: ../subflows/check-active-account.yaml + env: + ACTIVE: ${ACCOUNT2}@example.org +- takeScreenshot: add-webmail-01-two-accounts diff --git a/.maestro/signin/19-add-link.yaml b/.maestro/signin/19-add-link.yaml new file mode 100644 index 00000000..9ea216ed --- /dev/null +++ b/.maestro/signin/19-add-link.yaml @@ -0,0 +1,37 @@ +# Second account from a sign-in link opened by another app while signed in. +appId: org.bulwarkmail.mobile +name: Add account, sign-in link +tags: + - signin +--- +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/password-sign-in.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runScript: + file: ../scripts/helper.js + env: + CALL: /pair?user=${ACCOUNT2}&deliver=openurl +- extendedWaitUntil: + visible: "Open in .*Bulwark Mobile.*|Sign in with a link." + timeout: 60000 +- runFlow: + when: + visible: "Open in .*Bulwark Mobile.*" + commands: + - tapOn: "Open" +- extendedWaitUntil: + visible: "Sign in with a link." + timeout: 20000 +- takeScreenshot: add-link-01-prompt +- assertVisible: "This link adds an account.*" +- tapOn: + text: "Continue" + below: "Sign in with a link." +- runFlow: ../subflows/inbox.yaml +- runFlow: + file: ../subflows/check-active-account.yaml + env: + ACTIVE: ${ACCOUNT2}@example.org +- takeScreenshot: add-link-02-two-accounts diff --git a/.maestro/signin/20-webmail-session.yaml b/.maestro/signin/20-webmail-session.yaml new file mode 100644 index 00000000..e2238aee --- /dev/null +++ b/.maestro/signin/20-webmail-session.yaml @@ -0,0 +1,52 @@ +# Sign in via webmail while the browser still has a webmail session (from +# 06-webmail, or from using the webmail in Safari): the hand-off must still +# reach the app. Before the fix, the webmail's login page redirected an +# existing session to its own inbox inside the sign-in sheet, and the app +# never got the sign-in. +appId: org.bulwarkmail.mobile +name: Webmail hand-off with an existing webmail session +tags: + - signin +--- +# Leaves a webmail session in the shared browser cookies. +- runFlow: ../subflows/start.yaml +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${WEBMAIL} +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- extendedWaitUntil: + visible: "user@example.com|Hand over to the app|.*Inbox.*" + timeout: 60000 +- runFlow: + when: + visible: "user@example.com" + commands: + - runFlow: + file: ../subflows/webmail-login-page.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: + when: + visible: "Hand over to the app" + commands: + - tapOn: "Hand over to the app" +- runFlow: ../subflows/inbox.yaml +- runFlow: ../subflows/sign-out.yaml + +# Again: the webmail now knows the user. +- extendedWaitUntil: + visible: "Enter server details manually" + timeout: 30000 +- runFlow: + file: ../subflows/manual-server.yaml + env: + HOST: ${WEBMAIL} +- tapOn: "Continue" +- runFlow: ../subflows/allow-auth-session.yaml +- extendedWaitUntil: + visible: "user@example.com|Hand over to the app|Search mail.*" + timeout: 60000 +- takeScreenshot: webmail-session-01-second-visit +- assertNotVisible: "Search mail.*" diff --git a/.maestro/subflows/allow-auth-session.yaml b/.maestro/subflows/allow-auth-session.yaml new file mode 100644 index 00000000..3cb482df --- /dev/null +++ b/.maestro/subflows/allow-auth-session.yaml @@ -0,0 +1,11 @@ +# iOS asks before an app opens a sign-in session in the system browser: +# "“Bulwark Mobile” Wants to Use “” to Sign In". Accept it. +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: ".*Wants to Use.*to Sign In.*" + timeout: 30000 +- takeScreenshot: auth-session-alert +- tapOn: + text: "Continue" + below: ".*Wants to Use.*to Sign In.*" diff --git a/.maestro/subflows/allow-notifications.yaml b/.maestro/subflows/allow-notifications.yaml new file mode 100644 index 00000000..154ee098 --- /dev/null +++ b/.maestro/subflows/allow-notifications.yaml @@ -0,0 +1,21 @@ +# The app asks for notification permission now and then (calendar +# reminders, push). The system alert covers the whole app, so XCTest sees +# nothing else until it is answered: allow it when it is up. Taps on system +# alerts miss in landscape, so answer it in portrait. +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + visible: ".*Would Like to Send You Notifications.*" + commands: + - setOrientation: PORTRAIT + - waitForAnimationToEnd: + timeout: 3000 + - tapOn: + text: "Allow" + optional: true + - runFlow: + when: + true: ${ORIENTATION == 'landscape'} + commands: + - setOrientation: LANDSCAPE_LEFT diff --git a/.maestro/subflows/assert-delivered.yaml b/.maestro/subflows/assert-delivered.yaml new file mode 100644 index 00000000..d2bfc2fe --- /dev/null +++ b/.maestro/subflows/assert-delivered.yaml @@ -0,0 +1,18 @@ +# The helper checks over JMAP that AS (default userb) received SUBJECT; with +# ATTACHMENT set, that the message carries that file. +appId: org.bulwarkmail.mobile +--- +- runScript: + file: ../scripts/helper.js + env: + CALL: /find?as=${AS || 'userb'}&subject=${encodeURIComponent(SUBJECT)}&timeout=60 +- assertTrue: + condition: ${output.helper.found} + label: "${SUBJECT} delivered to ${AS || 'userb'}" +- runFlow: + when: + true: ${!!ATTACHMENT} + commands: + - assertTrue: + condition: ${output.helper.attachments.indexOf(ATTACHMENT) >= 0} + label: "${SUBJECT} carries ${ATTACHMENT}" diff --git a/.maestro/subflows/check-active-account.yaml b/.maestro/subflows/check-active-account.yaml new file mode 100644 index 00000000..b0a1fabb --- /dev/null +++ b/.maestro/subflows/check-active-account.yaml @@ -0,0 +1,17 @@ +# The Settings account card names the active account ACTIVE, and the Account +# page lists another account to switch to. Goes back to Mail afterwards. +appId: org.bulwarkmail.mobile +--- +- tapOn: + id: tab-settings +- extendedWaitUntil: + visible: ".*${ACTIVE}.*" + timeout: 20000 +- tapOn: ".*${ACTIVE}.*" +- scrollUntilVisible: + element: "Switch to this account" + direction: DOWN + timeout: 20000 +- takeScreenshot: accounts-${ACTIVE} +- tapOn: + id: tab-mail diff --git a/.maestro/subflows/clear-field.yaml b/.maestro/subflows/clear-field.yaml new file mode 100644 index 00000000..f56abd4d --- /dev/null +++ b/.maestro/subflows/clear-field.yaml @@ -0,0 +1,11 @@ +# Empty the field with id FIELD. A tap puts the cursor where it lands and +# eraseText only deletes before the cursor, so erase in passes: each tap +# lands in the middle of what is left. +appId: org.bulwarkmail.mobile +--- +- repeat: + times: 4 + commands: + - tapOn: + id: ${FIELD} + - eraseText: 100 diff --git a/.maestro/subflows/close-message.yaml b/.maestro/subflows/close-message.yaml new file mode 100644 index 00000000..1d968029 --- /dev/null +++ b/.maestro/subflows/close-message.yaml @@ -0,0 +1,11 @@ +# Leave the message view: Back on a phone; the split layout keeps the list. +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + visible: "Back" + commands: + - tapOn: "Back" +# A tap during the transition lands on stale coordinates. +- waitForAnimationToEnd: + timeout: 5000 diff --git a/.maestro/subflows/compose-body.yaml b/.maestro/subflows/compose-body.yaml new file mode 100644 index 00000000..a4968780 --- /dev/null +++ b/.maestro/subflows/compose-body.yaml @@ -0,0 +1,41 @@ +# In the composer: type BODY into the editor. The rich editor is a WebView +# whose placeholder is CSS, so it may not be exposed; then tap the middle of +# the screen, which is the body area below Subject (above the keyboard). +# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it +# to the body (an iPad in landscape shows hardly any body with the keyboard +# up, so there is nothing to tap). +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + true: ${FROM_SUBJECT == 'yes'} + commands: + - pressKey: Enter + - waitForAnimationToEnd: + timeout: 2000 + - inputText: ${BODY} +- runFlow: + when: + true: ${FROM_SUBJECT != 'yes'} + commands: + - runFlow: + when: + visible: "Write your message.*" + commands: + - tapOn: "Write your message.*" + # A tap on the WebView doesn't always take the focus from the Subject field + # (on the iPad the body once went into Subject); a second tap does. + # (hideKeyboard fails here: the composer's keyboard has no dismiss key.) + - runFlow: + when: + notVisible: "Write your message.*" + commands: + - tapOn: + point: "50%,50%" + - waitForAnimationToEnd: + timeout: 2000 + - tapOn: + point: "50%,50%" + - waitForAnimationToEnd: + timeout: 3000 + - inputText: ${BODY} diff --git a/.maestro/subflows/confirm-link.yaml b/.maestro/subflows/confirm-link.yaml new file mode 100644 index 00000000..5547676c --- /dev/null +++ b/.maestro/subflows/confirm-link.yaml @@ -0,0 +1,20 @@ +# A sign-in link from another app asks first. Continue. +appId: org.bulwarkmail.mobile +--- +# simctl openurl acts like a link from another app: iOS asks first. +- extendedWaitUntil: + visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*" + timeout: ${LINK_WAIT || 60000} +- runFlow: + when: + visible: "Open in .*Bulwark Mobile.*" + commands: + - tapOn: "Open" +# Right after a sign-out the app can take a while to get to the link. +- extendedWaitUntil: + visible: "Sign in with a link.|Open the sign-in page of.*" + timeout: 60000 +- takeScreenshot: link-confirm-prompt +- tapOn: + text: "Continue" + below: "Sign in with a link." diff --git a/.maestro/subflows/dismiss-keyboard-tip.yaml b/.maestro/subflows/dismiss-keyboard-tip.yaml new file mode 100644 index 00000000..ae0e82a2 --- /dev/null +++ b/.maestro/subflows/dismiss-keyboard-tip.yaml @@ -0,0 +1,12 @@ +# iOS shows a one-time slide-to-type tip over the keyboard on a fresh +# simulator. The workflow turns it off, but dismiss it if it still shows up. +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + visible: "Speed up your typing by sliding.*" + commands: + # The sign-in screen has its own Continue button; take the tip's. + - tapOn: + text: "Continue" + below: "Speed up your typing by sliding.*" diff --git a/.maestro/subflows/finish-two-factor-if-asked.yaml b/.maestro/subflows/finish-two-factor-if-asked.yaml new file mode 100644 index 00000000..84fa264f --- /dev/null +++ b/.maestro/subflows/finish-two-factor-if-asked.yaml @@ -0,0 +1,12 @@ +# Some routes hand the app a password for a TOTP account; the app then asks +# for a code itself. Answer it when it shows up. +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: "Authentication code|Inbox" + timeout: 90000 +- runFlow: + when: + visible: "Authentication code" + commands: + - runFlow: two-factor.yaml diff --git a/.maestro/subflows/go-to-folder.yaml b/.maestro/subflows/go-to-folder.yaml new file mode 100644 index 00000000..8c9526cf --- /dev/null +++ b/.maestro/subflows/go-to-folder.yaml @@ -0,0 +1,9 @@ +# Open folder FOLDER from the drawer or docked sidebar. +appId: org.bulwarkmail.mobile +--- +- runFlow: open-drawer.yaml +# A folder row's label joins its texts: ", , /, ". +- tapOn: ${FOLDER}([ ,].*)? +- extendedWaitUntil: + visible: ${FOLDER} + timeout: 15000 diff --git a/.maestro/subflows/go-to-inbox.yaml b/.maestro/subflows/go-to-inbox.yaml new file mode 100644 index 00000000..4b1b84cd --- /dev/null +++ b/.maestro/subflows/go-to-inbox.yaml @@ -0,0 +1,16 @@ +# Back to the inbox list, from wherever in the Mail tab. +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + visible: "Back" + commands: + - tapOn: "Back" +- runFlow: + file: go-to-folder.yaml + env: + FOLDER: Inbox +- extendedWaitUntil: + visible: + id: email-row + timeout: 60000 diff --git a/.maestro/subflows/inbox.yaml b/.maestro/subflows/inbox.yaml new file mode 100644 index 00000000..564c8604 --- /dev/null +++ b/.maestro/subflows/inbox.yaml @@ -0,0 +1,26 @@ +# Signed in: the tab bar is there and the inbox shows messages. Dismisses the +# one-time prompts on the way. +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: + id: tab-mail + timeout: 90000 +- runFlow: + when: + visible: "Not now" + commands: + - tapOn: "Not now" +# Adding an account from Settings returns to Settings. +- tapOn: + id: tab-mail +- extendedWaitUntil: + visible: + id: email-row + timeout: 60000 +# Rows are one element: sender, subject and preview together. Mail the +# tests sent earlier can push the seeded row below the fold. +- scrollUntilVisible: + element: ${INBOX_TEXT || '.*Your verification code.*'} + direction: DOWN + timeout: 30000 diff --git a/.maestro/subflows/manual-server.yaml b/.maestro/subflows/manual-server.yaml new file mode 100644 index 00000000..6d521db3 --- /dev/null +++ b/.maestro/subflows/manual-server.yaml @@ -0,0 +1,31 @@ +# From the sign-in choices: enter a server address by hand and continue to the +# confirm step. env HOST: the address as a user types it. +appId: org.bulwarkmail.mobile +--- +- tapOn: "Enter server details manually" +- extendedWaitUntil: + visible: + id: login-server-input + timeout: 10000 +- runFlow: + file: type.yaml + env: + FIELD: login-server-input + TAP: "no" + TEXT: ${HOST} +- tapOn: "Continue" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 + optional: true +# Once in a while the tap doesn't reach the button (it moves as the keyboard +# goes away): tap again if the form is still there. +- runFlow: + when: + visible: + id: login-server-input + commands: + - tapOn: "Continue" +- extendedWaitUntil: + visible: "Sign in with a password instead" + timeout: 20000 diff --git a/.maestro/subflows/open-add-account.yaml b/.maestro/subflows/open-add-account.yaml new file mode 100644 index 00000000..c6d86cd4 --- /dev/null +++ b/.maestro/subflows/open-add-account.yaml @@ -0,0 +1,19 @@ +# Signed in: Settings → account card → Account page → Add account, up to the +# add-mode choices. (On the iPad split layout the Account page is already +# open; tapping the card again is harmless.) +appId: org.bulwarkmail.mobile +--- +- tapOn: + id: tab-settings +- extendedWaitUntil: + visible: ".*@example.org, .*" + timeout: 10000 +- tapOn: ".*@example.org, .*" +- scrollUntilVisible: + element: "Add account" + direction: DOWN + timeout: 20000 +- tapOn: "Add account" +- extendedWaitUntil: + visible: "Add another account" + timeout: 10000 diff --git a/.maestro/subflows/open-drawer.yaml b/.maestro/subflows/open-drawer.yaml new file mode 100644 index 00000000..d7c277fc --- /dev/null +++ b/.maestro/subflows/open-drawer.yaml @@ -0,0 +1,24 @@ +# Show the folder list: the modal drawer, or nothing to do where the sidebar +# is docked (iPad landscape). +appId: org.bulwarkmail.mobile +--- +- waitForAnimationToEnd: + timeout: 3000 +- runFlow: + when: + visible: "Toggle menu" + commands: + - tapOn: "Toggle menu" +- extendedWaitUntil: + visible: "Scheduled.*" + timeout: 5000 + optional: true +# Once more if the first tap went to a list still settling. +- runFlow: + when: + notVisible: "Scheduled.*" + commands: + - tapOn: "Toggle menu" +- extendedWaitUntil: + visible: "Scheduled.*" + timeout: 10000 diff --git a/.maestro/subflows/open-message.yaml b/.maestro/subflows/open-message.yaml new file mode 100644 index 00000000..e61ad435 --- /dev/null +++ b/.maestro/subflows/open-message.yaml @@ -0,0 +1,36 @@ +# From a message list: open the message whose row mentions SUBJECT. +# A row near the bottom can count as visible while the phone's tab bar +# covers it (the tap then opens another tab): on the phone, scroll it to the +# middle. The iPad's list is a column Maestro's scroll (in the middle of the +# screen) doesn't reach; its rows fit on screen. +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + true: ${DEVICE != 'tablet'} + commands: + - scrollUntilVisible: + element: ".*${SUBJECT}.*" + direction: DOWN + centerElement: true + visibilityPercentage: 100 + timeout: 30000 +- runFlow: + when: + true: ${DEVICE == 'tablet'} + commands: + - scrollUntilVisible: + element: ".*${SUBJECT}.*" + direction: DOWN + timeout: 30000 +# A tap while the list still moves lands on another row. +- waitForAnimationToEnd: + timeout: 5000 +- tapOn: ".*${SUBJECT}.*" +# The message toolbar's "more" button: "Reply All" shows on the phone, but +# the iPad pane has a quick reply box instead for mail to one recipient. +- extendedWaitUntil: + visible: "more" + timeout: 20000 +- waitForAnimationToEnd: + timeout: 5000 diff --git a/.maestro/subflows/password-form.yaml b/.maestro/subflows/password-form.yaml new file mode 100644 index 00000000..5ba26f5b --- /dev/null +++ b/.maestro/subflows/password-form.yaml @@ -0,0 +1,19 @@ +# On the password step: fill in LOGIN and PASSWORD and sign in. +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: + id: login-username-input + timeout: 20000 +- runFlow: + file: type.yaml + env: + FIELD: login-username-input + TEXT: ${LOGIN} +- runFlow: + file: type-secret.yaml + env: + FIELD: login-password-input + TEXT: ${PASSWORD} +# Return on the password field signs in. +- pressKey: Enter diff --git a/.maestro/subflows/password-sign-in.yaml b/.maestro/subflows/password-sign-in.yaml new file mode 100644 index 00000000..ebd6af1f --- /dev/null +++ b/.maestro/subflows/password-sign-in.yaml @@ -0,0 +1,11 @@ +# From the sign-in choices to the inbox: manual server, password. +# env LOGIN: the account's address. +appId: org.bulwarkmail.mobile +--- +- runFlow: + file: manual-server.yaml + env: + HOST: ${SERVER} +- tapOn: "Sign in with a password instead" +- runFlow: password-form.yaml +- runFlow: inbox.yaml diff --git a/.maestro/subflows/paste-link.yaml b/.maestro/subflows/paste-link.yaml new file mode 100644 index 00000000..ef3c6cfa --- /dev/null +++ b/.maestro/subflows/paste-link.yaml @@ -0,0 +1,22 @@ +# The helper has put a sign-in link on the simulator's clipboard: open +# "Paste sign-in link", paste it and sign in. +appId: org.bulwarkmail.mobile +--- +- tapOn: "Paste sign-in link.*" +- extendedWaitUntil: + visible: "Paste" + timeout: 10000 +- tapOn: "Paste" +# iOS asks before an app reads the clipboard. +- runFlow: + when: + visible: "Allow Paste" + commands: + - tapOn: "Allow Paste" +- extendedWaitUntil: + visible: "bulwarkmail://pair.*" + timeout: 10000 +- takeScreenshot: paste-link-filled +- tapOn: + text: "Sign in" + below: "bulwarkmail://pair.*" diff --git a/.maestro/subflows/push-and-tap.yaml b/.maestro/subflows/push-and-tap.yaml new file mode 100644 index 00000000..c126d843 --- /dev/null +++ b/.maestro/subflows/push-and-tap.yaml @@ -0,0 +1,23 @@ +# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner +# where it appears, at once (it shows for a few seconds, about as long as +# Maestro takes to find it). If the tap came too early or too late, push +# again: up to three tries until the message is open. +appId: org.bulwarkmail.mobile +--- +- repeat: + while: + notVisible: "Reply All" + times: 3 + commands: + - runScript: + file: ../scripts/helper.js + env: + CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)} + - tapOn: + point: "50%,9%" + waitToSettleTimeoutMs: 500 + retryTapIfNoChange: false + - extendedWaitUntil: + visible: "Reply All" + timeout: 30000 + optional: true diff --git a/.maestro/subflows/settings-page.yaml b/.maestro/subflows/settings-page.yaml new file mode 100644 index 00000000..68104a56 --- /dev/null +++ b/.maestro/subflows/settings-page.yaml @@ -0,0 +1,87 @@ +# Settings list: find the row ROW (regex on "Title, summary"), open it, +# check PROOF shows, screenshot, and go back on a phone. +# +# Rows count as visible while the phone's tab bar or header covers them or +# while they hang half off the iPad's screen, and a tap then misses; the +# iPad's list is a left column Maestro's scroll (in the middle of the screen) +# doesn't reach. So search for the page (Q, a word of its title): the row +# then sits right under the search box. The account card (no Q) is always +# at the top. +appId: org.bulwarkmail.mobile +--- +- runFlow: + when: + true: ${!!Q} + commands: + - tapOn: "Search settings" + - eraseText: 40 + - inputText: ${Q} + - waitForAnimationToEnd: + timeout: 3000 + # Now and then a typed character goes missing: type it again. + - runFlow: + when: + notVisible: ${ROW} + commands: + - tapOn: "Search settings" + - eraseText: 40 + - inputText: ${Q} + - waitForAnimationToEnd: + timeout: 3000 +# A tap while the list still coasts from the swipe only stops it. +- waitForAnimationToEnd: + timeout: 5000 +- tapOn: ${ROW} +- waitForAnimationToEnd: + timeout: 5000 +# On the phone the page replaces the list: make sure it opened before +# scrolling (else the swipes below scroll the list away). +- runFlow: + when: + true: ${DEVICE != 'tablet'} + commands: + - runFlow: + when: + notVisible: "Back" + commands: + - tapOn: ${ROW} + - extendedWaitUntil: + visible: "Back" + timeout: 10000 +# PROOF can be further down the page: scroll the page (the right-hand pane +# on the iPad) until it shows. +- runFlow: + when: + true: ${DEVICE == 'tablet'} + commands: + - repeat: + while: + notVisible: ${PROOF} + times: 6 + commands: + - swipe: + start: 75%,80% + end: 75%,40% + duration: 600 +- runFlow: + when: + true: ${DEVICE != 'tablet'} + commands: + - repeat: + while: + notVisible: ${PROOF} + times: 6 + commands: + - swipe: + start: 50%,80% + end: 50%,40% + duration: 600 +- extendedWaitUntil: + visible: ${PROOF} + timeout: 20000 +- takeScreenshot: settings-${NAME} +- runFlow: + when: + visible: "Back" + commands: + - tapOn: "Back" diff --git a/.maestro/subflows/sign-out.yaml b/.maestro/subflows/sign-out.yaml new file mode 100644 index 00000000..fb16fe3a --- /dev/null +++ b/.maestro/subflows/sign-out.yaml @@ -0,0 +1,37 @@ +# Signed in: sign out of the active account from the end of Settings. +# On the iPad the Settings list is the left column; scroll that one. +appId: org.bulwarkmail.mobile +--- +- tapOn: + id: tab-settings +- runFlow: + when: + true: ${DEVICE == 'tablet'} + commands: + - repeat: + while: + notVisible: "Sign out" + times: 12 + commands: + - swipe: + start: 30%,85% + end: 30%,30% + duration: 500 +- runFlow: + when: + true: ${DEVICE != 'tablet'} + commands: + - scrollUntilVisible: + element: "Sign out" + direction: DOWN + timeout: 30000 +- tapOn: "Sign out" +- extendedWaitUntil: + visible: "The account is removed from this device.*" + timeout: 10000 +# The dialog's button, not the Settings row of the same name behind it. +- tapOn: + text: "Sign out" + rightOf: "Cancel" + below: "The account is removed from this device.*" +- takeScreenshot: sign-out-tapped diff --git a/.maestro/subflows/signed-in.yaml b/.maestro/subflows/signed-in.yaml new file mode 100644 index 00000000..d0834467 --- /dev/null +++ b/.maestro/subflows/signed-in.yaml @@ -0,0 +1,36 @@ +# Feature flows start here: the app signed in to ACCOUNT, on the inbox. +# Keeps the app's data between flows; signs in only when needed. +appId: org.bulwarkmail.mobile +--- +- launchApp: + permissions: + notifications: allow + photos: allow + camera: allow +# Answer the notification prompt (if the app asks) before turning to +# landscape: rotated, taps on system alerts miss. +- setOrientation: PORTRAIT +- extendedWaitUntil: + visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*" + timeout: 120000 +- runFlow: allow-notifications.yaml +- runFlow: + when: + true: ${ORIENTATION == 'landscape'} + commands: + - setOrientation: LANDSCAPE_LEFT +- extendedWaitUntil: + visible: "Enter server details manually|Inbox" + timeout: 30000 +- runFlow: + when: + visible: "Enter server details manually" + commands: + - runFlow: + file: password-sign-in.yaml + env: + LOGIN: ${ACCOUNT}@example.org +- runFlow: allow-notifications.yaml +- tapOn: + id: tab-mail +- runFlow: go-to-inbox.yaml diff --git a/.maestro/subflows/stalwart-login-page.yaml b/.maestro/subflows/stalwart-login-page.yaml new file mode 100644 index 00000000..b2ae066c --- /dev/null +++ b/.maestro/subflows/stalwart-login-page.yaml @@ -0,0 +1,28 @@ +# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any +# non-empty value: the account needs a one-time code). +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: "you@example.com" + timeout: 60000 +- takeScreenshot: stalwart-login-page +- runFlow: + file: type-web.yaml + env: + HINT: "you@example.com" + TEXT: ${LOGIN} +- tapOn: "••••••••" +- inputText: ${PASSWORD} +- runFlow: + when: + true: ${TOTP == 'yes'} + commands: + - runScript: + file: ../scripts/helper.js + env: + CALL: /totp + - tapOn: "000000" + - inputText: ${output.helper.code} +- tapOn: + text: "Sign in" + index: 1 diff --git a/.maestro/subflows/start.yaml b/.maestro/subflows/start.yaml new file mode 100644 index 00000000..3dc66523 --- /dev/null +++ b/.maestro/subflows/start.yaml @@ -0,0 +1,27 @@ +# Fresh install state, rotated for the landscape leg, on the sign-in choices. +# env CAMERA: allow (default) or deny. +appId: org.bulwarkmail.mobile +--- +# A system prompt left over from an earlier flow ("Open in …?") stays on +# screen across app launches. +- runFlow: + when: + visible: "Open in .*" + commands: + - tapOn: "Cancel" +- launchApp: + clearState: true + clearKeychain: true + permissions: + notifications: allow + camera: ${CAMERA || 'allow'} + photos: allow +- runFlow: + when: + true: ${ORIENTATION == 'landscape'} + commands: + - setOrientation: LANDSCAPE_LEFT +# A cold start of a release build on a CI simulator can be slow. +- extendedWaitUntil: + visible: "Enter server details manually" + timeout: 120000 diff --git a/.maestro/subflows/two-factor.yaml b/.maestro/subflows/two-factor.yaml new file mode 100644 index 00000000..58cf5e64 --- /dev/null +++ b/.maestro/subflows/two-factor.yaml @@ -0,0 +1,21 @@ +# The two-factor field is showing (it focuses itself): type a fresh code from +# the helper and sign in. +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: "Authentication code" + timeout: 30000 +- runScript: + file: ../scripts/helper.js + env: + CALL: /totp +- waitForAnimationToEnd: + timeout: 3000 +- inputText: ${output.helper.code} +# The number pad has no return key. +- runFlow: + when: + notVisible: "Sign in" + commands: + - hideKeyboard +- tapOn: "Sign in" diff --git a/.maestro/subflows/type-secret.yaml b/.maestro/subflows/type-secret.yaml new file mode 100644 index 00000000..3200c6c5 --- /dev/null +++ b/.maestro/subflows/type-secret.yaml @@ -0,0 +1,22 @@ +# Type a secret into the field with id FIELD (TAP=no for a field that focuses +# itself; see type.yaml for why the first character goes alone). The value +# can't be checked (a secure field shows dots) and must never be shown, so a +# lost keystroke surfaces as a failed sign-in. +appId: org.bulwarkmail.mobile +--- +- evalScript: ${output.secretHead = TEXT.slice(0, 1); output.secretTail = TEXT.slice(1)} +- runFlow: + when: + true: ${TAP != 'no'} + commands: + - tapOn: + id: ${FIELD} +- waitForAnimationToEnd: + timeout: 3000 +- inputText: ${output.secretHead} +# A short pause: wait for something that never shows. +- extendedWaitUntil: + visible: "zz-no-such-element-zz" + timeout: 1000 + optional: true +- inputText: ${output.secretTail} diff --git a/.maestro/subflows/type-web.yaml b/.maestro/subflows/type-web.yaml new file mode 100644 index 00000000..4f899808 --- /dev/null +++ b/.maestro/subflows/type-web.yaml @@ -0,0 +1,27 @@ +# Type TEXT into a web page field found by its placeholder HINT (the sign-in +# pages in the auth session), and check it arrived as typed. Now and then a +# stray character lands in front of it on the CI simulator; then erase the +# field and type again. The caret is still at the end of what was just +# typed, so eraseText (which deletes before the caret) empties it; selecting +# the text with a long press didn't work and appended instead. SECRET=yes +# skips the check (a password field shows dots). +appId: org.bulwarkmail.mobile +--- +- tapOn: ${HINT} +- waitForAnimationToEnd: + timeout: 3000 +- inputText: ${TEXT} +- runFlow: + when: + true: ${SECRET != 'yes'} + commands: + - repeat: + while: + notVisible: ${TEXT} + times: 2 + commands: + - eraseText: 150 + - waitForAnimationToEnd: + timeout: 2000 + - inputText: ${TEXT} + - assertVisible: ${TEXT} diff --git a/.maestro/subflows/type.yaml b/.maestro/subflows/type.yaml new file mode 100644 index 00000000..f5ebb2e2 --- /dev/null +++ b/.maestro/subflows/type.yaml @@ -0,0 +1,54 @@ +# Type TEXT into the field with id FIELD and check it arrived in full. +# +# iOS drops keystrokes in two situations seen on the CI simulator: while the +# edit menu (the "AutoFill" bubble) opens, which happens when a flow taps a +# field that already has focus, and right after the first character, while +# the field re-renders. So: no tap for a field that focuses itself (TAP=no; +# Maestro can't see a React Native field's focus), type the first character, +# wait until it shows, then type the rest. Retyped up to twice. +appId: org.bulwarkmail.mobile +--- +- evalScript: ${output.typeHead = TEXT.slice(0, 1); output.typeTail = TEXT.slice(1)} +- runFlow: + when: + true: ${TAP != 'no'} + commands: + - tapOn: + id: ${FIELD} +- waitForAnimationToEnd: + timeout: 3000 +# ERASE=yes: the field already holds text. +- runFlow: + when: + true: ${ERASE == 'yes'} + commands: + - runFlow: clear-field.yaml +- inputText: ${output.typeHead} +- extendedWaitUntil: + visible: + id: ${FIELD} + text: "${output.typeHead}.*" + timeout: 5000 + optional: true +- inputText: ${output.typeTail} +- runFlow: + when: + notVisible: + id: ${FIELD} + text: ${TEXT_PATTERN || TEXT} + commands: + - retry: + maxRetries: 2 + commands: + - runFlow: clear-field.yaml + - inputText: ${output.typeHead} + - extendedWaitUntil: + visible: + id: ${FIELD} + text: "${output.typeHead}.*" + timeout: 5000 + optional: true + - inputText: ${output.typeTail} + - assertVisible: + id: ${FIELD} + text: ${TEXT_PATTERN || TEXT} diff --git a/.maestro/subflows/webmail-login-page.yaml b/.maestro/subflows/webmail-login-page.yaml new file mode 100644 index 00000000..e2c8680c --- /dev/null +++ b/.maestro/subflows/webmail-login-page.yaml @@ -0,0 +1,41 @@ +# The Bulwark webmail's sign-in page inside the auth session (the app's +# "Sign in via webmail" hand-off). env LOGIN, TOTP=yes for a TOTP account. +appId: org.bulwarkmail.mobile +--- +- extendedWaitUntil: + visible: "user@example.com" + timeout: 60000 +- takeScreenshot: webmail-login-page +- runFlow: + file: type-web.yaml + env: + HINT: "user@example.com" + TEXT: ${LOGIN} +- runFlow: + file: type-web.yaml + env: + HINT: "Enter your password" + TEXT: ${PASSWORD} + SECRET: "yes" +- tapOn: "Sign in" +- runFlow: + when: + true: ${TOTP == 'yes'} + commands: + - extendedWaitUntil: + visible: "000000" + timeout: 30000 + - runScript: + file: ../scripts/helper.js + env: + CALL: /totp + - tapOn: "000000" + - inputText: ${output.helper.code} + - tapOn: "Sign in|Verify" + +# Before handing over, the webmail asks whether the app may take the sign-in. +- extendedWaitUntil: + visible: "Hand over to the app" + timeout: 60000 +- takeScreenshot: webmail-handover-prompt +- tapOn: "Hand over to the app" diff --git a/App.tsx b/App.tsx index 1020ec32..314ea587 100644 --- a/App.tsx +++ b/App.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useColorScheme } from 'react-native'; import { DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused, } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack'; -import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native'; +import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons'; import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream'; import { jmapClient } from './src/api/jmap-client'; @@ -19,6 +20,7 @@ import { startCalendarReminderTapHandling, } from './src/lib/calendar-notifications'; import { startLivenessMonitor } from './src/lib/connection-liveness'; +import { startDeviceSyncTriggers } from './src/device-sync/app/triggers'; import { setPendingCalendarOpen, type CalendarReminderTarget, @@ -38,15 +40,18 @@ import { type NotificationTapPayload, } from './src/lib/push-notifications'; import { addUnifiedPushEndpointListener } from './src/lib/unified-push'; + +// Pause between a mute change and the push-filter update it sends (#78). +const MUTES_RESYNC_DELAY_MS = 1_000; import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; import ComposeScreen from './src/screens/ComposeScreen'; import EmailThreadScreen from './src/screens/EmailThreadScreen'; import EmailSourceScreen from './src/screens/EmailSourceScreen'; import LoginScreen from './src/screens/LoginScreen'; -import EmailListScreen from './src/screens/EmailListScreen'; +import MailTab from './src/screens/MailTab'; import FilesScreen from './src/screens/FilesScreen'; import CalendarScreen from './src/screens/CalendarScreen'; -import ContactsScreen from './src/screens/ContactsScreen'; +import ContactsTab from './src/screens/ContactsTab'; import ContactDetailScreen from './src/screens/ContactDetailScreen'; import ContactFormScreen from './src/screens/ContactFormScreen'; import GroupDetailScreen from './src/screens/GroupDetailScreen'; @@ -57,12 +62,13 @@ import { useAccountStore } from './src/stores/account-store'; import { useAuthStore } from './src/stores/auth-store'; import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; -import { useEmailStore, viewerParamsForRow } from './src/stores/email-store'; -import { mailboxAccountId } from './src/lib/mailbox-tree'; -import { prefetchMessage } from './src/lib/email-detail-cache'; +import { useEmailStore } from './src/stores/email-store'; +import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { useLocaleStore } from './src/stores/locale-store'; +import { useLayout } from './src/lib/use-layout'; +import { toast } from './src/stores/toast-store'; import { useNetworkStore } from './src/stores/network-store'; import { useUpdatesStore } from './src/stores/updates-store'; import { UpdateBanner } from './src/components/UpdateBanner'; @@ -71,19 +77,35 @@ import { ToastHost } from './src/components/ToastHost'; import { UndoSnackbar } from './src/components/UndoSnackbar'; import { AppIconBadge } from './src/components/AppIconBadge'; import { getEmails } from './src/api/email'; -import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking'; +import { signOutWidgets, startWidgetSync } from './src/widgets/sync'; +import { draftContextFromEmail } from './src/lib/draft-context'; +import { + acceptSignInLink, + handleDeepLink, + parseDeepLink, + routeParkedSignInLink, + shareToDeepLink, + type DeepLink, +} from './src/navigation/linking'; +import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link'; +import { MAX_ACCOUNTS } from './src/lib/account-utils'; import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent'; import { OfflineCacheBanner } from './src/components/OfflineCacheBanner'; import { useOfflineCacheStore } from './src/stores/offline-cache-store'; import { useOutboxStore } from './src/stores/outbox-store'; import { runOfflineSync } from './src/lib/offline-sync'; import { spacing, typography, type ThemePalette } from './src/theme/tokens'; +import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks'; import { useColors } from './src/theme/colors'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); const navigationRef = createNavigationContainerRef(); +// The launch URL is read once per process: Android hands the same intent back +// on every getInitialURL(), and a sign-in link must not run twice. +let initialSignInLinkRead = false; + async function navigateToNotificationTap(payload: NotificationTapPayload): Promise { if (!navigationRef.isReady()) return; @@ -103,9 +125,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, + emailIds: [payload.emailId], subject: payload.subject, // A group mailbox's message lives under another JMAP account (#839). jmapAccountId: notificationTapJmapAccountId(payload), + ...(payload.action === 'reply' ? { action: 'reply' as const } : {}), }); } @@ -132,16 +156,27 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise { - await handleDeepLink(link, { + const opened = await handleDeepLink(link, { navigation: navigationRef, - resolveThreadId: async (emailId) => { + resolveThreadId: async (emailId, jmapAccountId) => { try { - const [email] = await getEmails([emailId]); + const [email] = await getEmails([emailId], jmapAccountId); return email?.threadId ?? null; } catch { return null; } }, + openDraft: async (emailId, jmapAccountId) => { + try { + // Cache first, so a draft read before still opens offline. + const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId); + if (!navigationRef.isReady()) return false; + navigationRef.navigate('Compose', { draft }); + return true; + } catch { + return false; + } + }, switchAccount: async (accountId) => { const auth = useAuthStore.getState(); if (auth.activeAccountId === accountId) return true; @@ -150,6 +185,9 @@ async function openDeepLink(link: DeepLink): Promise { return useAuthStore.getState().activeAccountId === accountId; }, }); + // A widget or a link elsewhere brought the app up for this; say it failed + // rather than leave the user wondering why the app just opened. + if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this")); } // Toasts and the undo bar show on whichever stack screen is up, so a failure @@ -211,21 +249,43 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps t('sidebar.tab_unavailable', '{name} (unavailable)', { name }); + // Side by side layouts move the tabs into a rail on the left, like the + // webmail's navigation rail. + const { isSplit } = useLayout(); + // A bottom tab bar keeps the screens clear of the gesture bar or taskbar; + // with the tabs in a side rail the whole tab area has to. + const bottomInset = useSafeAreaInsets().bottom; return ( - + ({ + tabPress: () => perfStart('tab-switch'), + focus: () => perfEndAfterPaint('tab-switch', { to: route.name }), + }) : undefined} screenOptions={{ headerShown: false, // Tabs out of view stop re-rendering until they are shown again. freezeOnBlur: true, + tabBarPosition: isSplit ? 'left' : 'bottom', + tabBarVariant: isSplit ? 'material' : 'uikit', + // A narrow rail (icon over label), not a wide sidebar with labels + // beside the icons. + ...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null), tabBarActiveTintColor: c.text, tabBarInactiveTintColor: c.textSecondary, - tabBarStyle: { + tabBarStyle: isSplit ? { + backgroundColor: c.secondary, + borderRightColor: c.border, + borderRightWidth: 1, + elevation: 0, + shadowOpacity: 0, + shadowColor: 'transparent', + } : { backgroundColor: c.background, borderTopColor: c.border, borderTopWidth: 1, @@ -243,6 +303,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined, tabBarBadgeStyle: { @@ -259,36 +320,14 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps - {() => ( - navigation.navigate('Compose')} - onEmailPress={(email) => { - // The list holds the open folder's mail: name its account so the - // viewer never has to guess it from whatever folder is open. - const { mailboxes: all, currentMailboxId } = useEmailStore.getState(); - const target = { - jmapAccountId: mailboxAccountId(all, currentMailboxId), - // A row of an "All folders" list or a tag view names its own - // account instead, and pages over that account's rows. - ...viewerParamsForRow(email), - }; - // Start on the body now: mounting the viewer takes a while. - prefetchMessage(email, target.jmapAccountId); - navigation.navigate('EmailThread', { - emailId: email.id, - threadId: email.threadId, - subject: email.subject, - ...target, - }); - }} - /> - )} + {() => } , tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar), @@ -301,9 +340,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts), @@ -319,6 +359,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files), @@ -333,6 +374,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps , }} > @@ -370,6 +412,7 @@ export default function App() { // cached mail list instead of the "Restoring session" spinner; the real // JMAP session comes up in the background. const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null); + const { isSplit } = useLayout(); React.useEffect(() => { if (!hasRestoredSession) { @@ -383,9 +426,20 @@ export default function App() { // Calendar reminders are kept scheduled from launch, not only once the // Calendar tab has been opened. startCalendarNotificationSync(); + // Device sync (Android, #34): live changes, the app's own edits and the + // foreground ask Android to sync; accounts are checked against Android's. + startDeviceSyncTriggers(); return useNetworkStore.getState().init(); }, []); + // Home-screen widgets follow the signed-in data; once the last account is + // gone they are wiped so no mail stays visible on the launcher. + React.useEffect(() => { + if (isAuthenticated) return startWidgetSync(); + if (hasRestoredSession) void signOutWidgets(); + return undefined; + }, [isAuthenticated, hasRestoredSession]); + // When the network flips back on while we're authenticated-but-offline // (no live JMAP session), retry the session so the user lands back on // live data without needing to relaunch. @@ -542,6 +596,52 @@ export default function App() { }; }, [isAuthenticated]); + // Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App, + // `bulwarkmail://connect?…`) work signed out too, so unlike the links above + // they are listened for from launch. They are parked for the login screen + // (the signed-out one takes them as it mounts), marked as coming from + // outside the app: any page or app can fire one, so the login screen asks + // before it redeems or opens anything. + React.useEffect(() => { + if (!initialSignInLinkRead) { + initialSignInLinkRead = true; + void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined); + } + const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); }); + return () => subscription.remove(); + }, []); + + // Signed in, a sign-in link adds an account: open Add account, whose login + // screen takes the parked link (or says why a refused one won't be used). + // On a cold start the navigator mounts right after the auth gate flips, so + // wait for it like a reminder tap does. + const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null); + const addAccountForLink = isAuthenticated && signInLinkPending; + React.useEffect(() => { + if (!addAccountForLink) return; + let cancelled = false; + void (async () => { + for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (cancelled) return; + } + if (cancelled || !navigationRef.isReady()) return; + const route = routeParkedSignInLink(useAccountStore.getState().accounts.length); + if (route === 'account-limit') { + // Said like the Add account buttons say it, instead of opening a + // screen whose sign-in can only fail. + const t = useLocaleStore.getState().t; + toast.error( + t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }), + t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'), + ); + return; + } + if (route === 'add-account') navigationRef.navigate('AddAccount'); + })(); + return () => { cancelled = true; }; + }, [addAccountForLink]); + // Re-register the device with the configured relay once authenticated, // and whenever the FCM token rotates. Honours the user's notification // preference - flipping it off tears down THIS account's subscription so @@ -598,6 +698,41 @@ export default function App() { }; }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); + // Muting a folder or account changes the server-side push filter (#78), + // which the setup above would only send at the next launch. Send it now, + // after a short pause so a few taps in a row make one update. Compared by + // content, since hydration hands back an equal but new object; the value + // seen first after hydration is the baseline, not a change. + const notificationMutes = useSettingsStore((s) => s.notificationMutes); + const settingsHydrated = useSettingsStore((s) => s.hydrated); + const mutesKey = JSON.stringify(notificationMutes); + const lastMutesKey = React.useRef(null); + React.useEffect(() => { + if (!settingsHydrated) return; + if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) { + lastMutesKey.current = mutesKey; + return; + } + lastMutesKey.current = mutesKey; + if (!isAuthenticated || !client || !emailNotificationsEnabled) return; + let cancelled = false; + const timer = setTimeout(async () => { + const relayBaseUrl = await getStoredRelayBaseUrl(); + if (!relayBaseUrl || cancelled) return; + try { + await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined }); + } catch (error) { + console.warn('[push] mute update failed:', error instanceof Error ? error.message : error); + } + }, MUTES_RESYNC_DELAY_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + // Only a change of the mutes should send an update; the rest is read live. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mutesKey, settingsHydrated]); + // Live updates (SSE with polling fallback), re-armed on every account // switch and every re-established session — the singleton `client` object // never changes identity, so it cannot be the dependency on its own. @@ -749,10 +884,11 @@ export default function App() { + // Android account type of device sync (#34), derived from the package + // name of each flavor. The XML in res/xml and the Kotlin in sync/ read + // it as @string/device_sync_account_type. + variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account" + } signingConfigs { debug { storeFile file('debug.keystore') @@ -240,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') { } } tasks.configureEach { task -> - if (task.name == 'preReleaseBuild') { + if (task.name ==~ /pre\w*ReleaseBuild/) { task.dependsOn(checkReleaseSigning) } } @@ -280,6 +311,11 @@ dependencies { // Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read. implementation("org.unifiedpush.android:connector:3.3.5") + // JVM unit tests for the logic in the Kotlin shell of device sync (src/test). + // android.jar only stubs org.json, so the tests use the real one. + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; diff --git a/android/app/google-services.json b/android/app/google-services.json index 4517c7e1..63a45bc8 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -1,13 +1,13 @@ { "project_info": { - "project_number": "504486613721", - "project_id": "bulwark-mobile", - "storage_bucket": "bulwark-mobile.firebasestorage.app" + "project_number": "752109678859", + "project_id": "bulwark-20c72", + "storage_bucket": "bulwark-20c72.firebasestorage.app" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8", + "mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e", "android_client_info": { "package_name": "com.anonymous.bulwarkmobile" } @@ -15,7 +15,7 @@ "oauth_client": [], "api_key": [ { - "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + "current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4" } ], "services": { @@ -26,15 +26,15 @@ }, { "client_info": { - "mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8", + "mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e", "android_client_info": { - "package_name": "com.bulwark.bulwarkmobile" + "package_name": "org.bulwarkmail.mobile" } }, "oauth_client": [], "api_key": [ { - "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + "current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4" } ], "services": { diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 3ec2507b..1146debf 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -3,5 +3,8 @@ - + + + + diff --git a/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt b/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt new file mode 100644 index 00000000..b1bd2a4d --- /dev/null +++ b/android/app/src/debug/java/com/anonymous/bulwarkmobile/sync/DeviceSyncDebugReceiver.kt @@ -0,0 +1,108 @@ +package com.anonymous.bulwarkmobile.sync + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Base64 +import android.util.Log +import java.io.File +import kotlin.concurrent.thread + +/** + * Debug builds only: lets adb set up device sync accounts and drive the + * provider bridge without the settings UI or JS, for the device tests in + * docs/device-sync.md ("Testing"). Protected by android.permission.DUMP, + * which only the shell and the system hold. Syncs themselves are triggered + * with `adb shell requestsync`. + * + * adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \ + * --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \ + * --es enable contacts,calendar + * + * Commands: `ensure` (name, registry, optional enable), `enable` (name, + * authority, optional --ez on false), `remove` (name), `list`, and the + * provider calls `query` and `batch` (name, authority, and the + * `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares + * the shell's quoting, or pushed to the app's external files directory and + * named by `file` when it is too long for a command line), `syncstate` + * (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and + * the sync-problem notification, `problem` (name, title, text, uri, optional + * channel) and `clearproblem` (name). Results go to logcat (tag + * BulwarkDeviceSync) and the broadcast's result data. + */ +class DeviceSyncDebugReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pending = goAsync() + // Provider calls block; keep them off the main thread. + thread(name = "DeviceSyncDebugReceiver") { + val result = try { + run(context, intent) + } catch (e: Exception) { + "error: ${e.javaClass.simpleName}: ${e.message}" + } + Log.i(TAG, "debug command: $result") + pending.resultData = result + pending.finish() + } + } + + private fun run(context: Context, intent: Intent): String { + val name = intent.getStringExtra("name") + return when (intent.getStringExtra("cmd")) { + "ensure" -> { + val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!) + for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) { + DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true) + } + "created=$created" + } + "enable" -> { + val on = intent.getBooleanExtra("on", true) + DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on) + "enabled=$on" + } + "remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}" + "list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" } + "query" -> io(context, intent) { it.query(json(context, intent)) } + "batch" -> io(context, intent) { it.applyBatch(json(context, intent)) } + "syncstate" -> io(context, intent) { it.readSyncState().toString() } + "photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use { + it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString() + } + "problem" -> { + SyncProblemNotifier.show( + context, + name!!, + intent.getStringExtra("title").orEmpty(), + intent.getStringExtra("text").orEmpty(), + intent.getStringExtra("uri").orEmpty(), + intent.getStringExtra("channel"), + ) + "shown" + } + "clearproblem" -> { + SyncProblemNotifier.clear(context, name!!) + "cleared" + } + else -> "unknown cmd" + } + } + + private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String = + ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call) + + private fun json(context: Context, intent: Intent): String { + intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() } + return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8) + } + + private fun authority(which: String) = when (which) { + "contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY + "calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY + else -> which + } + + companion object { + private const val TAG = "BulwarkDeviceSync" + } +} diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..bea95852 --- /dev/null +++ b/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/android/app/src/github/AndroidManifest.xml b/android/app/src/github/AndroidManifest.xml new file mode 100644 index 00000000..747e48e7 --- /dev/null +++ b/android/app/src/github/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/github/google-services.json b/android/app/src/github/google-services.json new file mode 100644 index 00000000..4517c7e1 --- /dev/null +++ b/android/app/src/github/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "504486613721", + "project_id": "bulwark-mobile", + "storage_bucket": "bulwark-mobile.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8", + "android_client_info": { + "package_name": "com.anonymous.bulwarkmobile" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8", + "android_client_info": { + "package_name": "com.bulwark.bulwarkmobile" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a1318459..71324ad2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,10 +2,22 @@ - + - + + + + + + + + + + + + @@ -13,11 +25,11 @@ - + - + @@ -28,6 +40,13 @@ + + + + + + + @@ -59,12 +78,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt new file mode 100644 index 00000000..f6cbde75 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkBuildModule.kt @@ -0,0 +1,18 @@ +package com.anonymous.bulwarkmobile + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule + +/** + * Facts about this build that JS can't see on its own. `distribution` is the + * product flavor (app/build.gradle): "github" for the sideloaded APK with its + * own updater, "play" for the Google Play bundle, which must not update + * itself (lib/platform-capabilities). + */ +class BulwarkBuildModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + override fun getName() = "BulwarkBuild" + + override fun getConstants(): Map = mapOf( + "distribution" to BuildConfig.DISTRIBUTION, + ) +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 6922adbe..2bcc8ff7 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -4,6 +4,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.net.Uri import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas @@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle") ?: accountId ?: "Bulwark Mail" + val preview = options.takeIf { it.hasKey("preview") }?.getString("preview") + val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read" + val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete" + val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply" + // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. thread(name = "bulwark-notification") { val largeIcon = iconUrl?.let { fetchBitmap(it) } ?: makeLetterAvatar(initials, bgColorHex) postNotification( - notificationId, title, body, largeIcon, bgColorHex, + notificationId, title, body, preview, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, + groupTitle, markReadLabel, deleteLabel, replyLabel, ) if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId) promise.resolve(null) @@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) notificationId: String, title: String, body: String, + preview: String?, largeIcon: Bitmap, colorHex: String, emailId: String?, @@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) accountId: String?, jmapAccountId: String?, groupKey: String?, + groupTitle: String?, + markReadLabel: String, + deleteLabel: String, + replyLabel: String, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-notification://$notificationId") if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) @@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val bigText = buildString { + if (!subject.isNullOrBlank()) append(subject) + if (!preview.isNullOrBlank()) { + if (isNotEmpty()) append("\n") + append(preview) + } + } + val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setContentTitle(title) + // body is the subject with the "(No subject)" fallback; an empty + // subject is "" here, not null, so `subject ?: ...` showed a blank line. .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(if (bigText.isNotBlank()) bigText else body) + .setBigContentTitle(title) + .setSummaryText(groupTitle) + ) .setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb"))) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) } + if (emailId != null && accountId != null) { + // Action 1: Mark as read + val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_MARK_READ + data = Uri.parse("bulwark-action-read://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "markRead") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + val markReadPending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":read").hashCode(), + markReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build() + ) + + // Action 2: Delete + val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_DELETE + data = Uri.parse("bulwark-action-delete://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "delete") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + val deletePending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":delete").hashCode(), + deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, deleteLabel, deletePending) + // Android 12+: unlock first, so a locked phone can't delete mail. + .setAuthenticationRequired(true) + .build() + ) + + // Action 3: Reply + val replyIntent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-reply://$notificationId") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) + if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + putExtra(EXTRA_ACTION, "reply") + } + val replyPending = PendingIntent.getActivity( + ctx, + (notificationId + ":reply").hashCode(), + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, replyLabel, replyPending).build() + ) + } + val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(notificationId, notificationId.hashCode(), builder.build()) } @@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) it.groupKey?.endsWith(groupKey) == true && (it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0 } - val count = maxOf(children.size, 1) + if (children.size < 2) { + manager.cancel(groupKey, groupKey.hashCode()) + return + } + val count = children.size val inbox = NotificationCompat.InboxStyle() children.sortedByDescending { it.postTime }.take(5).forEach { sbn -> val extras = sbn.notification.extras @@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-group-summary://$groupKey") if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) } val pending = PendingIntent.getActivity( @@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) @Volatile private var currentInstance: BulwarkFcmModule? = null + const val EXTRA_ACTION = "bulwark.notification.action" + const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId" + const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ" + const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE" + fun emit(eventName: String, params: WritableMap?) { val module = currentInstance ?: return val ctx = module.reactApplicationContext diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt index f384dd56..73b9ded0 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt @@ -1,5 +1,6 @@ package com.anonymous.bulwarkmobile +import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule import com.facebook.react.ReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext @@ -12,6 +13,10 @@ class BulwarkFcmPackage : ReactPackage { add(BulwarkFcmModule(reactContext)) add(BulwarkUnifiedPushModule(reactContext)) add(BulwarkClientCertModule(reactContext)) + add(BulwarkDeviceSyncModule(reactContext)) + add(BulwarkWidgetsModule(reactContext)) + add(BulwarkPdfModule(reactContext)) + add(BulwarkBuildModule(reactContext)) // The bridge registers React Native's own module under this name. if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { add(BulwarkHeadlessJsTaskSupportModule(reactContext)) diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt index 41c553db..3959e5b6 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.os.PowerManager import android.util.Log import androidx.core.app.NotificationCompat +import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter import com.facebook.react.bridge.Arguments import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage @@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() { val data = message.data + // Contact and calendar changes become syncs of the Android accounts + // they feed (device sync, #34); only mail needs the push task. + val mail = DeviceSyncPushRouter.route(applicationContext, data) + // Hand off to JS via a headless task only when the app isn't already // running in the foreground - HeadlessJsTaskContext throws if started // while foreground. When the app is open, the main JS instance already // receives JMAP push directly, so the fcm:message event below is // sufficient for it to refresh state. - if (!isAppInForeground()) { + if (mail && !isAppInForeground()) { startHeadlessTask(applicationContext, data) } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt new file mode 100644 index 00000000..ae210a4e --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt @@ -0,0 +1,124 @@ +package com.anonymous.bulwarkmobile + +import android.app.Application +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import com.facebook.react.jstasks.HeadlessJsTaskEventListener + +class BulwarkNotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID) + val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION) + val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID) + val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID) + val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID) + + // Dismiss the notification immediately from the tray + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationId != null) { + manager.cancel(notificationId, notificationId.hashCode()) + } + + if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) { + Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId") + return + } + + // On Android 8+, a broadcast receiver in background cannot start a Service + // (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and + // HeadlessJs.withReadyReactContext to execute the headless JS task directly. + val pendingResult = goAsync() + val app = context.applicationContext as Application + val mainHandler = Handler(Looper.getMainLooper()) + + // Safety timeout to ensure pendingResult.finish() is always called + val timeoutRunnable = Runnable { + Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)") + try { + pendingResult.finish() + } catch (_: Exception) {} + } + mainHandler.postDelayed(timeoutRunnable, 20_000L) + + try { + HeadlessJs.withReadyReactContext(app) { reactContext -> + if (!reactContext.hasActiveReactInstance()) { + Log.w(TAG, "React context does not have active instance") + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + return@withReadyReactContext + } + + val tasks = HeadlessJsTaskContext.getInstance(reactContext) + val taskBundle = Bundle().apply { + putString("action", action) + putString("emailId", emailId) + putString("accountId", accountId) + if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId) + // Keep raw extras as well + putString(BulwarkFcmModule.EXTRA_ACTION, action) + putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + + val config = HeadlessJsTaskConfig( + "BulwarkNotificationAction", + Arguments.fromBundle(taskBundle), + 15_000L, + true // allowExecutionInForeground + ) + + val listener = object : HeadlessJsTaskEventListener { + override fun onHeadlessJsTaskStart(taskId: Int) = Unit + override fun onHeadlessJsTaskFinish(taskId: Int) { + Log.i(TAG, "Notification action task $taskId finished for action $action") + tasks.removeTaskEventListener(this) + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + } + } + tasks.addTaskEventListener(listener) + + HeadlessJs.startTask( + tasks, + config, + onStarted = { taskId -> + Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)") + }, + onRefused = { e -> + Log.w(TAG, "Notification action task refused: ${e.message}") + tasks.removeTaskEventListener(listener) + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + } + ) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to dispatch headless task from action receiver", e) + mainHandler.removeCallbacks(timeoutRunnable) + try { + pendingResult.finish() + } catch (_: Exception) {} + } + } + + companion object { + private const val TAG = "BulwarkNotifAction" + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPdfModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPdfModule.kt new file mode 100644 index 00000000..e92b0805 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPdfModule.kt @@ -0,0 +1,143 @@ +package com.anonymous.bulwarkmobile + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.ParcelFileDescriptor +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.Executors + +/** + * Renders PDF pages to PNG files for the in-app file preview, with the + * platform's own PdfRenderer: Android has no PDF view, and its WebView + * doesn't show PDFs either. The preview draws the pages in a WebView of + * its own so they zoom, asking for each page as it scrolls into view. + * + * PdfRenderer is not thread-safe and allows one open page at a time, so + * every call runs on one background thread, and the open document is kept + * between calls for the file the preview shows. + */ +class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } } + private var openPath: String? = null + private var descriptor: ParcelFileDescriptor? = null + private var renderer: PdfRenderer? = null + + override fun getName(): String = "BulwarkPdf" + + override fun invalidate() { + worker.execute { closeDocument() } + worker.shutdown() + super.invalidate() + } + + /** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */ + @ReactMethod + fun open(fileUri: String, promise: Promise) { + worker.execute { + try { + val doc = document(fileUri) + val pages = Arguments.createArray() + for (i in 0 until doc.pageCount) { + doc.openPage(i).use { page -> + pages.pushMap(Arguments.createMap().apply { + putInt("width", page.width) + putInt("height", page.height) + }) + } + } + promise.resolve(Arguments.createMap().apply { putArray("pages", pages) }) + } catch (e: SecurityException) { + // Password-protected; PdfRenderer can't open those. + closeDocument() + promise.reject("E_PDF_PROTECTED", e.message, e) + } catch (e: Throwable) { + closeDocument() + promise.reject("E_PDF_OPEN", e.message, e) + } + } + } + + /** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */ + @ReactMethod + fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) { + worker.execute { + try { + val doc = document(fileUri) + if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index") + doc.openPage(index).use { page -> + val w = width.coerceIn(1, MAX_PAGE_PX) + val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2) + val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + try { + // Pages are transparent where nothing is drawn. + bitmap.eraseColor(Color.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + val out = fileOf(outUri) + // The preview's folder is gone once it closed; a page + // still queued then must not bring it back. + if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed") + FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) } + } finally { + bitmap.recycle() + } + } + promise.resolve(outUri) + } catch (e: Throwable) { + promise.reject("E_PDF_RENDER", e.message, e) + } + } + } + + /** Close the document the preview showed. */ + @ReactMethod + fun close(fileUri: String, promise: Promise) { + worker.execute { + if (openPath == fileOf(fileUri).absolutePath) closeDocument() + promise.resolve(null) + } + } + + private fun document(fileUri: String): PdfRenderer { + val path = fileOf(fileUri).absolutePath + renderer?.let { if (openPath == path) return it } + closeDocument() + val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY) + try { + val doc = PdfRenderer(fd) + descriptor = fd + renderer = doc + openPath = path + return doc + } catch (e: Throwable) { + fd.close() + throw e + } + } + + private fun closeDocument() { + try { renderer?.close() } catch (_: Throwable) {} + try { descriptor?.close() } catch (_: Throwable) {} + renderer = null + descriptor = null + openPath = null + } + + private fun fileOf(uri: String): File { + val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri + return File(path ?: throw IllegalArgumentException("not a file: $uri")) + } + + companion object { + // A 4096 px page bitmap is 64 MB; keep well below. + private const val MAX_PAGE_PX = 2480 + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt index cde4fd5f..60a7cc14 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt @@ -11,16 +11,11 @@ import android.os.Bundle import android.os.PersistableBundle import android.os.SystemClock import android.util.Log -import com.facebook.react.ReactApplication -import com.facebook.react.ReactInstanceEventListener import com.facebook.react.bridge.ReactContext -import com.facebook.react.bridge.UiThreadUtil -import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags import com.facebook.react.jstasks.HeadlessJsTaskConfig import com.facebook.react.jstasks.HeadlessJsTaskContext import com.facebook.react.jstasks.HeadlessJsTaskEventListener import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean /** * Runs the BulwarkPushTask headless JS task from a JobScheduler job. @@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener { override fun onStartJob(params: JobParameters): Boolean { val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras)) - withReadyReactContext { startTask(it, config, params) } + // JobService callbacks run on the main thread, where HeadlessJs wants to be called. + HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) } return true } @@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener { tasks.addTaskEventListener(this) taskContext = tasks } - UiThreadUtil.runOnUiThread { - try { - val taskId = tasks.startTask(config) - running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) - } catch (e: IllegalStateException) { + HeadlessJs.startTask( + tasks, + config, + onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) }, + onRefused = { e -> // The app came to the foreground since the push arrived and // syncs on its own there. Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}") jobFinished(params, false) - } - } - } - - /** - * Calls [onReady] once with a React context whose JS instance is up, - * starting React Native if the process was woken for this job. - * - * A context exists before its instance is ready, and a task started on - * it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance - * not available" and waits for the timeout), which happened when several - * pushes arrived while the app was starting. So the listener goes in - * first and the ready check after it; whichever sees the ready instance - * first runs the task. - */ - private fun withReadyReactContext(onReady: (ReactContext) -> Unit) { - val done = AtomicBoolean(false) - val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) } - val app = application as ReactApplication - if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { - val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" } - val listener = object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - host.removeReactInstanceEventListener(this) - runOnce(context) - } - } - host.addReactInstanceEventListener(listener) - val current = host.currentReactContext - if (current != null && current.hasActiveReactInstance()) { - host.removeReactInstanceEventListener(listener) - runOnce(current) - } else { - host.start() - } - } else { - @Suppress("DEPRECATION") - val manager = app.reactNativeHost.reactInstanceManager - val listener = object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - manager.removeReactInstanceEventListener(this) - runOnce(context) - } - } - manager.addReactInstanceEventListener(listener) - val current = manager.currentReactContext - if (current != null && current.hasActiveReactInstance()) { - manager.removeReactInstanceEventListener(listener) - runOnce(current) - } else if (!manager.hasStartedCreatingInitialContext()) { - manager.createReactContextInBackground() - } - } + }, + ) } companion object { diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt index 81f9f196..5fac3d44 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkUnifiedPushService.kt @@ -1,6 +1,7 @@ package com.anonymous.bulwarkmobile import android.content.Context +import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter import com.facebook.react.bridge.Arguments import org.json.JSONArray import org.json.JSONObject @@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() { BulwarkMessagingService.ensureChannel(this) val data = parsePayload(message.content) ?: return - if (!BulwarkMessagingService.isAppInForeground()) { + // Same routing as FCM: contact and calendar changes request device + // syncs, and only mail starts the push task. + val mail = DeviceSyncPushRouter.route(applicationContext, data) + if (mail && !BulwarkMessagingService.isAppInForeground()) { BulwarkMessagingService.startHeadlessTask(applicationContext, data) } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt new file mode 100644 index 00000000..eeee2dba --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetRefreshService.kt @@ -0,0 +1,20 @@ +package com.anonymous.bulwarkmobile + +import android.content.Intent +import com.facebook.react.HeadlessJsTaskService +import com.facebook.react.bridge.Arguments +import com.facebook.react.jstasks.HeadlessJsTaskConfig + +/** + * Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless + * task when the app goes to the background. Run from the app itself the + * refresh stalled: React Native pauses JS timers while the activity is in + * the background, and fetch resolves every response through a timer, so the + * first request went out and its answer was never read. A running headless + * task keeps timers going, and the service keeps the process from being + * frozen before the widgets are redrawn. + */ +class BulwarkWidgetRefreshService : HeadlessJsTaskService() { + override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig = + HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true) +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt new file mode 100644 index 00000000..528d823e --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkWidgetsModule.kt @@ -0,0 +1,28 @@ +package com.anonymous.bulwarkmobile + +import android.content.Intent +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +/** JS side: src/widgets/sync.ts. */ +class BulwarkWidgetsModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "BulwarkWidgets" + + /** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */ + @ReactMethod + fun refreshInBackground(promise: Promise) { + try { + reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java)) + promise.resolve(true) + } catch (e: IllegalStateException) { + // Background start limits (the app left the foreground too long ago). + promise.resolve(false) + } catch (e: SecurityException) { + promise.resolve(false) + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt new file mode 100644 index 00000000..b9722584 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt @@ -0,0 +1,146 @@ +package com.anonymous.bulwarkmobile + +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import androidx.annotation.MainThread +import com.facebook.react.ReactApplication +import com.facebook.react.ReactInstanceEventListener +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.interfaces.TaskInterface +import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Running a headless JS task without HeadlessJsTaskService: starting React + * Native when the process was woken without any UI, and starting the task on + * its context. Shared by the push job (BulwarkPushJobService) and the device + * sync adapters. + */ +object HeadlessJs { + private const val TAG = "HeadlessJs" + + /** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */ + private const val MAX_WAIT_MS = 600_000L + private const val WATCH_MS = 1_000L + + /** + * Calls [onReady] once with a React context whose JS instance is up, + * starting React Native if the process was woken for this work. + * + * A context exists before its instance is ready, and a task started on + * it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance + * not available" and waits for the timeout), which happened when several + * pushes arrived while the app was starting. So the listener goes in + * first and the ready check after it; whichever sees the ready instance + * first runs the task. + * + * The wait ends once, and its listener goes with it: with a ready context, + * or without one when React fails to start (the start task faults) or + * after [MAX_WAIT_MS], when no caller can still be waiting. A context that + * comes up later runs nothing of it, so waits given up on don't pile up on + * the host with everything their [onReady] holds. (A caller that gives up + * sooner, like the sync adapter at its deadline, checks that in [onReady].) + * + * Call it on the main thread; [onReady] runs there too. MainApplication + * creates the ReactHost on first use without a lock (ExpoReactHostFactory), + * so a background caller racing MainActivity could create a second one, + * with a second JS runtime. + * + * Returns React Native's start task when it had to be started, so a caller + * can notice a start that fails (a debug build with no Metro to load the + * bundle from), or null when the context was already up. + */ + @MainThread + fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface? { + val reactApp = app as ReactApplication + if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { + val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" } + val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) } + host.addReactInstanceEventListener(wait) + val current = host.currentReactContext + if (current != null && current.hasActiveReactInstance()) { + wait.onReactContextInitialized(current) + return null + } + return host.start().also { wait.watch(it) } + } + @Suppress("DEPRECATION") + val manager = reactApp.reactNativeHost.reactInstanceManager + val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) } + manager.addReactInstanceEventListener(wait) + val current = manager.currentReactContext + if (current != null && current.hasActiveReactInstance()) { + wait.onReactContextInitialized(current) + return null + } + if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground() + wait.watch(null) + return null + } + + /** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */ + private class ContextWait( + private val onReady: (ReactContext) -> Unit, + private val removeListener: (ReactInstanceEventListener) -> Unit, + ) : ReactInstanceEventListener { + private val ended = AtomicBoolean(false) + + override fun onReactContextInitialized(context: ReactContext) { + if (end()) onReady(context) + } + + /** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */ + fun watch(boot: TaskInterface?) { + val main = Handler(Looper.getMainLooper()) + val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS + main.postDelayed(object : Runnable { + override fun run() { + if (ended.get()) return + val faulted = boot?.isFaulted() == true + if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) { + main.postDelayed(this, WATCH_MS) + } else if (end()) { + val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms" + Log.w(TAG, "$why; no longer waiting") + } + } + }, WATCH_MS) + } + + private fun end(): Boolean { + if (!ended.compareAndSet(false, true)) return false + removeListener(this) + return true + } + } + + /** + * Starts [config] on the UI thread, where HeadlessJsTaskContext wants it. + * [onStarted] gets the task id in that same UI-thread turn, so before any + * finish callback (HeadlessJsTaskContext posts those to the UI thread). + * [onRefused] gets the refusal when the context is resumed and [config] + * isn't allowed in the foreground. + */ + fun startTask( + tasks: HeadlessJsTaskContext, + config: HeadlessJsTaskConfig, + onStarted: (taskId: Int) -> Unit, + onRefused: (IllegalStateException) -> Unit, + ) { + UiThreadUtil.runOnUiThread { + val taskId = try { + tasks.startTask(config) + } catch (e: IllegalStateException) { + onRefused(e) + return@runOnUiThread + } + onStarted(taskId) + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt index baed3c73..1ebebf61 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt @@ -43,7 +43,8 @@ object NotificationTapStore { // The JMAP account a group/shared mailbox's message lives in - a JMAP // id like the email id. Optional: older notifications don't carry it. val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) } - val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId) + val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" } + val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action) pending = payload // Clear so a subsequent activity lifecycle event doesn't replay this. extras.remove(EXTRA_EMAIL_ID) @@ -51,6 +52,7 @@ object NotificationTapStore { extras.remove(EXTRA_SUBJECT) extras.remove(EXTRA_ACCOUNT_ID) extras.remove(EXTRA_JMAP_ACCOUNT_ID) + extras.remove(EXTRA_ACTION) return payload } @@ -60,6 +62,7 @@ object NotificationTapStore { val subject: String?, val accountId: String?, val jmapAccountId: String?, + val action: String? = null, ) { fun toMap(): WritableMap = Arguments.createMap().apply { putString("emailId", emailId) @@ -67,6 +70,7 @@ object NotificationTapStore { if (subject != null) putString("subject", subject) if (accountId != null) putString("accountId", accountId) if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId) + if (action != null) putString("action", action) } } @@ -75,4 +79,5 @@ object NotificationTapStore { const val EXTRA_SUBJECT = "bulwark.notification.subject" const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId" const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId" + const val EXTRA_ACTION = "bulwark.notification.action" } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkAuthenticator.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkAuthenticator.kt new file mode 100644 index 00000000..e26aa44c --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkAuthenticator.kt @@ -0,0 +1,79 @@ +package com.anonymous.bulwarkmobile.sync + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.IBinder +import com.anonymous.bulwarkmobile.MainActivity + +/** + * The authenticator of the "Bulwark" account type. The app holds the + * credentials, not the account: accounts are only ever created by the app, + * from Settings → Contacts or Calendar, for an account that is signed in. + */ +class BulwarkAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) { + + /** Settings → Accounts → Add account opens the app's device sync settings instead. */ + override fun addAccount( + response: AccountAuthenticatorResponse?, + accountType: String?, + authTokenType: String?, + requiredFeatures: Array?, + options: Bundle?, + ): Bundle { + val intent = Intent(context, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse(SETTINGS_URI)) + return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) } + } + + /** Removal from Android Settings is allowed; the app notices and turns device sync off. */ + override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse?, account: Account?): Bundle = + Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) } + + override fun getAuthToken( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle?, + ): Bundle = unsupported() + + override fun confirmCredentials(response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?): Bundle = + unsupported() + + override fun updateCredentials( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle?, + ): Bundle = unsupported() + + override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle = unsupported() + + override fun getAuthTokenLabel(authTokenType: String?): String? = null + + override fun hasFeatures(response: AccountAuthenticatorResponse?, account: Account?, features: Array?): Bundle = + Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) } + + private fun unsupported() = Bundle().apply { + putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION) + putString(AccountManager.KEY_ERROR_MESSAGE, "Bulwark signs in from the app") + } + + companion object { + /** Handled by the app's deep links: Settings → Contacts, with its "Sync to this device" section. */ + const val SETTINGS_URI = "bulwarkmobile://settings/contacts" + } +} + +class BulwarkAuthenticatorService : Service() { + private val authenticator by lazy { BulwarkAuthenticator(this) } + + override fun onBind(intent: Intent): IBinder? = authenticator.iBinder +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt new file mode 100644 index 00000000..230141db --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/BulwarkDeviceSyncModule.kt @@ -0,0 +1,331 @@ +package com.anonymous.bulwarkmobile.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.content.ActivityNotFoundException +import android.content.ContentResolver +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter +import org.json.JSONObject +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +/** + * `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings, the run + * handshake, provider I/O, push routes and the sync-problem notification of + * device sync (#34). The contract is `DeviceSyncNativeModule` in + * src/device-sync/types.ts; docs/device-sync.md explains it. + */ +class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + private val accountsListener = OnAccountsUpdateListener { + emit(ACCOUNTS_CHANGED_EVENT, null) + } + + /** + * Provider I/O runs here, one call at a time: a batch can hold the + * provider for seconds (it sleeps at a contended yield point), which would + * stall every other native module call on React's native-modules thread. + */ + private val io: ExecutorService = Executors.newSingleThreadExecutor { task -> + Thread(task, "BulwarkDeviceSync-io").apply { isDaemon = true } + } + + init { + synchronized(BulwarkDeviceSyncModule::class.java) { currentInstance = this } + } + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + val manager = AccountManager.get(reactApplicationContext) + val handler = Handler(Looper.getMainLooper()) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.addOnAccountsUpdatedListener(accountsListener, handler, false, arrayOf(accountType())) + } else { + manager.addOnAccountsUpdatedListener(accountsListener, handler, false) + } + } catch (e: RuntimeException) { + Log.w(TAG, "Account change listener not registered", e) + } + } + + override fun invalidate() { + try { + AccountManager.get(reactApplicationContext).removeOnAccountsUpdatedListener(accountsListener) + } catch (e: RuntimeException) { + // Not registered. + } + io.shutdown() + super.invalidate() + } + + private fun accountType() = DeviceSyncAccounts.accountType(reactApplicationContext) + + // ── accounts ──────────────────────────────────────────────── + + @ReactMethod + fun getInfo(promise: Promise) { + promise.resolve(Arguments.createMap().apply { + putString("accountType", accountType()) + putInt("sdkInt", Build.VERSION.SDK_INT) + }) + } + + @ReactMethod + fun listAccounts(promise: Promise) = settle(promise) { + Arguments.createArray().apply { + for ((account, registryId) in DeviceSyncAccounts.list(reactApplicationContext)) { + pushMap(Arguments.createMap().apply { + putString("name", account.name) + putString("registryId", registryId) + }) + } + } + } + + @ReactMethod + fun ensureAccount(name: String, registryId: String, promise: Promise) = settle(promise) { + DeviceSyncAccounts.ensure(reactApplicationContext, name, registryId) + } + + @ReactMethod + fun removeAccount(name: String, promise: Promise) = settle(promise) { + DeviceSyncAccounts.remove(reactApplicationContext, name) + } + + // ── sync settings ─────────────────────────────────────────── + + @ReactMethod + fun getSyncSettings(name: String, promise: Promise) = settle(promise) { + val account = DeviceSyncAccounts.account(reactApplicationContext, name) + Arguments.createMap().apply { + putBoolean("masterAutomatic", ContentResolver.getMasterSyncAutomatically()) + putMap("authorities", Arguments.createMap().apply { + for (authority in DeviceSyncAccounts.AUTHORITIES) putMap(authority, authoritySettings(account, authority)) + }) + } + } + + private fun authoritySettings(account: Account, authority: String): WritableMap = Arguments.createMap().apply { + putInt("syncable", ContentResolver.getIsSyncable(account, authority)) + putBoolean("automatic", ContentResolver.getSyncAutomatically(account, authority)) + val periodic = ContentResolver.getPeriodicSyncs(account, authority).minOfOrNull { it.period } ?: 0L + putDouble("periodicSeconds", periodic.toDouble()) + putBoolean("active", ContentResolver.isSyncActive(account, authority)) + putBoolean("pending", ContentResolver.isSyncPending(account, authority)) + } + + @ReactMethod + fun setSyncEnabled(name: String, authority: String, enabled: Boolean, promise: Promise) = settle(promise) { + DeviceSyncAccounts.setSyncEnabled(reactApplicationContext, name, authority, enabled) + null + } + + @ReactMethod + fun setPeriodicSync(name: String, authority: String, seconds: Double, promise: Promise) = settle(promise) { + DeviceSyncAccounts.setPeriodicSync(reactApplicationContext, name, authority, seconds.toLong()) + null + } + + @ReactMethod + fun requestSync(name: String, authority: String, optionsJson: String, promise: Promise) = settle(promise) { + val options = JSONObject(optionsJson) + val flags = SyncRequestFlags( + manual = options.optBoolean("manual"), + expedited = options.optBoolean("expedited"), + upload = options.optBoolean("upload"), + overrideTooManyDeletions = options.optBoolean("overrideTooManyDeletions"), + discardLocalDeletions = options.optBoolean("discardLocalDeletions"), + ) + DeviceSyncAccounts.requestSync(reactApplicationContext, name, authority, flags) + null + } + + /** The account's own sync screen where the platform has one, else the list of synced accounts. */ + @ReactMethod + fun openAccountSettings(name: String, promise: Promise) = settle(promise) { + val account = DeviceSyncAccounts.account(reactApplicationContext, name) + val perAccount = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) + .putExtra(EXTRA_ACCOUNT, account) + // Newer Settings apps pass only these arguments on to the account's + // screen; without them it closes at once ("Account provided does + // not exist: null", Android 12L), and no exception tells us so. + .putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putParcelable(EXTRA_ACCOUNT, account) }) + val overview = Intent(Settings.ACTION_SYNC_SETTINGS) + .putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(account.type)) + val activity = reactApplicationContext.currentActivity + for (intent in listOf(perAccount, overview)) { + try { + if (activity != null) { + activity.startActivity(intent) + } else { + reactApplicationContext.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + return@settle null + } catch (e: ActivityNotFoundException) { + // Try the next one. + } + } + throw IllegalStateException("No settings screen for sync accounts") + } + + // ── run handshake ─────────────────────────────────────────── + + @ReactMethod + fun finishRun(runId: String, reportJson: String, promise: Promise) { + val accepted = DeviceSyncRuns.finish(runId, reportJson) + if (!accepted) Log.i(TAG, "Report for run $runId ignored: nothing waits for it") + promise.resolve(accepted) + } + + @ReactMethod + fun isRunCancelled(runId: String, promise: Promise) { + promise.resolve(DeviceSyncRuns.isCancelled(runId)) + } + + // ── provider I/O ──────────────────────────────────────────── + + /** `ProviderQuery` JSON in, `ProviderRows` JSON out; rejects with `scope`, `permission` or `provider`. */ + @ReactMethod + fun query(name: String, authority: String, queryJson: String, promise: Promise) = onIo(promise) { + ProviderIo(reactApplicationContext, name, authority).use { it.query(queryJson) } + } + + /** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never rejects: a failed batch is a result. */ + @ReactMethod + fun applyBatch(name: String, authority: String, opsJson: String, promise: Promise) { + val failed = { e: Throwable -> ProviderResults.failed(ProviderIo.failureOf(e), e.message ?: e.javaClass.simpleName) } + try { + io.execute { + val result = try { + ProviderIo(reactApplicationContext, name, authority).use { it.applyBatch(opsJson) } + } catch (e: Throwable) { + failed(e) + } + promise.resolve(result) + } + } catch (e: RejectedExecutionException) { + promise.resolve(failed(e)) + } + } + + @ReactMethod + fun readSyncState(name: String, authority: String, promise: Promise) = onIo(promise) { + ProviderIo(reactApplicationContext, name, authority).use { it.readSyncState() } + } + + /** `PhotoData` JSON, or null when the raw contact has no photo; rejects with `scope` for another account's. */ + @ReactMethod + fun readPhoto(name: String, rawContactId: Double, maxPx: Double, promise: Promise) = onIo(promise) { + ProviderIo(reactApplicationContext, name, DeviceSyncAccounts.CONTACTS_AUTHORITY) + .use { it.readPhoto(rawContactId.toLong(), maxPx.toInt()) } + } + + /** Settles [promise] with [block]'s result on the I/O thread; rejects with a `BatchFailure` code. */ + private fun onIo(promise: Promise, block: () -> Any?) { + try { + io.execute { + try { + promise.resolve(block()) + } catch (e: Throwable) { + promise.reject(ProviderIo.failureOf(e).wire, e.message ?: e.javaClass.simpleName, e) + } + } + } catch (e: RejectedExecutionException) { + promise.reject("provider", "Device sync is shutting down", e) + } + } + + // ── push routes and notifications ─────────────────────────── + + /** `{ jmapAccountId: [{ accountName, authorities }] }` for the push router (DeviceSyncPushRouter). */ + @ReactMethod + fun setPushRoutes(routesJson: String, promise: Promise) = settle(promise) { + DeviceSyncPushRouter.saveRoutes(reactApplicationContext, routesJson) + null + } + + /** + * Posts (or replaces) the account's sync-problem notification. [channelName] + * names the notification channel in the app's language; null keeps English. + */ + @ReactMethod + fun showSyncProblem( + accountName: String, + title: String, + text: String, + uri: String, + channelName: String?, + promise: Promise, + ) = settle(promise) { + SyncProblemNotifier.show(reactApplicationContext, accountName, title, text, uri, channelName) + null + } + + @ReactMethod + fun clearSyncProblem(accountName: String, promise: Promise) = settle(promise) { + SyncProblemNotifier.clear(reactApplicationContext, accountName) + null + } + + // NativeEventEmitter bookkeeping. + @ReactMethod + fun addListener(eventName: String) {} + + @ReactMethod + fun removeListeners(count: Int) {} + + private inline fun settle(promise: Promise, block: () -> Any?) { + try { + promise.resolve(block()) + } catch (e: AccountConflictException) { + promise.reject("conflict", e.message, e) + } catch (e: SecurityException) { + promise.reject("permission", e.message, e) + } catch (e: IllegalArgumentException) { + promise.reject("bad_args", e.message, e) + } catch (e: Exception) { + promise.reject("failed", e.message, e) + } + } + + companion object { + const val NAME = "BulwarkDeviceSync" + private const val TAG = "BulwarkDeviceSync" + const val ACCOUNTS_CHANGED_EVENT = "BulwarkDeviceSync:accountsChanged" + + /** Settings' per-account sync screen; public on AOSP-based settings apps, but not in the SDK. */ + private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" + private const val EXTRA_ACCOUNT = "account" + /** SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS: the arguments of the screen it shows. */ + private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + + @Volatile private var currentInstance: BulwarkDeviceSyncModule? = null + + /** Dropped when no React instance or no JS listener is there, like BulwarkFcmModule.emit. */ + fun emit(eventName: String, params: WritableMap?) { + val module = currentInstance ?: return + val ctx = module.reactApplicationContext + if (!ctx.hasActiveReactInstance()) return + ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params) + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt new file mode 100644 index 00000000..29e2fa7c --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccounts.kt @@ -0,0 +1,144 @@ +package com.anonymous.bulwarkmobile.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.content.ContentResolver +import android.content.Context +import android.os.Bundle +import android.provider.CalendarContract +import android.provider.ContactsContract +import com.anonymous.bulwarkmobile.R + +/** + * The Android accounts of device sync (#34) and their sync settings. One + * account per app account that has device sync on; see docs/device-sync.md. + * + * Android's own sync settings are the source of truth for on/off: the + * Contacts and Calendar toggles in Settings → Accounts are + * `getSyncAutomatically`, and the app reads them back instead of keeping a + * copy. + */ +object DeviceSyncAccounts { + const val CONTACTS_AUTHORITY: String = ContactsContract.AUTHORITY + const val CALENDAR_AUTHORITY: String = CalendarContract.AUTHORITY + val AUTHORITIES = listOf(CONTACTS_AUTHORITY, CALENDAR_AUTHORITY) + + /** userData key holding the app account (`AccountEntry.id`) an Android account belongs to. */ + const val USER_DATA_REGISTRY_ID = "registryId" + + /** `.account`, set in build.gradle so the package name is never hard-coded. */ + fun accountType(context: Context): String = context.getString(R.string.device_sync_account_type) + + fun account(context: Context, name: String) = Account(name, accountType(context)) + + fun list(context: Context): List> { + val manager = AccountManager.get(context) + return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) } + } + + fun exists(context: Context, name: String): Boolean = + AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name } + + /** + * Adds the account when it is missing. Both authorities start syncable + * with automatic sync off; the app turns on the ones the user picked. + * Returns true when the account was created, false when it exists for + * [registryId] already. An account that belongs to another registry id + * keeps it ([AccountConflictException]): its rows and SyncState were + * written for that app account. + */ + fun ensure(context: Context, name: String, registryId: String): Boolean { + val manager = AccountManager.get(context) + val account = account(context, name) + val exists = manager.getAccountsByType(account.type).any { it.name == name } + val current = if (exists) manager.getUserData(account, USER_DATA_REGISTRY_ID) else null + when (ensureAction(exists, current, registryId)) { + EnsureAction.KEEP -> return false + EnsureAction.ADOPT -> { + manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId) + return false + } + EnsureAction.CONFLICT -> throw AccountConflictException( + "The Android account $name belongs to another app account ($current)", + ) + EnsureAction.CREATE -> Unit + } + val userData = Bundle().apply { putString(USER_DATA_REGISTRY_ID, registryId) } + check(manager.addAccountExplicitly(account, null, userData)) { "Android refused to add the account" } + for (authority in AUTHORITIES) { + ContentResolver.setIsSyncable(account, authority, 1) + ContentResolver.setSyncAutomatically(account, authority, false) + } + return true + } + + enum class EnsureAction { CREATE, KEEP, ADOPT, CONFLICT } + + /** An existing account without a registry id (never completed) is adopted; one with another id is not touched. */ + fun ensureAction(exists: Boolean, currentRegistryId: String?, registryId: String): EnsureAction = when { + !exists -> EnsureAction.CREATE + currentRegistryId == registryId -> EnsureAction.KEEP + currentRegistryId.isNullOrEmpty() -> EnsureAction.ADOPT + else -> EnsureAction.CONFLICT + } + + /** Removes the account; the Contacts and Calendar providers drop all of its rows. */ + fun remove(context: Context, name: String): Boolean { + val manager = AccountManager.get(context) + val account = account(context, name) + if (manager.getAccountsByType(account.type).none { it.name == name }) return false + return manager.removeAccountExplicitly(account) + } + + fun setSyncEnabled(context: Context, name: String, authority: String, enabled: Boolean) { + requireAuthority(authority) + val account = account(context, name) + ContentResolver.setIsSyncable(account, authority, 1) + ContentResolver.setSyncAutomatically(account, authority, enabled) + } + + /** Replaces our periodic sync for the authority; 0 removes it. */ + fun setPeriodicSync(context: Context, name: String, authority: String, seconds: Long) { + requireAuthority(authority) + val account = account(context, name) + for (periodic in ContentResolver.getPeriodicSyncs(account, authority)) { + ContentResolver.removePeriodicSync(account, authority, periodic.extras) + } + if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds) + } + + /** + * Requests a sync. SyncManager drops a request that matches a sync it is + * running, so a run of that account and authority is also flagged to + * ask for another sync when it ends (see [DeviceSyncRuns.flagSyncAgain]). + */ + fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) { + requireAuthority(authority) + DeviceSyncRuns.flagSyncAgain(name, authority) + ContentResolver.requestSync(account(context, name), authority, flags.toExtras()) + } + + fun requireAuthority(authority: String) { + require(authority in AUTHORITIES) { "Unknown authority $authority" } + } +} + +/** [DeviceSyncAccounts.ensure] refused to hand an account of one app account to another. */ +class AccountConflictException(message: String) : Exception(message) + +/** The SYNC_EXTRAS_* flags a request carries. Constant extras let repeated requests coalesce. */ +data class SyncRequestFlags( + val manual: Boolean = false, + val expedited: Boolean = false, + val upload: Boolean = false, + val overrideTooManyDeletions: Boolean = false, + val discardLocalDeletions: Boolean = false, +) { + fun toExtras(): Bundle = Bundle().apply { + if (manual) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) + if (expedited) putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) + if (upload) putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true) + if (overrideTooManyDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, true) + if (discardLocalDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, true) + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt new file mode 100644 index 00000000..8df852ae --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAdapter.kt @@ -0,0 +1,213 @@ +package com.anonymous.bulwarkmobile.sync + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.Application +import android.app.Service +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import androidx.annotation.MainThread +import com.anonymous.bulwarkmobile.HeadlessJs +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import com.facebook.react.jstasks.HeadlessJsTaskEventListener +import java.util.concurrent.TimeUnit + +/** + * The sync adapter behind both authorities (#34). It does no syncing itself: + * it starts the BulwarkDeviceSync headless JS task, where the engine lives + * (src/device-sync), waits for its report and hands that to SyncManager. See + * docs/device-sync.md. + * + * onPerformSync runs on its own SyncAdapterThread and may block; React Native + * is started from the main thread (see HeadlessJs), which stays free. + * SyncManager holds a wake lock for as long as onPerformSync runs. + */ +class DeviceSyncAdapter(context: Context) : + AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true, /* allowParallelSyncs = */ false) { + + private val main = Handler(Looper.getMainLooper()) + + /** + * Anything thrown out of here is rethrown on the sync thread + * (AbstractThreadedSyncAdapter's SyncThread) and kills the process, the UI + * included. So every failure becomes a result: a soft error SyncManager + * retries with back-off, or a database error for a missing permission, + * as the framework would report it. + */ + override fun onPerformSync( + account: Account, + extras: Bundle, + authority: String, + provider: ContentProviderClient, + syncResult: SyncResult, + ) { + try { + performSync(account, extras, authority, syncResult) + } catch (e: SecurityException) { + Log.w(TAG, "Sync of $authority refused by a permission check", e) + syncResult.databaseError = true + } catch (t: Throwable) { + Log.e(TAG, "Sync of $authority failed", t) + syncResult.stats.numIoExceptions++ + } + } + + private fun performSync(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) { + val registryId = AccountManager.get(context).getUserData(account, DeviceSyncAccounts.USER_DATA_REGISTRY_ID) + if (registryId.isNullOrEmpty()) { + Log.w(TAG, "Sync of $authority skipped: the account has no app account") + syncResult.databaseError = true + return + } + val startedAt = SystemClock.elapsedRealtime() + // A sync scheduled as an expedited job (Android 12+) gets 3 minutes + // from JobScheduler instead of 10; stay inside either. + val expedited = extras.getBoolean(SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB, false) + val deadline = startedAt + if (expedited) EXPEDITED_BUDGET_MS else BUDGET_MS + val run = DeviceSyncRuns.open(account.name, authority) + main.post { startRun(run, account, registryId, authority, extras, deadline) } + try { + while (!run.done.await(POLL_MS, TimeUnit.MILLISECONDS)) { + val boot = run.boot + if (boot != null && boot.isFaulted()) { + run.end("React Native did not start: ${boot.getError()?.message}") + } else if (SystemClock.elapsedRealtime() >= deadline) { + run.end("no report before the deadline") + } + } + val outcome = SyncReports.outcome(run.report, run.endReason) + SyncReports.apply(outcome, syncResult) + Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " + + "${SystemClock.elapsedRealtime() - startedAt} ms: ${SyncReports.summary(run.report)}; $outcome") + } catch (e: InterruptedException) { + // onSyncCanceled interrupts this thread. JS sees the run as + // cancelled at its next checkpoint (isRunCancelled). + run.end("cancelled") + Log.i(TAG, "Sync ${run.runId} of $authority cancelled") + Thread.currentThread().interrupt() + } finally { + DeviceSyncRuns.close(run) + // After startRun, which was posted first, so a listener it added is removed. + main.post { run.detach() } + } + // A change pushed while the run was open: SyncManager dropped its + // request as a duplicate of this sync, so ask for the next one here. + if (run.syncAgainRequested) { + Log.i(TAG, "Sync ${run.runId} of $authority: changes arrived meanwhile, syncing again") + syncResult.fullSyncRequested = true + } + } + + @MainThread + private fun startRun( + run: DeviceSyncRuns.Run, + account: Account, + registryId: String, + authority: String, + extras: Bundle, + deadline: Long, + ) { + if (run.cancelled) return + try { + run.boot = HeadlessJs.withReadyReactContext(context.applicationContext as Application) { reactContext -> + if (run.cancelled) return@withReadyReactContext + if (!reactContext.hasActiveReactInstance()) { + run.end("the React Native instance is gone") + return@withReadyReactContext + } + val tasks = HeadlessJsTaskContext.getInstance(reactContext) + run.attach(tasks, object : HeadlessJsTaskEventListener { + override fun onHeadlessJsTaskStart(taskId: Int) = Unit + + // JS settled without a report, timed out, or the context was reloaded. + override fun onHeadlessJsTaskFinish(taskId: Int) { + if (taskId == run.taskId) run.end("task finished") + } + }) + val taskTimeout = (deadline - SystemClock.elapsedRealtime() - LATCH_SLACK_MS).coerceAtLeast(MIN_TASK_MS) + val data = Arguments.createMap().apply { + putString("runId", run.runId) + putString("accountName", account.name) + putString("registryId", registryId) + putString("authority", authority) + putMap("extras", runExtras(extras)) + putDouble("deadline", (System.currentTimeMillis() + taskTimeout - JS_SLACK_MS).toDouble()) + } + HeadlessJs.startTask( + tasks, + HeadlessJsTaskConfig(TASK_KEY, data, taskTimeout, /* isAllowedInForeground = */ true), + onStarted = { run.taskId = it }, + onRefused = { run.end("refused: ${it.message}") }, + ) + } + } catch (e: RuntimeException) { + run.end("could not start: ${e.message}") + } + } + + private fun runExtras(extras: Bundle): WritableMap = Arguments.createMap().apply { + fun flag(key: String, name: String) { + if (extras.getBoolean(key, false)) putBoolean(name, true) + } + flag(ContentResolver.SYNC_EXTRAS_MANUAL, "manual") + flag(ContentResolver.SYNC_EXTRAS_UPLOAD, "upload") + flag(ContentResolver.SYNC_EXTRAS_EXPEDITED, "expedited") + flag(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, "ignoreBackoff") + flag(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, "overrideTooManyDeletions") + flag(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, "discardLocalDeletions") + } + + companion object { + private const val TAG = "BulwarkDeviceSync" + const val TASK_KEY = "BulwarkDeviceSync" + + /** ContentResolver.SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB (API 31). */ + private const val SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB = "schedule_as_expedited_job" + + private const val BUDGET_MS = 570_000L // 9.5 min of JobScheduler's 10 + private const val EXPEDITED_BUDGET_MS = 150_000L // 2.5 min of 3 + private const val LATCH_SLACK_MS = 20_000L // the task times out this long before our deadline + private const val JS_SLACK_MS = 30_000L // JS aims to be done this long before the task timeout + private const val MIN_TASK_MS = 30_000L + private const val POLL_MS = 500L + } +} + +/** Binds the contacts authority's adapter (xml/sync_contacts.xml). */ +class ContactsSyncService : Service() { + override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder + + companion object { + @Volatile private var adapter: DeviceSyncAdapter? = null + + private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) { + adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it } + } + } +} + +/** Binds the calendar authority's adapter (xml/sync_calendar.xml). */ +class CalendarSyncService : Service() { + override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder + + companion object { + @Volatile private var adapter: DeviceSyncAdapter? = null + + private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) { + adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it } + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt new file mode 100644 index 00000000..9e46adeb --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRuns.kt @@ -0,0 +1,125 @@ +package com.anonymous.bulwarkmobile.sync + +import com.facebook.react.interfaces.TaskInterface +import com.facebook.react.jstasks.HeadlessJsTaskContext +import com.facebook.react.jstasks.HeadlessJsTaskEventListener +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +/** + * The device sync runs an adapter is waiting for, keyed by a random run id. + * + * A run ends exactly once, on the first of: JS calling finishRun, the + * headless task ending (JS settled, timed out or was reloaded away), React + * refusing or failing to start, the adapter's deadline, or a cancel. A report + * that arrives after that is refused whole rather than half-applied. Headless + * task ids restart at 1 with every React context, so they never identify a + * run; the run id does. + */ +object DeviceSyncRuns { + const val NO_TASK = -1 + + enum class State { WAITING, ENDED } + + class Run(val runId: String, val accountName: String, val authority: String) { + private val state = AtomicReference(State.WAITING) + private val syncAgain = AtomicBoolean(false) + val done = CountDownLatch(1) + + /** Written before [done] opens, read after it: the latch orders them. */ + @Volatile var report: String? = null + private set + @Volatile var endReason: String? = null + private set + @Volatile var cancelled = false + @Volatile var boot: TaskInterface? = null + /** Written and compared on the UI thread only. */ + @Volatile var taskId = NO_TASK + @Volatile private var tasks: HeadlessJsTaskContext? = null + @Volatile private var listener: HeadlessJsTaskEventListener? = null + + val isWaiting: Boolean get() = state.get() == State.WAITING + + /** True once a sync was requested for this account and authority while the run was open. */ + val syncAgainRequested: Boolean get() = syncAgain.get() + + /** Releases the waiting adapter. Only the first end counts; returns whether this was it. */ + fun end(reason: String): Boolean = end(reason, null) + + internal fun end(reason: String, reportJson: String?): Boolean { + if (!state.compareAndSet(State.WAITING, State.ENDED)) return false + report = reportJson + endReason = reason + done.countDown() + return true + } + + internal fun requestSyncAgain() { + syncAgain.set(true) + } + + fun attach(tasks: HeadlessJsTaskContext, listener: HeadlessJsTaskEventListener) { + this.tasks = tasks + this.listener = listener + tasks.addTaskEventListener(listener) + } + + fun detach() { + val tasks = tasks ?: return + listener?.let { tasks.removeTaskEventListener(it) } + this.tasks = null + this.listener = null + } + } + + private val runs = ConcurrentHashMap() + + fun open(accountName: String, authority: String): Run = + Run(UUID.randomUUID().toString(), accountName, authority).also { runs[it.runId] = it } + + /** + * Forgets the run: a late finishRun is then refused and isCancelled + * answers true. Read [Run.syncAgainRequested] after this, so a request + * can no longer slip in between reading the flag and closing. + */ + fun close(run: Run) { + run.end("closed") + run.cancelled = true + runs.remove(run.runId) + } + + /** From JS. Accepted only while the adapter still waits for this run: false when it ended, timed out or is unknown. */ + fun finish(runId: String, reportJson: String): Boolean = + runs[runId]?.end("reported", reportJson) ?: false + + /** Runs nobody waits for, or that already ended, count as cancelled: JS should stop. */ + fun isCancelled(runId: String): Boolean { + val run = runs[runId] ?: return true + return run.cancelled || !run.isWaiting + } + + fun cancel(runId: String) { + runs[runId]?.cancelled = true + } + + /** + * Flags the open runs of [accountName] and [authority] to request another + * sync when they end (`SyncResult.fullSyncRequested`): SyncManager drops a + * `requestSync` that matches a sync it is running, so a change pushed + * mid-run would otherwise wait for the next trigger. Returns whether a + * run was flagged. + */ + fun flagSyncAgain(accountName: String, authority: String): Boolean { + var flagged = false + for (run in runs.values) { + if (run.accountName == accountName && run.authority == authority) { + run.requestSyncAgain() + flagged = true + } + } + return flagged + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt new file mode 100644 index 00000000..37f23290 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderIo.kt @@ -0,0 +1,376 @@ +package com.anonymous.bulwarkmobile.sync + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentProviderOperation +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.OperationApplicationException +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.TransactionTooLargeException +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.provider.SyncStateContract +import android.util.Base64 +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException + +/** + * Runs device sync's provider calls for one Android account of our type and + * one authority: `query`, `applyBatch`, `readSyncState` and `readPhoto` of + * `NativeModules.BulwarkDeviceSync`. [ScopePlanner] decides what may run; this + * class only builds the calls and reads the answers. + * + * Every URI carries `caller_is_syncadapter=true` and the account + * (CalendarProvider accepts no other query parameter), so writes neither set + * DIRTY nor schedule upload syncs, and ContactsProvider filters data rows by + * the account. The scope lookups are the exception: they read ids and owners + * of rows of any account. + * + * The client is unstable: a provider process that dies fails the call instead + * of taking this process (and the UI) with it. Call from a background thread; + * close when done. + */ +class ProviderIo(context: Context, accountName: String, private val authority: String) : ScopeLookup, AutoCloseable { + private val appContext: Context = context.applicationContext + private val account: AccountRef + private val client: ContentProviderClient + + init { + if (authority !in DeviceSyncAccounts.AUTHORITIES) { + throw ProviderRefusal(BatchFailure.SCOPE, "$authority is not a device sync authority") + } + account = AccountRef(accountName, DeviceSyncAccounts.accountType(appContext)) + client = appContext.contentResolver.acquireUnstableContentProviderClient(authority) + ?: throw IllegalStateException("No content provider for $authority") + } + + override fun close() { + client.close() + } + + // ── the bridge calls ──────────────────────────────────────── + + /** `ProviderQuery` JSON in, `ProviderRows` JSON out. Throws [ProviderRefusal] or what the provider threw. */ + fun query(queryJson: String): String { + val plan = ScopePlanner(account, this).planQuery(ProviderOpParser.parseQuery(authority, queryJson)) + val writer = ProviderRowsWriter(plan.columns) + val projection = plan.columns.toTypedArray() + for (selection in plan.selections) { + query(uri(plan.table), projection, selection, plan.orderBy).use { cursor -> + while (cursor.moveToNext()) { + writer.beginRow() + for (i in projection.indices) { + when (cursor.getType(i)) { + Cursor.FIELD_TYPE_INTEGER -> writer.longCell(cursor.getLong(i)) + Cursor.FIELD_TYPE_FLOAT -> writer.doubleCell(cursor.getDouble(i)) + Cursor.FIELD_TYPE_STRING -> writer.textCell(cursor.getString(i)) + // Blobs (photo thumbnails) never cross the bridge; readPhoto reads them. + else -> writer.nullCell() + } + } + writer.endRow() + } + } + } + return writer.finish() + } + + /** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never throws: failures are results. */ + fun applyBatch(opsJson: String): String = try { + val ops = ProviderOpParser.parseBatch(authority, opsJson) + // Rows written for an account that is gone would outlive it: the + // providers purge an account's rows only when accounts change. + if (!DeviceSyncAccounts.exists(appContext, account.name)) { + throw ProviderRefusal(BatchFailure.SCOPE, "The account ${account.name} no longer exists") + } + if (ops.isEmpty()) { + ProviderResults.ok(emptyList()) + } else { + val planned = ScopePlanner(account, this).planBatch(ops) + val results = client.applyBatch(ArrayList(planned.map(::build))) + ProviderResults.ok(planned.zip(results) { op, result -> outcome(op, result.uri, result.count) }) + } + } catch (e: Throwable) { + ProviderResults.failed(failureOf(e), e.message ?: e.javaClass.simpleName) + } + + /** The account's SyncState blob as text, or null when there is none. */ + fun readSyncState(): String? = + SyncStateContract.Helpers.get(client, syncStateUri(), Account(account.name, account.type)) + ?.toString(Charsets.UTF_8) + + /** + * The raw contact's display photo, or its thumbnail when it has none, + * scaled so the longer side is at most [maxPx], as `PhotoData` JSON; null + * when the raw contact has no photo. + */ + fun readPhoto(rawContactId: Long, maxPx: Int): String? { + require(authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) { "Photos are contacts data" } + require(maxPx > 0) { "maxPx must be positive" } + if (rootAccounts(ProviderTable.RAW_CONTACTS, setOf(rawContactId))[rawContactId] != account) { + throw ProviderRefusal(BatchFailure.SCOPE, "Raw contact $rawContactId is not part of the account") + } + var fileId: Long? = null + var thumbnail: ByteArray? = null + val photoRow = Selection( + "raw_contact_id = ? AND mimetype = ?", + listOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE), + ) + query( + uri(ProviderTable.DATA), + arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, ContactsContract.CommonDataKinds.Photo.PHOTO), + photoRow, + "${ContactsContract.Data.IS_PRIMARY} DESC", + ).use { cursor -> + if (cursor.moveToFirst()) { + if (!cursor.isNull(0)) fileId = cursor.getLong(0) + if (cursor.getType(1) == Cursor.FIELD_TYPE_BLOB) thumbnail = cursor.getBlob(1) + } + } + val bytes = readDisplayPhoto(rawContactId) ?: thumbnail ?: return null + val jpeg = scaledJpeg(bytes, maxPx) ?: return null + return JSONObject() + .put("jpegBase64", Base64.encodeToString(jpeg, Base64.NO_WRAP)) + .put("fileId", fileId ?: JSONObject.NULL) + .toString() + } + + private fun readDisplayPhoto(rawContactId: Long): ByteArray? { + val uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId).buildUpon() + .appendPath(ContactsContract.RawContacts.DisplayPhoto.CONTENT_DIRECTORY) + .scoped() + .build() + return try { + client.openAssetFile(uri, "r")?.use { fd -> fd.createInputStream().use { it.readBytes() } } + } catch (e: FileNotFoundException) { + // No display photo stored (small photos only have the thumbnail). + null + } + } + + private fun scaledJpeg(bytes: ByteArray, maxPx: Int): ByteArray? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = BitmapFactory.Options().apply { + inSampleSize = PhotoScaling.sampleSize(bounds.outWidth, bounds.outHeight, maxPx) + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null + val (width, height) = PhotoScaling.targetSize(decoded.width, decoded.height, maxPx) + val scaled = if (width == decoded.width && height == decoded.height) { + decoded + } else { + Bitmap.createScaledBitmap(decoded, width, height, true) + } + return ByteArrayOutputStream().use { out -> + scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) + out.toByteArray() + } + } + + // ── ScopeLookup ───────────────────────────────────────────── + + override fun ownCalendarIds(): Set { + val ids = HashSet() + val selection = Selection( + "${ScopePlanner.ACCOUNT_NAME} = ? AND ${ScopePlanner.ACCOUNT_TYPE} = ?", + listOf(account.name, account.type), + ) + query(uri(ProviderTable.CALENDARS), arrayOf(ID), selection, null).use { c -> + while (c.moveToNext()) ids += c.getLong(0) + } + return ids + } + + override fun rootAccounts(table: ProviderTable, ids: Set): Map { + val owners = HashMap() + forEachRow(table, arrayOf(ID, ScopePlanner.ACCOUNT_NAME, ScopePlanner.ACCOUNT_TYPE), ID, ids) { c -> + owners[c.getLong(0)] = AccountRef(c.getString(1).orEmpty(), c.getString(2).orEmpty()) + } + return owners + } + + override fun dataRawContacts(ids: Set): Map = + idPairs(ProviderTable.DATA, "raw_contact_id", ids) + + override fun dataMimetypes(ids: Set): Map { + val mimetypes = HashMap() + forEachRow(ProviderTable.DATA, arrayOf(ID, "mimetype"), ID, ids) { c -> + if (!c.isNull(1)) mimetypes[c.getLong(0)] = c.getString(1) + } + return mimetypes + } + + override fun eventCalendars(ids: Set): Map = + idPairs(ProviderTable.EVENTS, "calendar_id", ids) + + override fun childEvents(table: ProviderTable, ids: Set): Map = + idPairs(table, "event_id", ids) + + override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List): Set { + val events = HashSet() + query(uri(table, scoped = false), arrayOf("event_id"), Selection(where, args), null).use { c -> + while (c.moveToNext()) if (!c.isNull(0)) events += c.getLong(0) + } + return events + } + + /** Row id → [column] for the rows of [ids] that exist, in any account. */ + private fun idPairs(table: ProviderTable, column: String, ids: Set): Map { + val pairs = HashMap() + forEachRow(table, arrayOf(ID, column), table.idColumn!!, ids) { c -> + if (!c.isNull(1)) pairs[c.getLong(0)] = c.getLong(1) + } + return pairs + } + + private fun forEachRow( + table: ProviderTable, + projection: Array, + idColumn: String, + ids: Set, + read: (Cursor) -> Unit, + ) { + for (chunk in ids.chunked(ScopePlanner.ID_LIST_CHUNK)) { + val selection = Selection(ScopePlanner.idList(idColumn, chunk), emptyList()) + query(uri(table, scoped = false), projection, selection, null).use { c -> + while (c.moveToNext()) read(c) + } + } + } + + private fun query(uri: Uri, projection: Array, selection: Selection, orderBy: String?): Cursor = + client.query(uri, projection, selection.sql, selection.args.toTypedArray(), orderBy) + ?: throw IllegalStateException("$authority returned no cursor for $uri") + + // ── operations ────────────────────────────────────────────── + + private fun build(op: PlannedOp): ContentProviderOperation = when (op) { + is PlannedOp.Insert -> ContentProviderOperation.newInsert(uri(op.table)) + .withValues(contentValues(op.values)) + .apply { for ((column, index) in op.backReferences) withValueBackReference(column, index) } + .withYieldAllowed(op.yieldAllowed) + .build() + is PlannedOp.Update -> ContentProviderOperation + .newUpdate(op.itemId?.let { uri(op.table, itemId = it) } ?: uri(op.table)) + .withValues(contentValues(op.values)) + .selectRows(op.selection) + .expectRows(op.expectedCount) + .withYieldAllowed(op.yieldAllowed) + .build() + is PlannedOp.Delete -> ContentProviderOperation.newDelete(uri(op.table)) + .selectRows(op.selection) + .expectRows(op.expectedCount) + .withYieldAllowed(op.yieldAllowed) + .build() + is PlannedOp.Assert -> ContentProviderOperation.newAssertQuery(uri(op.table)) + .selectRows(op.selection) + .apply { + op.values?.let { values -> + withValues(ContentValues().apply { for ((column, text) in values) put(column, text) }) + } + } + .expectRows(op.expectedCount) + .withYieldAllowed(op.yieldAllowed) + .build() + // An insert that replaces the account's row; never a yield point. + is PlannedOp.SetSyncState -> SyncStateContract.Helpers.newSetOperation( + syncStateUri(), + Account(account.name, account.type), + op.value.toByteArray(Charsets.UTF_8), + ) + } + + private fun ContentProviderOperation.Builder.selectRows(selection: Selection) = apply { + // An item URI takes no selection at all (CalendarProvider refuses one). + if (selection.sql != null) withSelection(selection.sql, selection.args.toTypedArray()) + } + + private fun ContentProviderOperation.Builder.expectRows(count: Int?) = apply { + if (count != null) withExpectedCount(count) + } + + private fun outcome(op: PlannedOp, uri: Uri?, count: Int?): OpOutcome = when (op) { + // Settings inserts answer with the account's URI, which has no id. + is PlannedOp.Insert -> OpOutcome(id = uri?.lastPathSegment?.toLongOrNull()) + is PlannedOp.SetSyncState -> OpOutcome() + else -> OpOutcome(count = count) + } + + private fun contentValues(values: Map) = ContentValues(values.size).apply { + for ((column, cell) in values) { + when (cell) { + is Cell.Text -> put(column, cell.value) + is Cell.Integer -> put(column, cell.value) + is Cell.Real -> put(column, cell.value) + is Cell.Blob -> put(column, decodeBlob(column, cell.base64)) + Cell.Null -> putNull(column) + } + } + } + + private fun decodeBlob(column: String, base64: String): ByteArray = try { + Base64.decode(base64, Base64.DEFAULT) + } catch (e: IllegalArgumentException) { + throw ProviderRefusal(BatchFailure.PROVIDER, "$column is not base64") + } + + // ── URIs ──────────────────────────────────────────────────── + + private fun uri(table: ProviderTable, itemId: Long? = null, scoped: Boolean = true): Uri { + val base = when (table) { + ProviderTable.RAW_CONTACTS -> ContactsContract.RawContacts.CONTENT_URI + ProviderTable.DATA -> ContactsContract.Data.CONTENT_URI + ProviderTable.GROUPS -> ContactsContract.Groups.CONTENT_URI + ProviderTable.SETTINGS -> ContactsContract.Settings.CONTENT_URI + ProviderTable.CALENDARS -> CalendarContract.Calendars.CONTENT_URI + ProviderTable.EVENTS -> CalendarContract.Events.CONTENT_URI + ProviderTable.ATTENDEES -> CalendarContract.Attendees.CONTENT_URI + ProviderTable.REMINDERS -> CalendarContract.Reminders.CONTENT_URI + ProviderTable.EXTENDED_PROPERTIES -> CalendarContract.ExtendedProperties.CONTENT_URI + ProviderTable.COLORS -> CalendarContract.Colors.CONTENT_URI + } + val builder = (itemId?.let { ContentUris.withAppendedId(base, it) } ?: base).buildUpon() + return if (scoped) builder.scoped().build() else builder.syncAdapter().build() + } + + private fun syncStateUri(): Uri = when (authority) { + DeviceSyncAccounts.CONTACTS_AUTHORITY -> ContactsContract.SyncState.CONTENT_URI + else -> CalendarContract.SyncState.CONTENT_URI + }.buildUpon().scoped().build() + + private fun Uri.Builder.syncAdapter() = appendQueryParameter(CALLER_IS_SYNCADAPTER, "true") + + private fun Uri.Builder.scoped() = syncAdapter() + .appendQueryParameter(ScopePlanner.ACCOUNT_NAME, account.name) + .appendQueryParameter(ScopePlanner.ACCOUNT_TYPE, account.type) + + companion object { + private const val ID = "_id" + private const val JPEG_QUALITY = 85 + + /** Same parameter name in ContactsContract and CalendarContract. */ + private const val CALLER_IS_SYNCADAPTER = ContactsContract.CALLER_IS_SYNCADAPTER + + /** The `BatchFailure` of an exception from planning or from the provider (possibly wrapped). */ + fun failureOf(e: Throwable): BatchFailure { + var t: Throwable? = e + while (t != null) { + when (t) { + is ProviderRefusal -> return t.reason + is TransactionTooLargeException -> return BatchFailure.TOO_LARGE + is SecurityException -> return BatchFailure.PERMISSION + is OperationApplicationException -> return ProviderResults.failureOfOperationApplication(t.message) + } + t = t.cause?.takeIf { it !== t } + } + return BatchFailure.PROVIDER + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt new file mode 100644 index 00000000..b5d9555b --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderOps.kt @@ -0,0 +1,466 @@ +package com.anonymous.bulwarkmobile.sync + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.math.BigDecimal +import java.math.BigInteger +import kotlin.math.abs + +/** + * The provider operations of device sync (#34) as the engine sends them: the + * `ProviderQuery` and `ProviderOp[]` JSON of src/device-sync/types.ts, parsed + * into a validated model. [ScopePlanner] turns the model into provider calls + * scoped to one account and [ProviderIo] runs them. Nothing here touches + * Android types, so JVM tests cover all of it. + */ + +/** `BatchFailure` of types.ts: why a batch was refused or failed. */ +enum class BatchFailure(val wire: String) { + ASSERT("assert"), + TOO_LARGE("tooLarge"), + SCOPE("scope"), + PERMISSION("permission"), + PROVIDER("provider"), +} + +/** A query or batch refused before it reached the provider. */ +class ProviderRefusal(val reason: BatchFailure, message: String) : Exception(message) + +/** How the rows of a table are tied to an account; see [ScopePlanner]. */ +enum class TableScope { ACCOUNT_COLUMNS, URI_PARAMETERS, CALENDAR_IDS, EVENT_IDS } + +/** The tables the engine may touch, per authority. Any other table is refused. */ +enum class ProviderTable( + val wire: String, + val authority: String, + val scope: TableScope, + /** The table whose row each row here belongs to, named by [parentColumn]. */ + val parent: ProviderTable? = null, + val parentColumn: String? = null, + /** + * How a selection names the row id; null when rows have none (Settings + * rows are keyed by their account). CalendarProvider queries attendees + * joined with events and calendars, where a bare `_id` is ambiguous. + */ + val idColumn: String? = "_id", +) { + RAW_CONTACTS("raw_contacts", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + DATA("data", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.URI_PARAMETERS, RAW_CONTACTS, "raw_contact_id"), + GROUPS("groups", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + SETTINGS("settings", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS, idColumn = null), + CALENDARS("calendars", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + EVENTS("events", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.CALENDAR_IDS, CALENDARS, "calendar_id"), + ATTENDEES( + "attendees", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Attendees._id", + ), + REMINDERS( + "reminders", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Reminders._id", + ), + EXTENDED_PROPERTIES( + "extended_properties", + DeviceSyncAccounts.CALENDAR_AUTHORITY, + TableScope.EVENT_IDS, + EVENTS, + "event_id", + "ExtendedProperties._id", + ), + COLORS("colors", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS), + ; + + /** An insert returns the new row id, which later ops can back-reference. */ + val insertReturnsId: Boolean get() = idColumn != null + + companion object { + fun of(authority: String, wire: String): ProviderTable = + entries.firstOrNull { it.wire == wire && it.authority == authority } + ?: throw ProviderRefusal(BatchFailure.SCOPE, "Table $wire is not part of $authority") + } +} + +/** + * A cell as it crosses the bridge: text, a number, null, or (written to + * `data.data15` only) photo bytes as base64. Numbers that are integers become + * [Integer], so an assert compares `5` with the provider's `5`, not `5.0`. + */ +sealed interface Cell { + data class Text(val value: String) : Cell + data class Integer(val value: Long) : Cell + data class Real(val value: Double) : Cell + data class Blob(val base64: String) : Cell + data object Null : Cell + + /** The text ContentProviderOperation compares an assert with; blobs never reach an assert. */ + fun text(): String? = when (this) { + is Text -> value + is Integer -> value.toString() + is Real -> value.toString() + is Blob -> throw IllegalStateException("Blobs have no text form") + Null -> null + } + + /** The row id this cell names, if it names one (`12` or `"12"`). */ + fun rowId(): Long? = when (this) { + is Integer -> value + is Text -> value.toLongOrNull() + else -> null + } +} + +/** Which rows an update, delete or assert addresses: one row by id, a `where` set, or the id row if it matches. */ +data class RowTarget(val id: Long?, val where: String?, val args: List) + +data class ProviderQuery( + val table: ProviderTable, + val columns: List, + val where: String?, + val args: List, + val orderBy: String?, +) + +/** One `ProviderOp` of types.ts, validated. */ +sealed interface ProviderOp { + val yieldAllowed: Boolean + + data class Insert( + val table: ProviderTable, + val values: Map, + /** Column → index of an earlier insert of the batch whose new row id fills it. */ + val refs: Map, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class Update( + val table: ProviderTable, + val target: RowTarget, + val values: Map, + val expectCount: Int?, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class Delete( + val table: ProviderTable, + val target: RowTarget, + val expectCount: Int?, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class Assert( + val table: ProviderTable, + val target: RowTarget, + val values: Map?, + val expectCount: Int?, + override val yieldAllowed: Boolean, + ) : ProviderOp + + data class SetSyncState(val value: String, override val yieldAllowed: Boolean) : ProviderOp +} + +const val GROUP_MEMBERSHIP_MIMETYPE = "vnd.android.cursor.item/group_membership" + +/** + * Checks a selection or sort order before the account's scope is ANDed onto + * it as `(text) AND (scope)`: its parentheses must balance outside quoted + * literals, or `1) OR (1` would close the scope's own parenthesis. It may + * not end or comment out the statement, nor query other tables. The engine's + * selections are constants with `?` arguments; this guards against bugs. + */ +object SqlFragments { + private val FORBIDDEN_WORDS = Regex("\\b(SELECT|UNION|ATTACH|DETACH|PRAGMA)\\b", RegexOption.IGNORE_CASE) + + fun check(text: String, what: String) { + var depth = 0 + var quote: Char? = null + val outside = StringBuilder(text.length) + var i = 0 + while (i < text.length) { + val c = text[i] + if (quote != null) { + if (c == quote) { + // A doubled quote is an escaped one inside the literal. + if (i + 1 < text.length && text[i + 1] == quote) i++ else quote = null + } + outside.append(' ') + } else { + when (c) { + '\'', '"' -> { + quote = c + outside.append(' ') + } + '(' -> { + depth++ + outside.append(c) + } + ')' -> { + if (--depth < 0) refuse(what, "closes a parenthesis it did not open") + outside.append(c) + } + ';' -> refuse(what, "ends the statement") + '\u0000' -> refuse(what, "contains a NUL character") + else -> outside.append(c) + } + } + i++ + } + if (quote != null) refuse(what, "leaves a quote open") + if (depth != 0) refuse(what, "leaves a parenthesis open") + val bare = outside.toString() + if ("--" in bare || "/*" in bare) refuse(what, "contains a comment") + FORBIDDEN_WORDS.find(bare)?.let { refuse(what, "uses ${it.value.uppercase()}") } + } + + private fun refuse(what: String, why: String): Nothing = + throw ProviderRefusal(BatchFailure.SCOPE, "$what $why") +} + +/** + * The table a written column points into when the row it names must belong to + * the account as well: the parent of a row, the master of an exception event, + * the group of a membership row. + */ +internal fun referencedTable(table: ProviderTable, column: String, values: Map): ProviderTable? = when { + column == table.parentColumn -> table.parent + table == ProviderTable.EVENTS && column == "original_id" -> ProviderTable.EVENTS + table == ProviderTable.DATA && column == "data1" && + values["mimetype"] == Cell.Text(GROUP_MEMBERSHIP_MIMETYPE) -> ProviderTable.GROUPS + else -> null +} + +object ProviderOpParser { + /** + * ContactsProvider throws when its op counter reaches this. It counts an + * op before resetting at a yield point, so at most 499 ops may follow a + * yield point up to and including the next one. + */ + const val CONTACTS_OPS_PER_YIELD_POINT = 500 + + /** JS numbers beyond this are not exact integers any more. */ + private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0 + + fun parseQuery(authority: String, json: String): ProviderQuery { + val o = parseObject(json, "query") + val table = ProviderTable.of(authority, o.requireString("table")) + val rawColumns = o.opt("columns") as? JSONArray ?: throw invalid("query needs a columns array") + val columns = (0 until rawColumns.length()).map { i -> + rawColumns.opt(i) as? String ?: throw invalid("columns[$i] must be a string") + } + if (columns.isEmpty()) throw invalid("query needs at least one column") + val (where, args) = o.whereArgs("query") + val orderBy = o.optionalString("orderBy")?.also { SqlFragments.check(it, "query: orderBy") } + return ProviderQuery(table, columns, where, args, orderBy) + } + + fun parseBatch(authority: String, json: String): List { + val array = try { + JSONArray(json) + } catch (e: JSONException) { + throw invalid("ops are not a JSON array: ${e.message}") + } + val ops = (0 until array.length()).map { i -> + val o = array.opt(i) as? JSONObject ?: throw invalid("op $i is not an object") + parseOp(authority, i, o) + } + checkSyncState(ops) + checkBackReferences(ops) + if (authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) checkYieldPoints(ops) + return ops + } + + private fun parseOp(authority: String, index: Int, o: JSONObject): ProviderOp { + val where = "op $index" + val kind = o.opt("op") as? String ?: throw invalid("$where has no op") + val yieldAllowed = o.optBoolean("yieldAllowed", false) + if (kind == "syncState") { + val value = o.opt("value") as? String ?: throw invalid("$where: syncState needs a string value") + return ProviderOp.SetSyncState(value, yieldAllowed) + } + val table = ProviderTable.of(authority, o.requireString("table")) + return when (kind) { + "insert" -> ProviderOp.Insert(table, o.writeValues(table, where), o.refs(where), yieldAllowed) + "update" -> { + val values = o.writeValues(table, where) + if (values.isEmpty()) throw invalid("$where: an update needs values") + ProviderOp.Update(table, o.target(table, where), values, o.expectCount(where), yieldAllowed) + } + "delete" -> ProviderOp.Delete(table, o.target(table, where), o.expectCount(where), yieldAllowed) + "assert" -> { + val target = o.target(table, where) + val values = o.assertValues(where) + val expectCount = o.expectCount(where) + if (values.isNullOrEmpty() && expectCount == null && target.id == null) { + throw invalid("$where: an assert needs values, an expected count or an id") + } + ProviderOp.Assert(table, target, values?.takeIf { it.isNotEmpty() }, expectCount, yieldAllowed) + } + else -> throw invalid("$where: unknown op $kind") + } + } + + /** + * The state describes the rows of its batch, so it must commit with them: + * as the last op, and not at a yield point, where the provider may commit + * everything before it on its own. + */ + private fun checkSyncState(ops: List) { + ops.forEachIndexed { i, op -> + if (op !is ProviderOp.SetSyncState) return@forEachIndexed + if (i != ops.lastIndex) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must be the last op of its batch") + } + if (op.yieldAllowed) throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must not be a yield point") + } + } + + private fun checkBackReferences(ops: List) { + ops.forEachIndexed { i, op -> + if (op !is ProviderOp.Insert) return@forEachIndexed + for ((column, ref) in op.refs) { + val target = ops.getOrNull(ref) + if (ref >= i || target !is ProviderOp.Insert || !target.table.insertReturnsId) { + throw invalid("op $i: $column refers to op $ref, which is not an earlier insert with a row id") + } + val expected = referencedTable(op.table, column, op.values) + if (expected != null && target.table != expected) { + throw ProviderRefusal( + BatchFailure.SCOPE, + "op $i: $column must refer to a ${expected.wire} insert, not ${target.table.wire}", + ) + } + } + val parentColumn = op.table.parentColumn ?: return@forEachIndexed + val named = parentColumn in op.refs || (op.values[parentColumn] ?: Cell.Null) != Cell.Null + if (!named) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: a ${op.table.wire} insert must name its $parentColumn") + } + } + } + + private fun checkYieldPoints(ops: List) { + var count = 0 + ops.forEachIndexed { i, op -> + if (++count >= CONTACTS_OPS_PER_YIELD_POINT) { + throw invalid( + "op $i: more than ${CONTACTS_OPS_PER_YIELD_POINT - 1} ops without a yield point " + + "(ContactsProvider refuses the batch)", + ) + } + if (i > 0 && op.yieldAllowed) count = 0 + } + } + + // ── JSON helpers ──────────────────────────────────────────── + + private fun invalid(message: String) = ProviderRefusal(BatchFailure.PROVIDER, message) + + private fun parseObject(json: String, what: String): JSONObject = try { + JSONObject(json) + } catch (e: JSONException) { + throw invalid("$what is not a JSON object: ${e.message}") + } + + private fun JSONObject.requireString(key: String): String = + opt(key) as? String ?: throw invalid("$key must be a string") + + private fun JSONObject.optionalString(key: String): String? = when (val v = opt(key)) { + null, JSONObject.NULL -> null + is String -> v.takeIf { it.isNotBlank() } + else -> throw invalid("$key must be a string") + } + + private fun JSONObject.target(table: ProviderTable, where: String): RowTarget { + val id = when (val raw = opt("id")) { + null, JSONObject.NULL -> null + else -> (scalar(raw, "$where: id") as? Cell.Integer)?.value?.takeIf { it >= 0 } + ?: throw invalid("$where: id must be a row id") + } + if (id != null && table.idColumn == null) throw invalid("$where: ${table.wire} rows have no id") + val (sql, args) = whereArgs(where) + return RowTarget(id, sql, args) + } + + private fun JSONObject.whereArgs(where: String): Pair> { + val sql = optionalString("where")?.also { SqlFragments.check(it, "$where: where") } + val args = when (val raw = opt("args")) { + null, JSONObject.NULL -> emptyList() + is JSONArray -> (0 until raw.length()).map { i -> + scalar(raw.opt(i), "$where: args[$i]").text() + ?: throw invalid("$where: args[$i] must be a string or a number") + } + else -> throw invalid("$where: args must be an array") + } + if (sql == null && args.isNotEmpty()) throw invalid("$where: args without a where") + return sql to args + } + + private fun JSONObject.expectCount(where: String): Int? = when (val raw = opt("expectCount")) { + null, JSONObject.NULL -> null + else -> (scalar(raw, "$where: expectCount") as? Cell.Integer)?.value + ?.takeIf { it in 0..Int.MAX_VALUE }?.toInt() + ?: throw invalid("$where: expectCount must be a count") + } + + private fun JSONObject.refs(where: String): Map { + val raw = opt("refs") ?: return emptyMap() + if (raw == JSONObject.NULL) return emptyMap() + if (raw !is JSONObject) throw invalid("$where: refs must be an object") + val out = LinkedHashMap() + for (column in raw.keys()) { + val index = (scalar(raw.opt(column), "$where: refs.$column") as? Cell.Integer)?.value + ?.takeIf { it in 0..Int.MAX_VALUE } + ?: throw invalid("$where: refs.$column must be an op index") + out[column] = index.toInt() + } + return out + } + + private fun JSONObject.writeValues(table: ProviderTable, where: String): Map { + val raw = opt("values") as? JSONObject ?: throw invalid("$where: values must be an object") + val out = LinkedHashMap() + for (column in raw.keys()) { + val v = raw.opt(column) + out[column] = if (v is JSONObject) blob(table, column, v, where) else scalar(v, "$where: $column") + } + return out + } + + private fun JSONObject.assertValues(where: String): Map? { + val raw = opt("values") ?: return null + if (raw == JSONObject.NULL) return null + if (raw !is JSONObject) throw invalid("$where: values must be an object") + val out = LinkedHashMap() + for (column in raw.keys()) out[column] = scalar(raw.opt(column), "$where: $column") + return out + } + + /** Only photo rows carry bytes (`data.data15`); the provider stores and scales them itself. */ + private fun blob(table: ProviderTable, column: String, v: JSONObject, where: String): Cell.Blob { + if (table != ProviderTable.DATA || column != "data15") { + throw ProviderRefusal(BatchFailure.SCOPE, "$where: bytes are only accepted for data.data15, not ${table.wire}.$column") + } + val base64 = v.opt("b64") as? String ?: throw invalid("$where: $column needs { b64 }") + return Cell.Blob(base64) + } + + private fun scalar(v: Any?, what: String): Cell = when (v) { + null, JSONObject.NULL -> Cell.Null + is String -> Cell.Text(v) + is Boolean -> Cell.Integer(if (v) 1 else 0) + is Number -> numberCell(v) + else -> throw invalid("$what must be text, a number or null") + } + + /** org.json yields Integer, Long, Double, BigInteger or BigDecimal depending on the platform and the value. */ + internal fun numberCell(n: Number): Cell = when (n) { + is Int, is Long, is Short, is Byte -> Cell.Integer(n.toLong()) + is BigInteger -> if (n.bitLength() < 64) Cell.Integer(n.toLong()) else Cell.Real(n.toDouble()) + is BigDecimal -> try { + Cell.Integer(n.longValueExact()) + } catch (e: ArithmeticException) { + Cell.Real(n.toDouble()) + } + else -> { + val d = n.toDouble() + if (d.isFinite() && d == Math.rint(d) && abs(d) <= MAX_SAFE_INTEGER) Cell.Integer(d.toLong()) else Cell.Real(d) + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt new file mode 100644 index 00000000..7706b0a1 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/ProviderScope.kt @@ -0,0 +1,549 @@ +package com.anonymous.bulwarkmobile.sync + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Plans provider calls for one account: every selection gets the account + * condition ANDed in, and every row an operation names by id, or points to + * from a written column, is checked to belong to the account first + * (docs/device-sync.md, "Native module API", Scoping). + * + * How a table is tied to the account ([TableScope]): + * - raw contacts, groups, settings, calendars and colors carry it in their + * `account_name`/`account_type` columns; + * - contacts data rows are filtered by ContactsProvider itself through the + * `account_name`/`account_type` URI parameters. Their account columns are + * never selected on: Android 17 drops them from the Data view for apps + * targeting more than API 36; + * - events get `calendar_id IN (…)` of the account's calendars, not the + * account columns: CalendarProvider updates events on the Events table, + * which has none; + * - attendees, reminders and extended properties get `event_id IN (…)` of the + * account's events among those the operation touches. CalendarProvider + * applies no account filter to them. + * + * Selections never use subqueries: the providers may compile them strictly. + */ +class ScopePlanner(private val account: AccountRef, private val lookup: ScopeLookup) { + private val calendarIds: Set by lazy { lookup.ownCalendarIds() } + + fun planQuery(query: ProviderQuery): PlannedQuery { + val target = RowTarget(null, query.where, query.args) + val selections = if (query.table.scope == TableScope.EVENT_IDS) { + // Mostly a handful of events (the engine reads children per chunk + // of events); a query over all of them is split so no selection + // outgrows a Binder transaction. Rows are then ordered per chunk. + ownEventsMatching(query.table, target) + .sorted() + .chunked(ID_LIST_CHUNK) + .map { chunk -> selection(query.table, target, chunk) } + } else { + listOf(selection(query.table, target, null)) + } + return PlannedQuery(query.table, query.columns, selections, query.orderBy) + } + + fun planBatch(ops: List): List { + val memberships = membershipUpdates(ops) + val owners = Owners(ownershipRequests(ops, memberships)) + return ops.mapIndexed { i, op -> plan(i, op, owners, memberships) } + } + + /** + * Membership rows among the data rows that updates by id write `data1` + * of without restating the mimetype: their `data1` names a group, which + * must be the account's like on insert. + */ + private fun membershipUpdates(ops: List): Set { + val ids = ops.asSequence() + .filterIsInstance() + .filter { it.table == ProviderTable.DATA && writesUntypedData1(it.values) } + .mapNotNull { it.target.id } + .toSet() + if (ids.isEmpty()) return emptySet() + return lookup.dataMimetypes(ids).filterValues { it == GROUP_MEMBERSHIP_MIMETYPE }.keys + } + + private fun writesUntypedData1(values: Map) = + MIMETYPE !in values && (values[DATA1] ?: Cell.Null) != Cell.Null + + private fun plan(i: Int, op: ProviderOp, owners: Owners, memberships: Set): PlannedOp = when (op) { + is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value) + is ProviderOp.Insert -> { + checkAccountColumns(i, op.table, op.values) + checkPhotoFile(i, op.table, op.values) + checkReferences(i, op.table, op.values - op.refs.keys, owners) + val values = if (op.table.scope == TableScope.ACCOUNT_COLUMNS) op.values + accountCells() else op.values + PlannedOp.Insert(op.table, values, op.refs, op.yieldAllowed) + } + is ProviderOp.Update -> { + checkAccountColumns(i, op.table, op.values) + checkPhotoFile(i, op.table, op.values) + checkReferences(i, op.table, op.values, owners) + if (op.table == ProviderTable.DATA && writesUntypedData1(op.values)) { + checkMembershipUpdate(i, op, owners, memberships) + } + checkTarget(i, op.table, op.target, owners) + if (op.table == ProviderTable.EXTENDED_PROPERTIES) { + // CalendarProvider updates extended properties only through + // their item URI, which takes no selection: the id check above + // is the scope. + val id = op.target.id + if (id == null || op.target.where != null) { + throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: extended properties can only be updated by id") + } + PlannedOp.Update(op.table, Selection.NONE, id, op.values, op.expectCount ?: 1, op.yieldAllowed) + } else { + val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners)) + PlannedOp.Update(op.table, selection, null, op.values, expectedCount(op.target, op.expectCount), op.yieldAllowed) + } + } + is ProviderOp.Delete -> { + checkTarget(i, op.table, op.target, owners) + val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners)) + PlannedOp.Delete(op.table, selection, expectedCount(op.target, op.expectCount), op.yieldAllowed) + } + is ProviderOp.Assert -> { + checkTarget(i, op.table, op.target, owners) + val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners)) + val values = op.values?.mapValues { (_, cell) -> cell.text() } + PlannedOp.Assert(op.table, selection, values, expectedCount(op.target, op.expectCount), op.yieldAllowed) + } + } + + /** An op that names one row by id expects exactly that row: the engine relies on it to notice a vanished row. */ + private fun expectedCount(target: RowTarget, expectCount: Int?): Int? = + expectCount ?: if (target.id != null) 1 else null + + private fun accountCells() = mapOf( + ACCOUNT_NAME to Cell.Text(account.name), + ACCOUNT_TYPE to Cell.Text(account.type), + ) + + /** + * Account columns may only restate the account, on the tables that have + * them; a data row, event or attendee follows its parent's account. + */ + private fun checkAccountColumns(i: Int, table: ProviderTable, values: Map) { + for (column in listOf(ACCOUNT_NAME, ACCOUNT_TYPE, DATA_SET)) { + val cell = values[column] ?: continue + val restatesAccount = table.scope == TableScope.ACCOUNT_COLUMNS && when (column) { + ACCOUNT_NAME -> cell == Cell.Text(account.name) + ACCOUNT_TYPE -> cell == Cell.Text(account.type) + else -> cell == Cell.Null + } + if (!restatesAccount) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column would leave the account") + } + } + } + + /** + * Parents, masters and groups a written column names must be rows of the + * account that still exist. (Back-references were checked by the parser: + * they name inserts of the same batch.) + */ + private fun checkReferences(i: Int, table: ProviderTable, values: Map, owners: Owners) { + for ((column, cell) in values) { + val referenced = referencedTable(table, column, values) ?: continue + if (cell == Cell.Null) { + if (column == table.parentColumn) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column must name a ${referenced.wire} row") + } + continue + } + val id = cell.rowId() + ?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: ${table.wire}.$column must be a row id") + when (owners.of(referenced, id)) { + Owner.OURS -> Unit + Owner.FOREIGN -> throw ProviderRefusal( + BatchFailure.SCOPE, + "op $i: ${table.wire}.$column names ${referenced.wire} $id of another account", + ) + // Deleted meanwhile, as far as the engine can tell: re-read and re-plan. + Owner.MISSING -> throw ProviderRefusal( + BatchFailure.ASSERT, + "op $i: ${table.wire}.$column names ${referenced.wire} $id, which no longer exists", + ) + } + } + } + + /** + * `data1` written by an update that doesn't restate the mimetype: on a + * membership row it names a group. Rows chosen by a selection could be + * memberships the lookup didn't see, so only updates by id may do it. + */ + private fun checkMembershipUpdate(i: Int, op: ProviderOp.Update, owners: Owners, memberships: Set) { + val id = op.target.id + ?: throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data1 may only be written to a data row by id") + if (id !in memberships) return + val group = op.values[DATA1]?.rowId() + ?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: a membership's data1 must be a group id") + when (owners.of(ProviderTable.GROUPS, group)) { + Owner.OURS -> Unit + Owner.FOREIGN -> throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data1 names group $group of another account") + Owner.MISSING -> throw ProviderRefusal(BatchFailure.ASSERT, "op $i: data.data1 names group $group, which no longer exists") + } + } + + /** + * A photo row's file id (`data14`) is the provider's: it points into the + * photo store, where another account's file id would let `readPhoto` + * read that account's photo. Photos are written as bytes (`data15`). + */ + private fun checkPhotoFile(i: Int, table: ProviderTable, values: Map) { + if (table == ProviderTable.DATA && (values[DATA14] ?: Cell.Null) != Cell.Null) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data14 (a photo file id) is written by the provider only") + } + } + + /** A row named by id must not belong to another account; a missing one fails on the expected count instead. */ + private fun checkTarget(i: Int, table: ProviderTable, target: RowTarget, owners: Owners) { + val id = target.id ?: return + if (owners.of(table, id) == Owner.FOREIGN) { + throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire} $id belongs to another account") + } + } + + /** For attendees, reminders and extended properties: the account's events the op may touch. */ + private fun eventScope(table: ProviderTable, target: RowTarget, owners: Owners): Collection? { + if (table.scope != TableScope.EVENT_IDS) return null + val id = target.id ?: return ownEventsMatching(table, target) + return listOfNotNull(owners.eventOf(table, id)) + } + + private fun ownEventsMatching(table: ProviderTable, target: RowTarget): Set { + val candidates = lookup.childEventIdsMatching(table, target.where, target.args) + if (candidates.isEmpty()) return emptySet() + return lookup.eventCalendars(candidates).filterValues { it in calendarIds }.keys + } + + private fun selection(table: ProviderTable, target: RowTarget, eventIds: Collection?): Selection { + val parts = ArrayList(3) + val args = ArrayList() + target.id?.let { + parts += "${table.idColumn} = ?" + args += it.toString() + } + target.where?.let { + parts += it + args += target.args + } + when (table.scope) { + TableScope.ACCOUNT_COLUMNS -> { + parts += "$ACCOUNT_NAME = ? AND $ACCOUNT_TYPE = ?" + args += account.name + args += account.type + } + TableScope.URI_PARAMETERS -> Unit + TableScope.CALENDAR_IDS -> parts += idList("calendar_id", calendarIds) + TableScope.EVENT_IDS -> parts += idList("event_id", eventIds.orEmpty()) + } + return Selection(parts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") { "($it)" }, args) + } + + /** Every row id the batch names, by table: op targets and referenced rows. */ + private fun ownershipRequests(ops: List, memberships: Set): Map> { + val requests = HashMap>() + fun request(table: ProviderTable, id: Long) { + requests.getOrPut(table) { HashSet() } += id + } + fun requestReferences(table: ProviderTable, values: Map) { + for ((column, cell) in values) { + val referenced = referencedTable(table, column, values) ?: continue + cell.rowId()?.let { request(referenced, it) } + } + } + for (op in ops) { + when (op) { + is ProviderOp.Insert -> requestReferences(op.table, op.values) + is ProviderOp.Update -> { + requestReferences(op.table, op.values) + op.target.id?.let { request(op.table, it) } + if (op.target.id in memberships) op.values[DATA1]?.rowId()?.let { request(ProviderTable.GROUPS, it) } + } + is ProviderOp.Delete -> op.target.id?.let { request(op.table, it) } + is ProviderOp.Assert -> op.target.id?.let { request(op.table, it) } + is ProviderOp.SetSyncState -> Unit + } + } + return requests + } + + private enum class Owner { OURS, FOREIGN, MISSING } + + /** Who owns the rows a batch names, resolved up front with as few lookups as the tables allow. */ + private inner class Owners(requests: Map>) { + private val owners = HashMap>() + private val childEvents = HashMap>() + + init { + // Children lead to events, data rows to raw contacts; both are + // added to their parent's lookup. + val eventIds = HashSet(requests[ProviderTable.EVENTS].orEmpty()) + for (table in CHILD_TABLES) { + val ids = requests[table] ?: continue + val events = lookup.childEvents(table, ids) + childEvents[table] = events + eventIds += events.values + } + val rawContactIds = HashSet(requests[ProviderTable.RAW_CONTACTS].orEmpty()) + val dataParents = requests[ProviderTable.DATA]?.let { lookup.dataRawContacts(it) }.orEmpty() + rawContactIds += dataParents.values + + if (eventIds.isNotEmpty()) { + owners[ProviderTable.EVENTS] = lookup.eventCalendars(eventIds) + .mapValues { (_, calendarId) -> if (calendarId in calendarIds) Owner.OURS else Owner.FOREIGN } + } + for ((table, events) in childEvents) { + // A child whose event is gone is an orphan, not ours. + owners[table] = events.mapValues { (_, eventId) -> ownerOrForeign(ProviderTable.EVENTS, eventId) } + } + val roots = HashMap>() + for (table in ROOT_TABLES) requests[table]?.let { roots[table] = it } + if (rawContactIds.isNotEmpty()) roots[ProviderTable.RAW_CONTACTS] = rawContactIds + for ((table, ids) in roots) { + owners[table] = lookup.rootAccounts(table, ids) + .mapValues { (_, owner) -> if (owner == account) Owner.OURS else Owner.FOREIGN } + } + if (dataParents.isNotEmpty()) { + owners[ProviderTable.DATA] = dataParents.mapValues { (_, rawContactId) -> + ownerOrForeign(ProviderTable.RAW_CONTACTS, rawContactId) + } + } + } + + private fun ownerOrForeign(table: ProviderTable, id: Long): Owner = + owners[table]?.get(id)?.takeIf { it != Owner.MISSING } ?: Owner.FOREIGN + + fun of(table: ProviderTable, id: Long): Owner = owners[table]?.get(id) ?: Owner.MISSING + + /** The event of a child row, when both exist and are the account's. */ + fun eventOf(table: ProviderTable, id: Long): Long? = + childEvents[table]?.get(id)?.takeIf { of(table, id) == Owner.OURS } + } + + companion object { + const val ACCOUNT_NAME = "account_name" + const val ACCOUNT_TYPE = "account_type" + const val DATA_SET = "data_set" + private const val MIMETYPE = "mimetype" + private const val DATA1 = "data1" + private const val DATA14 = "data14" + + /** Ids per `IN (…)` list: a few KB of selection, far from SQLite's and Binder's limits. */ + const val ID_LIST_CHUNK = 500 + + private val CHILD_TABLES = ProviderTable.entries.filter { it.scope == TableScope.EVENT_IDS } + private val ROOT_TABLES = ProviderTable.entries.filter { it.scope == TableScope.ACCOUNT_COLUMNS } + + /** `column IN (1,2,3)`, or a condition no row meets. The ids are ours to inline: numbers we read. */ + fun idList(column: String, ids: Collection): String = + if (ids.isEmpty()) "0" else ids.sorted().joinToString(",", "$column IN (", ")") + } +} + +/** An Android account of our type: the scope of every provider call. */ +data class AccountRef(val name: String, val type: String) + +/** + * What [ScopePlanner] needs to know about rows beyond the operations + * themselves. [ProviderIo] answers with provider queries: except for + * [ownCalendarIds], these read rows of any account, but only their ids and + * owners, to tell rows of another account from rows that are gone. + */ +interface ScopeLookup { + /** `_id`s of the account's calendars. */ + fun ownCalendarIds(): Set + + /** The account of each row of a root table that exists. */ + fun rootAccounts(table: ProviderTable, ids: Set): Map + + /** The raw contact of each data row that exists. */ + fun dataRawContacts(ids: Set): Map + + /** The mimetype of each data row that exists. */ + fun dataMimetypes(ids: Set): Map + + /** The calendar of each event that exists. */ + fun eventCalendars(ids: Set): Map + + /** The event of each attendee, reminder or extended property that exists. */ + fun childEvents(table: ProviderTable, ids: Set): Map + + /** The events of the rows of a child table that match [where]. */ + fun childEventIdsMatching(table: ProviderTable, where: String?, args: List): Set +} + +/** A selection with its `?` arguments; a null [sql] selects every row the URI allows. */ +data class Selection(val sql: String?, val args: List) { + companion object { + val NONE = Selection(null, emptyList()) + } +} + +/** A provider query, split into [selections] when its scope list is long; no selection means no rows. */ +data class PlannedQuery( + val table: ProviderTable, + val columns: List, + val selections: List, + val orderBy: String?, +) + +/** One ContentProviderOperation, ready to build on the account's sync-adapter URI. */ +sealed interface PlannedOp { + val yieldAllowed: Boolean + + data class Insert( + val table: ProviderTable, + val values: Map, + val backReferences: Map, + override val yieldAllowed: Boolean, + ) : PlannedOp + + /** [itemId] set: an update through the row's own URI, without a selection. */ + data class Update( + val table: ProviderTable, + val selection: Selection, + val itemId: Long?, + val values: Map, + val expectedCount: Int?, + override val yieldAllowed: Boolean, + ) : PlannedOp + + data class Delete( + val table: ProviderTable, + val selection: Selection, + val expectedCount: Int?, + override val yieldAllowed: Boolean, + ) : PlannedOp + + /** [values] compared as text, as ContentProviderOperation does. */ + data class Assert( + val table: ProviderTable, + val selection: Selection, + val values: Map?, + val expectedCount: Int?, + override val yieldAllowed: Boolean, + ) : PlannedOp + + data class SetSyncState(val value: String) : PlannedOp { + override val yieldAllowed: Boolean get() = false + } +} + +/** The result of one op, as `OpResult` of types.ts. */ +data class OpOutcome(val id: Long? = null, val count: Int? = null) + +object ProviderResults { + fun ok(results: List): String { + val array = JSONArray() + for (result in results) { + array.put(JSONObject().apply { + result.id?.let { put("id", it) } + result.count?.let { put("count", it) } + }) + } + return JSONObject().put("ok", true).put("results", array).toString() + } + + fun failed(reason: BatchFailure, message: String?): String = JSONObject() + .put("ok", false) + .put("reason", reason.wire) + .put("message", message ?: reason.wire) + .toString() + + /** + * An OperationApplicationException is an assert that did not hold or an + * expected count that did not match (`assert`), except when an insert got + * no row back or ContactsProvider refused the batch's yield points. + */ + fun failureOfOperationApplication(message: String?): BatchFailure = when { + message == null -> BatchFailure.ASSERT + message.startsWith("Insert into ") -> BatchFailure.PROVIDER + message.startsWith("Too many content provider operations") -> BatchFailure.PROVIDER + else -> BatchFailure.ASSERT + } +} + +/** + * Query results as `ProviderRows` JSON, built by hand: a chunk of contacts + * can carry megabytes of shadows, and this is the hot path of every run. + */ +class ProviderRowsWriter(columns: List) { + private val out = StringBuilder(4096) + private var rows = 0 + private var cells = 0 + + init { + out.append("{\"columns\":[") + columns.forEachIndexed { i, column -> + if (i > 0) out.append(',') + appendString(column) + } + out.append("],\"rows\":[") + } + + fun beginRow() { + if (rows++ > 0) out.append(',') + out.append('[') + cells = 0 + } + + fun endRow() { + out.append(']') + } + + fun nullCell() = cell { out.append("null") } + + fun longCell(value: Long) = cell { out.append(value) } + + fun doubleCell(value: Double) = cell { if (value.isFinite()) out.append(value) else out.append("null") } + + fun textCell(value: String) = cell { appendString(value) } + + fun finish(): String = out.append("]}").toString() + + private inline fun cell(write: () -> Unit) { + if (cells++ > 0) out.append(',') + write() + } + + private fun appendString(s: String) { + out.append('"') + for (c in s) { + when { + c == '"' -> out.append("\\\"") + c == '\\' -> out.append("\\\\") + c == '\n' -> out.append("\\n") + c == '\r' -> out.append("\\r") + c == '\t' -> out.append("\\t") + c < ' ' || c.code == 0x2028 || c.code == 0x2029 -> + out.append("\\u").append(Integer.toHexString(c.code).padStart(4, '0')) + else -> out.append(c) + } + } + out.append('"') + } +} + +/** Sizes for [ProviderIo.readPhoto]: decode close to the target, then scale the longer side down to it. */ +object PhotoScaling { + /** The largest power of two that keeps the decoded image at least [maxPx] on its longer side. */ + fun sampleSize(width: Int, height: Int, maxPx: Int): Int { + require(maxPx > 0) { "maxPx must be positive" } + var sample = 1 + val longer = maxOf(width, height) + while (longer / (sample * 2) >= maxPx) sample *= 2 + return sample + } + + fun targetSize(width: Int, height: Int, maxPx: Int): Pair { + val longer = maxOf(width, height) + if (longer <= maxPx) return width to height + val scale = maxPx.toDouble() / longer + return maxOf(1, Math.round(width * scale).toInt()) to maxOf(1, Math.round(height * scale).toInt()) + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt new file mode 100644 index 00000000..fd5d938a --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/PushRouting.kt @@ -0,0 +1,173 @@ +package com.anonymous.bulwarkmobile.sync + +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * What a push asks for (#34): syncs of the Android accounts that contacts and + * calendar changes feed, and whether the mail push task should run at all. + * + * The relay forwards the JMAP StateChange's `changed` map verbatim (FCM data + * key `changed`: JMAP account id → type → state; only its first account is + * repeated as `accountId`), so every account key is looked at. The mail task + * runs only for mail: when some account's map has a mail type, for anything + * but a StateChange (an EmailPush), when `changed` is missing, unreadable or + * names no type (older relays), or when an account's entry is unreadable, so + * a contact change never ends in a mail notification. The push task's guard + * (`carriesNoMail` in src/lib/push-background-task.ts) applies the same rules. + */ +object PushRouting { + /** An Android account and the authorities it syncs, fed by one JMAP account. */ + data class Route(val accountName: String, val authorities: Set) + + data class Decision( + /** (Android account name, authority) pairs to request a sync for. */ + val syncs: Set>, + val startMailTask: Boolean, + ) + + private const val STATE_CHANGE_KIND = "jmap-state-change" + + /** + * Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`, + * which new mail changes too. A subscription made by an older build + * listens to them until the launch-time resync replaces its types, and a + * server without `EmailDelivery` has only them. Keep in sync with + * MAIL_TYPES in src/lib/push-background-task.ts. + */ + private val MAIL_TYPES = setOf("EmailDelivery", "Email", "Mailbox") + private val CONTACT_TYPES = setOf("ContactCard", "AddressBook") + private val CALENDAR_TYPES = setOf("CalendarEvent", "Calendar") + + fun decide(data: Map, routes: Map>): Decision { + val changed = data["changed"]?.let(::parseChanged) + // Only a StateChange can say it carries no mail. + var mail = data["kind"] != STATE_CHANGE_KIND || changed == null + val syncs = LinkedHashSet>() + for ((jmapAccountId, types) in changed.orEmpty()) { + if (types == null) { + // One account's entry is unreadable: it may be mail. + mail = true + continue + } + if (types.any { it in MAIL_TYPES }) mail = true + val contacts = types.any { it in CONTACT_TYPES } + val calendar = types.any { it in CALENDAR_TYPES } + for (route in routes[jmapAccountId].orEmpty()) { + if (contacts && DeviceSyncAccounts.CONTACTS_AUTHORITY in route.authorities) { + syncs += route.accountName to DeviceSyncAccounts.CONTACTS_AUTHORITY + } + if (calendar && DeviceSyncAccounts.CALENDAR_AUTHORITY in route.authorities) { + syncs += route.accountName to DeviceSyncAccounts.CALENDAR_AUTHORITY + } + } + } + return Decision(syncs, mail) + } + + /** + * JMAP account id → its changed types (null for an entry that is not an + * object). Null for a map that is unreadable or names no type at all, + * which the relay sends as `{}` when it had no map. + */ + private fun parseChanged(json: String): Map?>? { + val o = try { + JSONObject(json) + } catch (e: JSONException) { + return null + } + val out = LinkedHashMap?>() + for (jmapAccountId in o.keys()) { + val types = o.opt(jmapAccountId) as? JSONObject + out[jmapAccountId] = types?.keys()?.asSequence()?.toSet() + } + return out.takeIf { entries -> entries.values.any { it == null || it.isNotEmpty() } } + } + + /** + * `{ jmapAccountId: [{ accountName, authorities }] }` as JS stores it. + * [strict] refuses anything malformed (IllegalArgumentException); otherwise + * malformed entries are skipped, so a bad route never stops mail. + */ + fun parseRoutes(json: String?, strict: Boolean = false): Map> { + if (json.isNullOrEmpty()) return emptyMap() + fun bad(message: String): Nothing = throw IllegalArgumentException("Push routes: $message") + val o = try { + JSONObject(json) + } catch (e: JSONException) { + if (strict) bad("not a JSON object") else return emptyMap() + } + val out = LinkedHashMap>() + for (jmapAccountId in o.keys()) { + val entries = o.opt(jmapAccountId) as? JSONArray + ?: if (strict) bad("$jmapAccountId must map to an array") else continue + val routes = ArrayList() + for (i in 0 until entries.length()) { + val entry = entries.opt(i) as? JSONObject + val name = entry?.opt("accountName") as? String + val authorities = entry?.opt("authorities") as? JSONArray + if (name.isNullOrEmpty() || authorities == null) { + if (strict) bad("$jmapAccountId[$i] needs accountName and authorities") else continue + } + val known = (0 until authorities.length()).mapNotNull { j -> + val authority = authorities.opt(j) as? String + if (authority !in DeviceSyncAccounts.AUTHORITIES) { + if (strict) bad("$jmapAccountId[$i]: unknown authority $authority") else null + } else { + authority + } + } + routes += Route(name, known.toSet()) + } + out[jmapAccountId] = routes + } + return out + } +} + +/** + * Applies [PushRouting] to incoming pushes (BulwarkMessagingService, + * BulwarkUnifiedPushService) with the routes JS stored through + * `setPushRoutes`. + */ +object DeviceSyncPushRouter { + private const val TAG = "BulwarkDeviceSync" + private const val PREFS = "bulwark_device_sync" + private const val KEY_ROUTES = "pushRoutes" + + /** Validates and stores the routes; they must survive the process, pushes arrive without JS. */ + fun saveRoutes(context: Context, json: String) { + PushRouting.parseRoutes(json, strict = true) + val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_ROUTES, json) + .commit() + check(saved) { "Push routes could not be saved" } + } + + /** + * Requests the syncs the push asks for, with constant (empty) extras so + * repeated pushes coalesce into one pending sync. Returns whether the mail + * push task should run; any failure here says yes, so mail never depends + * on device sync. + */ + fun route(context: Context, data: Map): Boolean = try { + val routes = PushRouting.parseRoutes( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ROUTES, null), + ) + val decision = PushRouting.decide(data, routes) + for ((accountName, authority) in decision.syncs) { + try { + DeviceSyncAccounts.requestSync(context, accountName, authority, SyncRequestFlags()) + } catch (e: RuntimeException) { + Log.w(TAG, "Push: no sync requested for $authority", e) + } + } + decision.startMailTask + } catch (e: Exception) { + Log.w(TAG, "Push not routed", e) + true + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncProblemNotifier.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncProblemNotifier.kt new file mode 100644 index 00000000..d76a88fc --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncProblemNotifier.kt @@ -0,0 +1,75 @@ +package com.anonymous.bulwarkmobile.sync + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import com.anonymous.bulwarkmobile.MainActivity +import com.anonymous.bulwarkmobile.R + +/** + * The one notification per Android account that device sync needs the user + * for (#34), e.g. "Sign in again" after the server rejected the credentials. + * The engine posts it through `showSyncProblem`; the app clears it once the + * problem is solved. Tapping it opens a `bulwarkmobile://` deep link in the + * app. + */ +object SyncProblemNotifier { + const val CHANNEL_ID = "bulwark_sync" + private const val DEFAULT_CHANNEL_NAME = "Sync problems" + private const val TAG_PREFIX = "bulwark-sync:" + private const val NOTIFICATION_ID = 1 + private const val DEEP_LINK_SCHEME = "bulwarkmobile" + + /** + * Creates the channel, or renames it: JS passes the name in the app's + * language. Nothing urgent, so default importance and no vibration. + */ + fun ensureChannel(context: Context, name: String?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + name?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { enableVibration(false) } + manager.createNotificationChannel(channel) + } + + fun show(context: Context, accountName: String, title: String, text: String, uri: String, channelName: String?) { + val link = Uri.parse(uri) + require(link.scheme == DEEP_LINK_SCHEME) { "Only $DEEP_LINK_SCHEME:// links open from a sync notification" } + ensureChannel(context, channelName) + val tag = TAG_PREFIX + accountName + // Explicit, so no other app can answer the tap. + val intent = Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val pending = PendingIntent.getActivity( + context, + tag.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + // Runs repeat the same problem until it is solved; say it once. + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setContentIntent(pending) + .build() + context.getSystemService(NotificationManager::class.java).notify(tag, NOTIFICATION_ID, notification) + } + + fun clear(context: Context, accountName: String) { + context.getSystemService(NotificationManager::class.java).cancel(TAG_PREFIX + accountName, NOTIFICATION_ID) + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt new file mode 100644 index 00000000..37e6dc38 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/sync/SyncReports.kt @@ -0,0 +1,112 @@ +package com.anonymous.bulwarkmobile.sync + +import android.content.SyncResult +import org.json.JSONException +import org.json.JSONObject + +/** + * What a run's report (src/device-sync/types.ts `RunReport`) asks of the + * framework's SyncResult. Kept free of Android classes so JVM tests cover it; + * [SyncReports.apply] copies it onto the real SyncResult. + * + * SyncManager treats io errors as soft (retry with back-off), auth errors, + * database errors and too many deletions as hard (no retry until something + * changes), and `fullSyncRequested` as "schedule another sync now". + */ +data class SyncOutcome( + val ioErrors: Long = 0, + val authErrors: Long = 0, + val databaseError: Boolean = false, + val tooManyDeletions: Boolean = false, + val inserts: Long = 0, + val updates: Long = 0, + val deletes: Long = 0, + val entries: Long = 0, + val skipped: Long = 0, + /** Epoch seconds; 0 when unset. */ + val delayUntil: Long = 0, + val fullSyncRequested: Boolean = false, +) + +object SyncReports { + /** + * Translates a report. [reportJson] is null when the run ended without + * one ([endReason] says why: JS never answered, React failed to start, …), + * which is a soft error so the framework retries. + */ + fun outcome(reportJson: String?, endReason: String?): SyncOutcome { + if (reportJson == null) return SyncOutcome(ioErrors = 1) + val report = try { + JSONObject(reportJson) + } catch (e: JSONException) { + return SyncOutcome(ioErrors = 1) + } + val stats = report.optJSONObject("stats") + val down = stats?.optJSONObject("downloaded") + val up = stats?.optJSONObject("uploaded") + fun count(side: JSONObject?, key: String) = side?.optLong(key, 0) ?: 0 + val base = SyncOutcome( + inserts = count(down, "created") + count(up, "created"), + updates = count(down, "updated") + count(up, "updated"), + deletes = count(down, "deleted") + count(up, "deleted"), + entries = stats?.optLong("entries", 0) ?: 0, + skipped = stats?.optLong("skipped", 0) ?: 0, + delayUntil = report.optLong("delayUntil", 0), + fullSyncRequested = report.optBoolean("moreRecordsToGet", false), + ) + // SyncManager retries a failed sync at once, without back-off, when it + // made progress (inserts, updates, or deletes without + // tooManyDeletions). A hard error must therefore report none, or an + // auth failure after some work would loop. + val hard = base.copy(inserts = 0, updates = 0, deletes = 0) + return when (report.optString("outcome")) { + "ok", "cancelled", "disabled" -> base + "auth" -> hard.copy(authErrors = 1) + "permission", "unsupported", "safetyAbort" -> hard.copy(databaseError = true) + "tooManyDeletions" -> hard.copy( + tooManyDeletions = true, + // Shown in the system's "too many deletions" notification. + deletes = report.optJSONObject("tooManyDeletions")?.optLong("count", 0) ?: 0, + ) + // "io", "internal" and anything a newer JS might send: retry, at + // once if some work got done, else with back-off. + else -> base.copy(ioErrors = 1) + } + } + + /** + * One line for logcat: the report's outcome, its message and how many + * items failed. The reason a run failed is otherwise only in the report, + * and JS warnings from a headless run don't reach logcat. + */ + fun summary(reportJson: String?): String { + if (reportJson == null) return "no report" + val report = try { + JSONObject(reportJson) + } catch (e: JSONException) { + return "unreadable report" + } + val parts = mutableListOf("outcome=" + report.optString("outcome", "?")) + val items = report.optJSONArray("itemErrors")?.length() ?: 0 + if (items > 0) parts += "itemErrors=$items" + val message = report.optString("message", "") + if (message.isNotEmpty()) parts += "message=" + message.take(MAX_LOGGED_MESSAGE) + return parts.joinToString(" ") + } + + private const val MAX_LOGGED_MESSAGE = 300 + + fun apply(outcome: SyncOutcome, result: SyncResult) { + result.stats.numIoExceptions += outcome.ioErrors + result.stats.numAuthExceptions += outcome.authErrors + result.stats.numInserts += outcome.inserts + result.stats.numUpdates += outcome.updates + result.stats.numDeletes += outcome.deletes + result.stats.numEntries += outcome.entries + result.stats.numSkippedEntries += outcome.skipped + if (outcome.databaseError) result.databaseError = true + if (outcome.tooManyDeletions) result.tooManyDeletions = true + if (outcome.delayUntil > 0) result.delayUntil = outcome.delayUntil + if (outcome.fullSyncRequested) result.fullSyncRequested = true + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/widget/WidgetProviders.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/widget/WidgetProviders.kt new file mode 100644 index 00000000..3a4efe0b --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/widget/WidgetProviders.kt @@ -0,0 +1,40 @@ +// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. +// react-native-android-widget names a widget after its provider class, so each +// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx) +// renders by that name. +package com.anonymous.bulwarkmobile.widget + +import com.reactnativeandroidwidget.RNWidgetProvider + +class InboxWidget : RNWidgetProvider() +class TriageWidget : RNWidgetProvider() +class UnreadCountWidget : RNWidgetProvider() +class LatestMessageWidget : RNWidgetProvider() +class FolderCountsWidget : RNWidgetProvider() +class MailShortcutsWidget : RNWidgetProvider() +class AllAccountsWidget : RNWidgetProvider() +class StarredWidget : RNWidgetProvider() +class FavouritePeopleWidget : RNWidgetProvider() +class MailSearchWidget : RNWidgetProvider() +class OutboxWidget : RNWidgetProvider() +class TagWidget : RNWidgetProvider() +class LastDraftWidget : RNWidgetProvider() +class AgendaWidget : RNWidgetProvider() +class WeekWidget : RNWidgetProvider() +class MonthWidget : RNWidgetProvider() +class TodayEventsWidget : RNWidgetProvider() +class NextEventWidget : RNWidgetProvider() +class DateNextWidget : RNWidgetProvider() +class UpNextWidget : RNWidgetProvider() +class InvitationsWidget : RNWidgetProvider() +class FreeTimeWidget : RNWidgetProvider() +class TasksWidget : RNWidgetProvider() +class TaskProgressWidget : RNWidgetProvider() +class CountdownWidget : RNWidgetProvider() +class BirthdaysWidget : RNWidgetProvider() +class TodayHubWidget : RNWidgetProvider() +class MailAndNextWidget : RNWidgetProvider() +class RecentFilesWidget : RNWidgetProvider() +class AttachmentsWidget : RNWidgetProvider() +class VacationWidget : RNWidgetProvider() +class StorageWidget : RNWidgetProvider() diff --git a/android/app/src/main/res/drawable-nodpi/agendawidget_preview.png b/android/app/src/main/res/drawable-nodpi/agendawidget_preview.png new file mode 100644 index 00000000..dcf4490c Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/agendawidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/allaccountswidget_preview.png b/android/app/src/main/res/drawable-nodpi/allaccountswidget_preview.png new file mode 100644 index 00000000..ad872a13 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/allaccountswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/attachmentswidget_preview.png b/android/app/src/main/res/drawable-nodpi/attachmentswidget_preview.png new file mode 100644 index 00000000..9ac6ab4c Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/attachmentswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/birthdayswidget_preview.png b/android/app/src/main/res/drawable-nodpi/birthdayswidget_preview.png new file mode 100644 index 00000000..b3364e92 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/birthdayswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/countdownwidget_preview.png b/android/app/src/main/res/drawable-nodpi/countdownwidget_preview.png new file mode 100644 index 00000000..f6e4a9d1 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/countdownwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/datenextwidget_preview.png b/android/app/src/main/res/drawable-nodpi/datenextwidget_preview.png new file mode 100644 index 00000000..b5d3798a Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/datenextwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/favouritepeoplewidget_preview.png b/android/app/src/main/res/drawable-nodpi/favouritepeoplewidget_preview.png new file mode 100644 index 00000000..dfe1a104 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/favouritepeoplewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/foldercountswidget_preview.png b/android/app/src/main/res/drawable-nodpi/foldercountswidget_preview.png new file mode 100644 index 00000000..22cbd733 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/foldercountswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/freetimewidget_preview.png b/android/app/src/main/res/drawable-nodpi/freetimewidget_preview.png new file mode 100644 index 00000000..a54e4716 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/freetimewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/inboxwidget_preview.png b/android/app/src/main/res/drawable-nodpi/inboxwidget_preview.png new file mode 100644 index 00000000..f4aa76b4 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/inboxwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/invitationswidget_preview.png b/android/app/src/main/res/drawable-nodpi/invitationswidget_preview.png new file mode 100644 index 00000000..159262a5 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/invitationswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/lastdraftwidget_preview.png b/android/app/src/main/res/drawable-nodpi/lastdraftwidget_preview.png new file mode 100644 index 00000000..2b41b338 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/lastdraftwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/latestmessagewidget_preview.png b/android/app/src/main/res/drawable-nodpi/latestmessagewidget_preview.png new file mode 100644 index 00000000..da746e30 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/latestmessagewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/mailandnextwidget_preview.png b/android/app/src/main/res/drawable-nodpi/mailandnextwidget_preview.png new file mode 100644 index 00000000..0fe25017 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/mailandnextwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/mailsearchwidget_preview.png b/android/app/src/main/res/drawable-nodpi/mailsearchwidget_preview.png new file mode 100644 index 00000000..a021ad13 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/mailsearchwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/mailshortcutswidget_preview.png b/android/app/src/main/res/drawable-nodpi/mailshortcutswidget_preview.png new file mode 100644 index 00000000..6ff6bc1d Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/mailshortcutswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/monthwidget_preview.png b/android/app/src/main/res/drawable-nodpi/monthwidget_preview.png new file mode 100644 index 00000000..28e5e7b9 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/monthwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/nexteventwidget_preview.png b/android/app/src/main/res/drawable-nodpi/nexteventwidget_preview.png new file mode 100644 index 00000000..6dcdbc34 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/nexteventwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/outboxwidget_preview.png b/android/app/src/main/res/drawable-nodpi/outboxwidget_preview.png new file mode 100644 index 00000000..e85e69b1 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/outboxwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/recentfileswidget_preview.png b/android/app/src/main/res/drawable-nodpi/recentfileswidget_preview.png new file mode 100644 index 00000000..c829630c Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/recentfileswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/starredwidget_preview.png b/android/app/src/main/res/drawable-nodpi/starredwidget_preview.png new file mode 100644 index 00000000..2c28d67f Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/starredwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/storagewidget_preview.png b/android/app/src/main/res/drawable-nodpi/storagewidget_preview.png new file mode 100644 index 00000000..0d4f0730 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/storagewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/tagwidget_preview.png b/android/app/src/main/res/drawable-nodpi/tagwidget_preview.png new file mode 100644 index 00000000..f899c935 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/tagwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/taskprogresswidget_preview.png b/android/app/src/main/res/drawable-nodpi/taskprogresswidget_preview.png new file mode 100644 index 00000000..18ac9df8 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/taskprogresswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/taskswidget_preview.png b/android/app/src/main/res/drawable-nodpi/taskswidget_preview.png new file mode 100644 index 00000000..3eddbd81 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/taskswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/todayeventswidget_preview.png b/android/app/src/main/res/drawable-nodpi/todayeventswidget_preview.png new file mode 100644 index 00000000..b10d1f5e Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/todayeventswidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/todayhubwidget_preview.png b/android/app/src/main/res/drawable-nodpi/todayhubwidget_preview.png new file mode 100644 index 00000000..4e9437ab Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/todayhubwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/triagewidget_preview.png b/android/app/src/main/res/drawable-nodpi/triagewidget_preview.png new file mode 100644 index 00000000..865bb9fa Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/triagewidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/unreadcountwidget_preview.png b/android/app/src/main/res/drawable-nodpi/unreadcountwidget_preview.png new file mode 100644 index 00000000..92c9a232 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/unreadcountwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/upnextwidget_preview.png b/android/app/src/main/res/drawable-nodpi/upnextwidget_preview.png new file mode 100644 index 00000000..701f482f Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/upnextwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/vacationwidget_preview.png b/android/app/src/main/res/drawable-nodpi/vacationwidget_preview.png new file mode 100644 index 00000000..0e927ce5 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/vacationwidget_preview.png differ diff --git a/android/app/src/main/res/drawable-nodpi/weekwidget_preview.png b/android/app/src/main/res/drawable-nodpi/weekwidget_preview.png new file mode 100644 index 00000000..b2a611f7 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/weekwidget_preview.png differ diff --git a/android/app/src/main/res/drawable/widget_frame.xml b/android/app/src/main/res/drawable/widget_frame.xml new file mode 100644 index 00000000..1e0d98a9 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_frame.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_surface.xml b/android/app/src/main/res/drawable/widget_surface.xml new file mode 100644 index 00000000..730f8463 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_surface.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/layout-night/rn_widget.xml b/android/app/src/main/res/layout-night/rn_widget.xml new file mode 100644 index 00000000..0eb7803b --- /dev/null +++ b/android/app/src/main/res/layout-night/rn_widget.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/rn_widget.xml b/android/app/src/main/res/layout/rn_widget.xml new file mode 100644 index 00000000..2d50ffec --- /dev/null +++ b/android/app/src/main/res/layout/rn_widget.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 6a1ea921..4a812495 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index e03a563e..034164b2 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 6a1ea921..bd97bb52 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 1f78c2d8..e939dbd6 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index c5968390..78d9efb2 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 1f78c2d8..55970bea 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index c7c47470..47e468c5 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 54c58844..bdb98696 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index c7c47470..f74824f2 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 55f5baa6..1b5f4e37 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 968dd171..7013bc58 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 55f5baa6..f1578a3a 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index bc273aec..8118d3de 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index 8ed62d71..22d1b65f 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index bc273aec..f7f1541a 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 8c8b16ea..32088aa9 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -3,4 +3,6 @@ #09090b #09090b + + #0a0a0a \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index a8383caf..0643cabc 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,9 +1,13 @@ #ffffff - #09090b + #db2d54 #023c69 #09090b #ffffff + + #ffffff + + #4d808080 \ No newline at end of file diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..eb80d179 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 16dp + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f7c8e036..3e363054 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ Bulwark Mobile contain false + + Bulwark \ No newline at end of file diff --git a/android/app/src/main/res/values/widget_strings.xml b/android/app/src/main/res/values/widget_strings.xml new file mode 100644 index 00000000..c8011188 --- /dev/null +++ b/android/app/src/main/res/values/widget_strings.xml @@ -0,0 +1,68 @@ + + + + Inbox + Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview. + Triage + One unread message at a time with archive, delete and reply buttons. + Unread count + Unread count for the inbox, with the latest senders. + Latest message + Only the newest unread message. + Folder counts + Unread counts for up to four folders. + Shortcuts + Compose, calendar, contacts and files. + All accounts + The unified inbox across every signed-in account. + Starred + Starred messages, newest first. + Favourite people + The people you hear from most, with their unread count. + Search mail + Search bar with your recent searches. + Scheduled and outbox + Scheduled messages and anything waiting in the outbox. + Tag + Unread count for your busiest tag. + Last draft + Back into the draft you left. + Agenda + Upcoming events by day with time, colour and place. + This week + The week at a glance from 8:00 to 18:00. + Month + Month grid with a dot per calendar and the next two events. + Today + Today\'s date with the rest of today and tomorrow. + Next event + What\'s next, when and where. + Date and next two + Today\'s date with the next two events. + Up next + Countdown to the next event with join and running-late buttons. + Invitations + Pending invitations with accept, maybe and decline. + Free time + When you\'re free this week. + Tasks + Open tasks with tick boxes that work from the home screen. + Task progress + How many tasks are done. + Countdown + Days until your next all-day event. + Birthdays + Upcoming birthdays from your contacts. + Today overview + Next event, newest unread mail and tasks due, in one widget. + Mail and next event + Unread mail on one side, the next event on the other. + Recent files + Recently changed items in Files. + Recent attachments + The newest attachments from your mail. + Vacation responder + Vacation responder status with an on/off switch. + Storage + How much of your mailbox quota is used. + diff --git a/android/app/src/main/res/xml/authenticator.xml b/android/app/src/main/res/xml/authenticator.xml new file mode 100644 index 00000000..ee861b32 --- /dev/null +++ b/android/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/xml/contacts.xml b/android/app/src/main/res/xml/contacts.xml new file mode 100644 index 00000000..1aff029f --- /dev/null +++ b/android/app/src/main/res/xml/contacts.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..1d2747a4 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/xml/sync_calendar.xml b/android/app/src/main/res/xml/sync_calendar.xml new file mode 100644 index 00000000..9c5c8d15 --- /dev/null +++ b/android/app/src/main/res/xml/sync_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/xml/sync_contacts.xml b/android/app/src/main/res/xml/sync_contacts.xml new file mode 100644 index 00000000..25e25fbc --- /dev/null +++ b/android/app/src/main/res/xml/sync_contacts.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/xml/widgetprovider_agendawidget.xml b/android/app/src/main/res/xml/widgetprovider_agendawidget.xml new file mode 100644 index 00000000..dbe5aaa1 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_agendawidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_allaccountswidget.xml b/android/app/src/main/res/xml/widgetprovider_allaccountswidget.xml new file mode 100644 index 00000000..6a7f0518 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_allaccountswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_attachmentswidget.xml b/android/app/src/main/res/xml/widgetprovider_attachmentswidget.xml new file mode 100644 index 00000000..8a5a3539 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_attachmentswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_birthdayswidget.xml b/android/app/src/main/res/xml/widgetprovider_birthdayswidget.xml new file mode 100644 index 00000000..050009b7 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_birthdayswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_countdownwidget.xml b/android/app/src/main/res/xml/widgetprovider_countdownwidget.xml new file mode 100644 index 00000000..cb6f174f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_countdownwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_datenextwidget.xml b/android/app/src/main/res/xml/widgetprovider_datenextwidget.xml new file mode 100644 index 00000000..2c97b57e --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_datenextwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_favouritepeoplewidget.xml b/android/app/src/main/res/xml/widgetprovider_favouritepeoplewidget.xml new file mode 100644 index 00000000..5195a43f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_favouritepeoplewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_foldercountswidget.xml b/android/app/src/main/res/xml/widgetprovider_foldercountswidget.xml new file mode 100644 index 00000000..d6acc336 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_foldercountswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_freetimewidget.xml b/android/app/src/main/res/xml/widgetprovider_freetimewidget.xml new file mode 100644 index 00000000..dfb312ec --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_freetimewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_inboxwidget.xml b/android/app/src/main/res/xml/widgetprovider_inboxwidget.xml new file mode 100644 index 00000000..5602c37a --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_inboxwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_invitationswidget.xml b/android/app/src/main/res/xml/widgetprovider_invitationswidget.xml new file mode 100644 index 00000000..8a19f0f2 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_invitationswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_lastdraftwidget.xml b/android/app/src/main/res/xml/widgetprovider_lastdraftwidget.xml new file mode 100644 index 00000000..d6868790 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_lastdraftwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_latestmessagewidget.xml b/android/app/src/main/res/xml/widgetprovider_latestmessagewidget.xml new file mode 100644 index 00000000..b3f29e33 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_latestmessagewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_mailandnextwidget.xml b/android/app/src/main/res/xml/widgetprovider_mailandnextwidget.xml new file mode 100644 index 00000000..5e36edbe --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_mailandnextwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_mailsearchwidget.xml b/android/app/src/main/res/xml/widgetprovider_mailsearchwidget.xml new file mode 100644 index 00000000..7fb3e15e --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_mailsearchwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_mailshortcutswidget.xml b/android/app/src/main/res/xml/widgetprovider_mailshortcutswidget.xml new file mode 100644 index 00000000..d0dbca8f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_mailshortcutswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_monthwidget.xml b/android/app/src/main/res/xml/widgetprovider_monthwidget.xml new file mode 100644 index 00000000..0e298659 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_monthwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_nexteventwidget.xml b/android/app/src/main/res/xml/widgetprovider_nexteventwidget.xml new file mode 100644 index 00000000..6f6a8e8c --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_nexteventwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_outboxwidget.xml b/android/app/src/main/res/xml/widgetprovider_outboxwidget.xml new file mode 100644 index 00000000..8635b1df --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_outboxwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_recentfileswidget.xml b/android/app/src/main/res/xml/widgetprovider_recentfileswidget.xml new file mode 100644 index 00000000..afa8712b --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_recentfileswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_starredwidget.xml b/android/app/src/main/res/xml/widgetprovider_starredwidget.xml new file mode 100644 index 00000000..a69ef5d7 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_starredwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_storagewidget.xml b/android/app/src/main/res/xml/widgetprovider_storagewidget.xml new file mode 100644 index 00000000..98fd3fd9 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_storagewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_tagwidget.xml b/android/app/src/main/res/xml/widgetprovider_tagwidget.xml new file mode 100644 index 00000000..4e9275f6 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_tagwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_taskprogresswidget.xml b/android/app/src/main/res/xml/widgetprovider_taskprogresswidget.xml new file mode 100644 index 00000000..3a15f164 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_taskprogresswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_taskswidget.xml b/android/app/src/main/res/xml/widgetprovider_taskswidget.xml new file mode 100644 index 00000000..ecb6a9a4 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_taskswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_todayeventswidget.xml b/android/app/src/main/res/xml/widgetprovider_todayeventswidget.xml new file mode 100644 index 00000000..961a96be --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_todayeventswidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_todayhubwidget.xml b/android/app/src/main/res/xml/widgetprovider_todayhubwidget.xml new file mode 100644 index 00000000..c053e67a --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_todayhubwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_triagewidget.xml b/android/app/src/main/res/xml/widgetprovider_triagewidget.xml new file mode 100644 index 00000000..0d731a32 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_triagewidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_unreadcountwidget.xml b/android/app/src/main/res/xml/widgetprovider_unreadcountwidget.xml new file mode 100644 index 00000000..c39dcd8d --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_unreadcountwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_upnextwidget.xml b/android/app/src/main/res/xml/widgetprovider_upnextwidget.xml new file mode 100644 index 00000000..cc956c56 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_upnextwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_vacationwidget.xml b/android/app/src/main/res/xml/widgetprovider_vacationwidget.xml new file mode 100644 index 00000000..18a0ee3f --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_vacationwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/main/res/xml/widgetprovider_weekwidget.xml b/android/app/src/main/res/xml/widgetprovider_weekwidget.xml new file mode 100644 index 00000000..6541f415 --- /dev/null +++ b/android/app/src/main/res/xml/widgetprovider_weekwidget.xml @@ -0,0 +1,16 @@ + + + + diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccountsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccountsTest.kt new file mode 100644 index 00000000..abbaa92e --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncAccountsTest.kt @@ -0,0 +1,18 @@ +package com.anonymous.bulwarkmobile.sync + +import com.anonymous.bulwarkmobile.sync.DeviceSyncAccounts.EnsureAction +import org.junit.Assert.assertEquals +import org.junit.Test + +class DeviceSyncAccountsTest { + @Test + fun `ensureAccount creates, keeps, adopts or refuses`() { + assertEquals(EnsureAction.CREATE, DeviceSyncAccounts.ensureAction(false, null, "alice@example.org@mail")) + assertEquals(EnsureAction.KEEP, DeviceSyncAccounts.ensureAction(true, "alice@example.org@mail", "alice@example.org@mail")) + // An account whose setup never wrote a registry id. + assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, null, "alice@example.org@mail")) + assertEquals(EnsureAction.ADOPT, DeviceSyncAccounts.ensureAction(true, "", "alice@example.org@mail")) + // Its rows and SyncState belong to another app account: never rewritten. + assertEquals(EnsureAction.CONFLICT, DeviceSyncAccounts.ensureAction(true, "alice@example.org@old", "alice@example.org@mail")) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRunsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRunsTest.kt new file mode 100644 index 00000000..dbdfc040 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/DeviceSyncRunsTest.kt @@ -0,0 +1,62 @@ +package com.anonymous.bulwarkmobile.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeviceSyncRunsTest { + private val contacts = "com.android.contacts" + private val calendar = "com.android.calendar" + + @Test + fun `a report is accepted once, while the adapter waits`() { + val run = DeviceSyncRuns.open("alice", contacts) + assertTrue(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}")) + assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"io\"}")) + assertFalse(run.end("no report before the deadline")) + assertEquals(0L, run.done.count) + assertEquals("{\"outcome\":\"ok\"}", run.report) + assertEquals("reported", run.endReason) + DeviceSyncRuns.close(run) + } + + @Test + fun `a report after the run ended is refused whole`() { + val run = DeviceSyncRuns.open("alice", contacts) + assertTrue(run.end("no report before the deadline")) + assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}")) + assertNull(run.report) + assertEquals("no report before the deadline", run.endReason) + DeviceSyncRuns.close(run) + assertFalse(DeviceSyncRuns.finish(run.runId, "{\"outcome\":\"ok\"}")) + } + + @Test + fun `runs nobody waits for, or that ended, count as cancelled`() { + assertTrue(DeviceSyncRuns.isCancelled("unknown")) + val run = DeviceSyncRuns.open("alice", calendar) + assertFalse(DeviceSyncRuns.isCancelled(run.runId)) + run.end("task finished") + assertTrue(DeviceSyncRuns.isCancelled(run.runId)) + DeviceSyncRuns.close(run) + assertTrue(DeviceSyncRuns.isCancelled(run.runId)) + } + + @Test + fun `a sync requested during a run flags that run only, and the flag outlives the close`() { + val mine = DeviceSyncRuns.open("alice", contacts) + val otherAuthority = DeviceSyncRuns.open("alice", calendar) + val otherAccount = DeviceSyncRuns.open("bob", contacts) + assertTrue(DeviceSyncRuns.flagSyncAgain("alice", contacts)) + DeviceSyncRuns.close(mine) + assertTrue(mine.syncAgainRequested) + assertFalse(otherAuthority.syncAgainRequested) + assertFalse(otherAccount.syncAgainRequested) + // Closed runs are not flagged any more. + assertFalse(DeviceSyncRuns.flagSyncAgain("alice", contacts)) + DeviceSyncRuns.close(otherAuthority) + DeviceSyncRuns.close(otherAccount) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt new file mode 100644 index 00000000..2d1f6930 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/ProviderOpsTest.kt @@ -0,0 +1,562 @@ +package com.anonymous.bulwarkmobile.sync + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.math.BigDecimal +import java.math.BigInteger + +private const val CONTACTS = "com.android.contacts" +private const val CALENDAR = "com.android.calendar" +private val ME = AccountRef("alice@example.org", "com.anonymous.bulwarkmobile.account") +private val OTHER = AccountRef("bob@gmail.com", "com.google") + +/** The provider as far as the planner asks: who owns which row; [me] is the account the lookup is bound to. */ +private class FakeLookup(private val me: AccountRef = ME) : ScopeLookup { + val rawContacts = HashMap() + val groups = HashMap() + val calendars = HashMap() + val colors = HashMap() + /** data id → raw contact */ + val data = HashMap() + /** data id → mimetype */ + val mimetypes = HashMap() + /** event id → calendar */ + val events = HashMap() + /** child table → child id → event */ + val children = HashMap>() + /** (child table, where) → events of the matching rows; a null where matches every row. */ + val matching = HashMap, Set>() + + override fun ownCalendarIds() = calendars.filterValues { it == me }.keys + + override fun rootAccounts(table: ProviderTable, ids: Set): Map = when (table) { + ProviderTable.RAW_CONTACTS -> rawContacts + ProviderTable.GROUPS -> groups + ProviderTable.CALENDARS -> calendars + ProviderTable.COLORS -> colors + else -> throw AssertionError("no root lookup for $table") + }.filterKeys { it in ids } + + override fun dataRawContacts(ids: Set) = data.filterKeys { it in ids } + + override fun dataMimetypes(ids: Set) = mimetypes.filterKeys { it in ids } + + override fun eventCalendars(ids: Set) = events.filterKeys { it in ids } + + override fun childEvents(table: ProviderTable, ids: Set): Map = + children[table].orEmpty().filterKeys { it in ids } + + override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List): Set = + matching[table to where] ?: if (where == null) children[table].orEmpty().values.toSet() else emptySet() + + fun child(table: ProviderTable, id: Long, event: Long) { + children.getOrPut(table) { HashMap() }[id] = event + } +} + +class ProviderOpsTest { + private val lookup = FakeLookup().apply { + rawContacts[1] = ME + rawContacts[2] = OTHER + groups[10] = ME + groups[11] = OTHER + data[100] = 1 + data[101] = 2 + calendars[20] = ME + calendars[21] = ME + calendars[22] = OTHER + colors[40] = ME + events[30] = 20 + events[31] = 21 + events[32] = 22 + child(ProviderTable.ATTENDEES, 50, 30) + child(ProviderTable.ATTENDEES, 51, 32) + child(ProviderTable.REMINDERS, 60, 31) + child(ProviderTable.EXTENDED_PROPERTIES, 70, 30) + child(ProviderTable.EXTENDED_PROPERTIES, 71, 32) + } + private val planner = ScopePlanner(ME, lookup) + + private fun ops(vararg op: String) = "[${op.joinToString(",")}]" + + private fun plan(authority: String, vararg op: String) = + planner.planBatch(ProviderOpParser.parseBatch(authority, ops(*op))) + + private fun refused(reason: BatchFailure, block: () -> Unit): String { + try { + block() + } catch (e: ProviderRefusal) { + assertEquals(e.message, reason, e.reason) + return e.message.orEmpty() + } + fail("expected a $reason refusal") + throw AssertionError() + } + + // ── tables and parsing ────────────────────────────────────── + + @Test + fun `tables are enumerated per authority and anything else is out of scope`() { + assertEquals(ProviderTable.EXTENDED_PROPERTIES, ProviderTable.of(CALENDAR, "extended_properties")) + assertEquals(ProviderTable.SETTINGS, ProviderTable.of(CONTACTS, "settings")) + refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "events") } + refused(BatchFailure.SCOPE) { ProviderTable.of(CALENDAR, "raw_contacts") } + refused(BatchFailure.SCOPE) { ProviderTable.of(CONTACTS, "contacts") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"instances","where":"1"}""") } + } + + @Test + fun `numbers keep integers exact whatever org_json parsed them into`() { + assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5)) + assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(5.0)) + assertEquals(Cell.Integer(5), ProviderOpParser.numberCell(BigDecimal("5.0"))) + assertEquals(Cell.Integer(1L shl 60), ProviderOpParser.numberCell(BigInteger.ONE.shiftLeft(60))) + assertEquals(Cell.Integer(1_700_000_000_000), ProviderOpParser.numberCell(1_700_000_000_000L)) + assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(BigDecimal("1.5"))) + assertEquals(Cell.Real(1.5), ProviderOpParser.numberCell(1.5)) + } + + @Test + fun `values keep text, numbers, booleans as 0 and 1, and nulls`() { + val insert = ProviderOpParser.parseBatch( + CONTACTS, + ops("""{"op":"insert","table":"raw_contacts","values":{"sync1":"a","dirty":0,"starred":true,"sync2":null}}"""), + ).single() as ProviderOp.Insert + assertEquals( + mapOf("sync1" to Cell.Text("a"), "dirty" to Cell.Integer(0), "starred" to Cell.Integer(1), "sync2" to Cell.Null), + insert.values, + ) + } + + @Test + fun `malformed ops are provider failures`() { + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, "{}") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"upsert","table":"groups","values":{}}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{}}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"assert","table":"groups","where":"dirty = 1"}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"settings","id":1}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","args":["x"]}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","id":1.5}""") } + refused(BatchFailure.PROVIDER) { plan(CONTACTS, """{"op":"delete","table":"groups","where":"a = ?","args":[null]}""") } + } + + @Test + fun `bytes are only accepted for data_data15`() { + val planned = plan( + CONTACTS, + """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo","data15":{"b64":"AAEC"}}}""", + ).single() as PlannedOp.Insert + assertEquals(Cell.Blob("AAEC"), planned.values["data15"]) + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"update","table":"raw_contacts","id":1,"values":{"sync2":{"b64":"AAEC"}}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data1":{"b64":"AA"}}}""") + } + refused(BatchFailure.PROVIDER) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"data15":{"bytes":"AA"}}}""") + } + } + + // ── batch rules ───────────────────────────────────────────── + + @Test + fun `a syncState op must be the last op and not a yield point`() { + val planned = plan( + CONTACTS, + """{"op":"update","table":"groups","id":10,"values":{"sync2":"x"},"yieldAllowed":true}""", + """{"op":"syncState","value":"{\"v\":1}"}""", + ) + assertEquals(PlannedOp.SetSyncState("{\"v\":1}"), planned.last()) + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"syncState","value":"{}"}""", """{"op":"delete","table":"groups","id":10}""") + } + refused(BatchFailure.SCOPE) { + plan( + CONTACTS, + """{"op":"delete","table":"groups","id":10}""", + """{"op":"syncState","value":"{}","yieldAllowed":true}""", + ) + } + } + + private fun deletes(count: Int, yieldAt: Set = emptySet()) = (0 until count).joinToString(",", "[", "]") { i -> + """{"op":"delete","table":"groups","where":"sync1 = ?","args":["$i"]${if (i in yieldAt) ""","yieldAllowed":true""" else ""}}""" + } + + @Test + fun `contacts batches stop at 499 ops between yield points, counted as ContactsProvider does`() { + assertEquals(499, ProviderOpParser.parseBatch(CONTACTS, deletes(499)).size) + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500)) } + // The yielding op still counts toward the ops before it. + assertEquals(998, ProviderOpParser.parseBatch(CONTACTS, deletes(998, setOf(498))).size) + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(999, setOf(498))) } + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(600, setOf(499))) } + // A yield on the first op is ignored, as the provider ignores it. + refused(BatchFailure.PROVIDER) { ProviderOpParser.parseBatch(CONTACTS, deletes(500, setOf(0))) } + } + + @Test + fun `calendar batches have no yield point limit`() { + val many = (0 until 600).joinToString(",", "[", "]") { """{"op":"delete","table":"colors","where":"color_index = '$it'"}""" } + assertEquals(600, ProviderOpParser.parseBatch(CALENDAR, many).size) + } + + @Test + fun `back-references name earlier inserts, and parents name their parent table`() { + val planned = plan( + CONTACTS, + """{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1"}}""", + """{"op":"insert","table":"data","values":{"mimetype":"vnd.android.cursor.item/name","data1":"A"},"refs":{"raw_contact_id":0}}""", + ) + assertEquals(mapOf("raw_contact_id" to 0), (planned[1] as PlannedOp.Insert).backReferences) + refused(BatchFailure.PROVIDER) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""") + } + refused(BatchFailure.PROVIDER) { + plan( + CONTACTS, + """{"op":"delete","table":"groups","id":10}""", + """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""", + ) + } + // Settings rows have no id to refer to. + refused(BatchFailure.PROVIDER) { + plan( + CONTACTS, + """{"op":"insert","table":"settings","values":{"ungrouped_visible":1}}""", + """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""", + ) + } + refused(BatchFailure.SCOPE) { + plan( + CONTACTS, + """{"op":"insert","table":"groups","values":{"title":"g"}}""", + """{"op":"insert","table":"data","values":{"mimetype":"x"},"refs":{"raw_contact_id":0}}""", + ) + } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"mimetype":"x"}}""") } + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":null,"attendeeEmail":"a@b"}}""") + } + } + + // ── scope: inserts ────────────────────────────────────────── + + @Test + fun `root inserts are written into the account, whatever the op says`() { + val planned = plan( + CONTACTS, + """{"op":"insert","table":"raw_contacts","values":{"sourceid":"c/1","account_name":"alice@example.org"}}""", + ).single() as PlannedOp.Insert + assertEquals(Cell.Text(ME.name), planned.values["account_name"]) + assertEquals(Cell.Text(ME.type), planned.values["account_type"]) + val calendar = plan(CALENDAR, """{"op":"insert","table":"calendars","values":{"name":"c"}}""").single() + assertEquals(Cell.Text(ME.type), (calendar as PlannedOp.Insert).values["account_type"]) + } + + @Test + fun `account columns never move a row out of the account`() { + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"raw_contacts","values":{"account_name":"bob@gmail.com"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"update","table":"groups","id":10,"values":{"account_type":"com.google"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"groups","values":{"title":"g","data_set":"plugin"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"account_name":"alice@example.org"}}""") + } + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"account_name":"alice@example.org"}}""") + } + } + + @Test + fun `inserts must name a parent of the account that still exists`() { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x"}}""") + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"1","mimetype":"x"}}""") + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":2,"mimetype":"x"}}""") } + refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":9,"mimetype":"x"}}""") } + refused(BatchFailure.PROVIDER) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":"me","mimetype":"x"}}""") + } + plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":21,"dtstart":0}}""") + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":22,"dtstart":0}}""") } + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"insert","table":"events","values":{"calendar_id":20,"original_id":32}}""") + } + plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":31,"minutes":10}}""") + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"insert","table":"reminders","values":{"event_id":32,"minutes":10}}""") } + refused(BatchFailure.ASSERT) { plan(CALENDAR, """{"op":"insert","table":"attendees","values":{"event_id":99}}""") } + } + + @Test + fun `a membership row may only name a group of the account`() { + val membership = """"mimetype":"vnd.android.cursor.item/group_membership"""" + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":10}}""") + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"group_sourceid":"c/g"}}""") + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,$membership,"data1":11}}""") + } + // data1 of other kinds is plain data. + plan(CONTACTS, """{"op":"insert","table":"data","values":{"raw_contact_id":1,"mimetype":"x","data1":11}}""") + } + + @Test + fun `a membership row's group is checked on updates by id too`() { + lookup.mimetypes[100] = GROUP_MEMBERSHIP_MIMETYPE + plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":10}}""") + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""") } + refused(BatchFailure.ASSERT) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":12}}""") } + // data1 by a selection could reach memberships the lookup never saw. + refused(BatchFailure.SCOPE) { + plan(CONTACTS, """{"op":"update","table":"data","where":"raw_contact_id = ?","args":[1],"values":{"data1":11}}""") + } + // data1 of other kinds is plain data. + lookup.mimetypes[100] = "vnd.android.cursor.item/phone_v2" + plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":11}}""") + } + + @Test + fun `photo file ids are never written`() { + val photo = """"raw_contact_id":1,"mimetype":"vnd.android.cursor.item/photo"""" + plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":null,"data15":{"b64":"AAAA"}}}""") + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"insert","table":"data","values":{$photo,"data14":5}}""") } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data14":5}}""") } + } + + @Test + fun `a selection can't close the scope's parentheses or query elsewhere`() { + for (where in listOf("1) OR (1", "dirty = 1) OR (1 = 1", "(1", "1; DELETE FROM events", "1 -- x", "1 /* x */", + "_id IN (SELECT _id FROM events)", "1 UNION ALL x", "title = 'open")) { + val quoted = JSONObject.quote(where) + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","where":$quoted,"values":{"dirty":1}}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","where":$quoted}""") } + refused(BatchFailure.SCOPE) { + ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":$quoted}""") + } + } + refused(BatchFailure.SCOPE) { + ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id); DROP TABLE x; --"}""") + } + // Parentheses and keywords inside quoted literals are data. + plan(CALENDAR, """{"op":"update","table":"events","where":"title = '(x' OR title = 'select'","values":{"dirty":1}}""") + plan(CALENDAR, """{"op":"assert","table":"events","where":"(dirty = 1 OR deleted = 1) AND _sync_id LIKE '~pending/%'","expectCount":0}""") + ProviderOpParser.parseQuery(CONTACTS, """{"table":"raw_contacts","columns":["_id"],"orderBy":"_id DESC"}""") + } + + @Test + fun `moving a row to another parent is checked like an insert`() { + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":22}}""") } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"raw_contact_id":null}}""") } + plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"calendar_id":21}}""") + } + + // ── scope: selections ─────────────────────────────────────── + + @Test + fun `root tables are selected by their account columns`() { + val op = plan( + CONTACTS, + """{"op":"update","table":"raw_contacts","where":"dirty = ? AND deleted = 0","args":[1],"values":{"dirty":0}}""", + ).single() as PlannedOp.Update + assertEquals( + Selection("(dirty = ? AND deleted = 0) AND (account_name = ? AND account_type = ?)", listOf("1", ME.name, ME.type)), + op.selection, + ) + assertNull(op.expectedCount) + } + + @Test + fun `an op by id selects that row of the account and expects exactly one row`() { + val op = plan(CONTACTS, """{"op":"delete","table":"groups","id":10}""").single() as PlannedOp.Delete + assertEquals(Selection("(_id = ?) AND (account_name = ? AND account_type = ?)", listOf("10", ME.name, ME.type)), op.selection) + assertEquals(1, op.expectedCount) + val counted = plan(CONTACTS, """{"op":"assert","table":"raw_contacts","id":1,"expectCount":0}""").single() + assertEquals(0, (counted as PlannedOp.Assert).expectedCount) + } + + @Test + fun `an id of another account is refused, a vanished one is left to the expected count`() { + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":2}""") } + refused(BatchFailure.SCOPE) { plan(CONTACTS, """{"op":"update","table":"data","id":101,"values":{"data1":"x"}}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"assert","table":"events","id":32,"values":{"dirty":0}}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"delete","table":"attendees","id":51}""") } + refused(BatchFailure.SCOPE) { plan(CALENDAR, """{"op":"update","table":"calendars","id":22,"values":{"name":"x"}}""") } + val gone = plan(CONTACTS, """{"op":"delete","table":"raw_contacts","id":9}""").single() as PlannedOp.Delete + assertEquals(1, gone.expectedCount) + } + + @Test + fun `data rows rely on the URI parameters and never select on the Data view's account columns`() { + val op = plan(CONTACTS, """{"op":"update","table":"data","id":100,"values":{"data1":"x"}}""").single() as PlannedOp.Update + assertEquals(Selection("(_id = ?)", listOf("100")), op.selection) + val query = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"],"where":"raw_contact_id IN (1)"}""")) + assertEquals(listOf(Selection("(raw_contact_id IN (1))", emptyList())), query.selections) + val all = planner.planQuery(ProviderOpParser.parseQuery(CONTACTS, """{"table":"data","columns":["_id"]}""")) + assertEquals(listOf(Selection.NONE), all.selections) + } + + @Test + fun `events are selected by the account's calendars`() { + val query = planner.planQuery( + ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id","title"],"where":"dirty = 1","orderBy":"_id"}"""), + ) + assertEquals(listOf(Selection("(dirty = 1) AND (calendar_id IN (20,21))", emptyList())), query.selections) + assertEquals("_id", query.orderBy) + val update = plan(CALENDAR, """{"op":"update","table":"events","id":30,"values":{"dirty":0}}""").single() as PlannedOp.Update + assertEquals(Selection("(_id = ?) AND (calendar_id IN (20,21))", listOf("30")), update.selection) + } + + @Test + fun `an account without calendars selects no events`() { + val carol = AccountRef("carol", ME.type) + val empty = ScopePlanner(carol, FakeLookup(carol).apply { calendars.putAll(lookup.calendars) }) + val query = empty.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"events","columns":["_id"]}""")) + assertEquals(listOf(Selection("(0)", emptyList())), query.selections) + } + + @Test + fun `children of events are selected by the account's events among those they touch`() { + lookup.matching[ProviderTable.ATTENDEES to "event_id IN (30,32)"] = setOf(30, 32) + val query = planner.planQuery( + ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["attendeeEmail"],"where":"event_id IN (30,32)"}"""), + ) + assertEquals(listOf(Selection("(event_id IN (30,32)) AND (event_id IN (30))", emptyList())), query.selections) + + val byId = plan(CALENDAR, """{"op":"delete","table":"attendees","id":50}""").single() as PlannedOp.Delete + assertEquals(Selection("(Attendees._id = ?) AND (event_id IN (30))", listOf("50")), byId.selection) + assertEquals(1, byId.expectedCount) + + val gone = plan(CALENDAR, """{"op":"delete","table":"reminders","id":69}""").single() as PlannedOp.Delete + assertEquals(Selection("(Reminders._id = ?) AND (0)", listOf("69")), gone.selection) + + lookup.matching[ProviderTable.REMINDERS to "event_id = ?"] = setOf(31) + val byWhere = plan(CALENDAR, """{"op":"delete","table":"reminders","where":"event_id = ?","args":[31]}""").single() + assertEquals(Selection("(event_id = ?) AND (event_id IN (31))", listOf("31")), (byWhere as PlannedOp.Delete).selection) + } + + @Test + fun `a child query that touches none of the account's events reads nothing`() { + lookup.matching[ProviderTable.ATTENDEES to "event_id = 32"] = setOf(32) + val query = planner.planQuery( + ProviderOpParser.parseQuery(CALENDAR, """{"table":"attendees","columns":["_id"],"where":"event_id = 32"}"""), + ) + assertTrue(query.selections.isEmpty()) + } + + @Test + fun `long event lists are split into chunks`() { + val many = (1000L until 1000L + 1200).toSet() + for (id in many) lookup.events[id] = 20 + lookup.matching[ProviderTable.REMINDERS to null] = many + val query = planner.planQuery(ProviderOpParser.parseQuery(CALENDAR, """{"table":"reminders","columns":["minutes"]}""")) + assertEquals(3, query.selections.size) + assertTrue(query.selections.all { it.sql!!.startsWith("(event_id IN (") }) + } + + @Test + fun `extended properties are updated through their item URI, so only by id`() { + val op = plan(CALENDAR, """{"op":"update","table":"extended_properties","id":70,"values":{"value":"v"}}""").single() + assertEquals(PlannedOp.Update(ProviderTable.EXTENDED_PROPERTIES, Selection.NONE, 70, mapOf("value" to Cell.Text("v")), 1, false), op) + refused(BatchFailure.SCOPE) { + plan(CALENDAR, """{"op":"update","table":"extended_properties","id":71,"values":{"value":"v"}}""") + } + refused(BatchFailure.PROVIDER) { + plan(CALENDAR, """{"op":"update","table":"extended_properties","where":"name = 'x'","values":{"value":"v"}}""") + } + } + + @Test + fun `asserts compare text and keep nulls`() { + val op = plan( + CONTACTS, + """{"op":"assert","table":"raw_contacts","id":1,"values":{"version":3,"dirty":0,"sync3":null,"sourceid":"c/1"}}""", + ).single() as PlannedOp.Assert + assertEquals(mapOf("version" to "3", "dirty" to "0", "sync3" to null, "sourceid" to "c/1"), op.values) + assertEquals(1, op.expectedCount) + } + + @Test + fun `settings are written by account`() { + val insert = plan(CONTACTS, """{"op":"insert","table":"settings","values":{"ungrouped_visible":1,"should_sync":1}}""").single() + assertEquals(Cell.Text(ME.name), (insert as PlannedOp.Insert).values["account_name"]) + val update = plan(CONTACTS, """{"op":"update","table":"settings","values":{"ungrouped_visible":1}}""").single() + assertEquals( + Selection("(account_name = ? AND account_type = ?)", listOf(ME.name, ME.type)), + (update as PlannedOp.Update).selection, + ) + } + + // ── results ───────────────────────────────────────────────── + + @Test + fun `batch results are BatchResult JSON`() { + val ok = JSONObject(ProviderResults.ok(listOf(OpOutcome(id = 7), OpOutcome(count = 1), OpOutcome()))) + assertEquals(true, ok.getBoolean("ok")) + val results = ok.getJSONArray("results") + assertEquals(7L, results.getJSONObject(0).getLong("id")) + assertEquals(1, results.getJSONObject(1).getInt("count")) + assertEquals(0, results.getJSONObject(2).length()) + val failed = JSONObject(ProviderResults.failed(BatchFailure.TOO_LARGE, "TransactionTooLargeException")) + assertEquals(false, failed.getBoolean("ok")) + assertEquals("tooLarge", failed.getString("reason")) + assertEquals("TransactionTooLargeException", failed.getString("message")) + } + + @Test + fun `an OperationApplicationException is an assert unless an insert got no row`() { + assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Found value 4 when expected 3 for column version")) + assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("Expected 1 rows but actual 0")) + assertEquals(BatchFailure.ASSERT, ProviderResults.failureOfOperationApplication("wrong number of rows: 0")) + assertEquals( + BatchFailure.PROVIDER, + ProviderResults.failureOfOperationApplication("Insert into content://com.android.contacts/settings returned no result"), + ) + assertEquals( + BatchFailure.PROVIDER, + ProviderResults.failureOfOperationApplication("Too many content provider operations between yield points."), + ) + } + + @Test + fun `query rows are ProviderRows JSON with escaped text`() { + val writer = ProviderRowsWriter(listOf("_id", "title", "x")) + writer.beginRow() + writer.longCell(9_007_199_254_740_991) + writer.textCell("a \"quoted\"\\ line\nbreak\u0001 and \u2028") + writer.nullCell() + writer.endRow() + writer.beginRow() + writer.longCell(2) + writer.doubleCell(1.5) + writer.doubleCell(Double.NaN) + writer.endRow() + val parsed = JSONObject(writer.finish()) + assertEquals(JSONArray(listOf("_id", "title", "x")).toString(), parsed.getJSONArray("columns").toString()) + val rows = parsed.getJSONArray("rows") + assertEquals(9_007_199_254_740_991L, rows.getJSONArray(0).getLong(0)) + assertEquals("a \"quoted\"\\ line\nbreak\u0001 and \u2028", rows.getJSONArray(0).getString(1)) + assertTrue(rows.getJSONArray(0).isNull(2)) + assertEquals(1.5, rows.getJSONArray(1).getDouble(1), 0.0) + assertTrue(rows.getJSONArray(1).isNull(2)) + assertEquals("{\"columns\":[],\"rows\":[]}", ProviderRowsWriter(emptyList()).finish()) + } + + @Test + fun `photos decode close to the target size and scale their longer side down to it`() { + assertEquals(1, PhotoScaling.sampleSize(720, 720, 512)) + assertEquals(4, PhotoScaling.sampleSize(4000, 3000, 512)) + assertEquals(512 to 384, PhotoScaling.targetSize(1000, 750, 512)) + assertEquals(96 to 96, PhotoScaling.targetSize(96, 96, 512)) + assertEquals(1 to 512, PhotoScaling.targetSize(3, 4000, 512)) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt new file mode 100644 index 00000000..db2d5b53 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/PushRoutingTest.kt @@ -0,0 +1,192 @@ +package com.anonymous.bulwarkmobile.sync + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PushRoutingTest { + private val contacts = "com.android.contacts" + private val calendar = "com.android.calendar" + + private val routes = PushRouting.parseRoutes( + """ + { + "c": [{ "accountName": "alice@example.org", "authorities": ["$contacts", "$calendar"] }], + "team": [ + { "accountName": "alice@example.org", "authorities": ["$contacts"] }, + { "accountName": "alice-work", "authorities": ["$calendar"] } + ] + } + """.trimIndent(), + strict = true, + ) + + private fun stateChange(changed: String) = mapOf( + "kind" to "jmap-state-change", + "accountLabel" to "alice", + "accountId" to "c", + "emailIds" to "[]", + "changed" to changed, + ) + + @Test + fun `a contact change requests a contacts sync and no mail task`() { + val decision = PushRouting.decide(stateChange("""{"c":{"ContactCard":"s1"}}"""), routes) + assertEquals(setOf("alice@example.org" to contacts), decision.syncs) + assertFalse(decision.startMailTask) + } + + @Test + fun `calendar types request calendar syncs of the routed accounts only`() { + val decision = PushRouting.decide(stateChange("""{"team":{"Calendar":"s1","CalendarEvent":"s2"}}"""), routes) + assertEquals(setOf("alice-work" to calendar), decision.syncs) + assertFalse(decision.startMailTask) + } + + @Test + fun `every account key is routed, not only the one repeated as accountId`() { + val decision = PushRouting.decide( + stateChange("""{"c":{"AddressBook":"s1"},"team":{"ContactCard":"s2","CalendarEvent":"s3"}}"""), + routes, + ) + // alice@example.org's contacts sync is requested once. + assertEquals(setOf("alice@example.org" to contacts, "alice-work" to calendar), decision.syncs) + } + + @Test + fun `mail anywhere in the map starts the mail task, next to the syncs`() { + val decision = PushRouting.decide( + stateChange("""{"team":{"CalendarEvent":"s1"},"c":{"EmailDelivery":"s2","Email":"s3"}}"""), + routes, + ) + assertTrue(decision.startMailTask) + assertEquals(setOf("alice-work" to calendar), decision.syncs) + } + + @Test + fun `an Email or Mailbox change is possible mail, as a subscription from an older build sends it`() { + for (changed in listOf( + """{"c":{"Email":"s1","Mailbox":"s2"}}""", + """{"c":{"Email":"s1"}}""", + """{"c":{"Mailbox":"s1"}}""", + )) { + val decision = PushRouting.decide(stateChange(changed), routes) + assertTrue("changed=$changed", decision.startMailTask) + assertTrue("changed=$changed", decision.syncs.isEmpty()) + } + val mixed = PushRouting.decide(stateChange("""{"team":{"CalendarEvent":"e1"},"c":{"Email":"s1"}}"""), routes) + assertTrue(mixed.startMailTask) + assertEquals(setOf("alice-work" to calendar), mixed.syncs) + // Types that are neither mail nor device sync say nothing about mail. + val other = PushRouting.decide(stateChange("""{"c":{"Thread":"t1","ContactCard":"s1"}}"""), routes) + assertFalse(other.startMailTask) + assertEquals(setOf("alice@example.org" to contacts), other.syncs) + } + + @Test + fun `an email push always starts the mail task`() { + val data = mapOf("kind" to "jmap-email-push", "accountId" to "c", "changed" to """{"c":{"EmailDelivery":"s"}}""") + assertTrue(PushRouting.decide(data, routes).startMailTask) + assertTrue(PushRouting.decide(mapOf("kind" to "jmap-email-push"), routes).startMailTask) + } + + @Test + fun `payloads without a readable map are legacy mail pushes`() { + for (changed in listOf(null, "", "{}", "not json", "[1]", """{"c":{}}""")) { + val data = stateChange(changed ?: "").let { if (changed == null) it - "changed" else it } + val decision = PushRouting.decide(data, routes) + assertTrue("changed=$changed", decision.startMailTask) + assertTrue("changed=$changed", decision.syncs.isEmpty()) + } + } + + @Test + fun `an unreadable account entry may be mail`() { + val decision = PushRouting.decide(stateChange("""{"c":"garbled","team":{"ContactCard":"s"}}"""), routes) + assertTrue(decision.startMailTask) + assertEquals(setOf("alice@example.org" to contacts), decision.syncs) + } + + @Test + fun `reads a push the same way as the push task`() { + // kind (null: none), changed (null: none) → whether the mail task starts. The + // same table as MAIL_CASES in src/lib/__tests__/push-background-task.test.ts, + // whose carriesNoMail must agree. + val state = "jmap-state-change" + val emailPush = "jmap-email-push" + val cases: List> = listOf( + // No readable map: as before device sync. + Triple(state, null, true), + Triple(state, "", true), + Triple(state, "not json", true), + Triple(state, "[1]", true), + Triple(state, "{}", true), + // A map that names no type is read like a missing one. + Triple(state, """{"c":{}}""", true), + Triple(state, """{"c":{},"team":{}}""", true), + // An account entry that can't be read may be mail. + Triple(state, """{"c":null}""", true), + Triple(state, """{"c":"garbled","team":{"ContactCard":"s"}}""", true), + Triple(state, """{"c":[],"team":{"ContactCard":"s"}}""", true), + Triple(state, """{"c":5}""", true), + // Mail types anywhere in the map. + Triple(state, """{"c":{"EmailDelivery":"s"}}""", true), + Triple(state, """{"c":{"Email":"s"}}""", true), + Triple(state, """{"c":{"Mailbox":"s"}}""", true), + Triple(state, """{"c":{"ContactCard":"s1"},"team":{"EmailDelivery":"s2"}}""", true), + // Only a StateChange can say it carries no mail. + Triple(null, """{"c":{"ContactCard":"s"}}""", true), + Triple(emailPush, """{"c":{"EmailDelivery":"s"}}""", true), + Triple(emailPush, null, true), + // Contact and calendar changes, and types that say nothing about mail. + Triple(state, """{"c":{"ContactCard":"s1","AddressBook":"s2"}}""", false), + Triple(state, """{"c":{"CalendarEvent":"e1"},"team":{"Calendar":"c1"}}""", false), + Triple(state, """{"c":{"ContactCard":"s"},"team":{}}""", false), + Triple(state, """{"elsewhere":{"ContactCard":"s"}}""", false), + Triple(state, """{"c":{"Thread":"t1","ContactCard":"s1"}}""", false), + ) + for ((kind, changed, mail) in cases) { + val data = buildMap { + if (kind != null) put("kind", kind) + put("accountLabel", "alice") + put("accountId", "c") + put("emailIds", "[]") + if (changed != null) put("changed", changed) + } + assertEquals("kind=$kind changed=$changed", mail, PushRouting.decide(data, routes).startMailTask) + } + } + + @Test + fun `accounts without routes are ignored`() { + val decision = PushRouting.decide(stateChange("""{"elsewhere":{"ContactCard":"s"}}"""), routes) + assertTrue(decision.syncs.isEmpty()) + assertFalse(decision.startMailTask) + assertTrue(PushRouting.decide(stateChange("""{"c":{"ContactCard":"s"}}"""), emptyMap()).syncs.isEmpty()) + } + + @Test + fun `stored routes are validated strictly and read leniently`() { + val bad = listOf( + "[]", + """{"c":{}}""", + """{"c":[{"authorities":["$contacts"]}]}""", + """{"c":[{"accountName":"a","authorities":["com.example.tasks"]}]}""", + ) + for (json in bad) { + try { + PushRouting.parseRoutes(json, strict = true) + throw AssertionError("accepted $json") + } catch (e: IllegalArgumentException) { + // expected + } + } + assertEquals(emptyMap>(), PushRouting.parseRoutes("[]")) + assertEquals( + mapOf("c" to listOf(PushRouting.Route("a", setOf(contacts)))), + PushRouting.parseRoutes("""{"c":[{"accountName":"a","authorities":["$contacts","com.example.tasks"]},{"x":1}]}"""), + ) + assertEquals(emptyMap>(), PushRouting.parseRoutes(null)) + } +} diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt new file mode 100644 index 00000000..3594a338 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/sync/SyncReportsTest.kt @@ -0,0 +1,99 @@ +package com.anonymous.bulwarkmobile.sync + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SyncReportsTest { + private fun report(outcome: String, extra: String = "") = """ + {"v":1,"runId":"r","authority":"com.android.contacts","outcome":"$outcome", + "startedAt":0,"durationMs":5,"conflicts":0,"itemErrors":[], + "stats":{"downloaded":{"created":3,"updated":2,"deleted":1}, + "uploaded":{"created":1,"updated":4,"deleted":2},"entries":13,"skipped":1}$extra} + """.trimIndent() + + @Test + fun `a run without a report is a soft error`() { + assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome(null, "task finished")) + } + + @Test + fun `an unreadable report is a soft error`() { + assertEquals(SyncOutcome(ioErrors = 1), SyncReports.outcome("{not json", "reported")) + } + + @Test + fun `ok sums both directions into the framework's stats`() { + assertEquals( + SyncOutcome(inserts = 4, updates = 6, deletes = 3, entries = 13, skipped = 1), + SyncReports.outcome(report("ok"), "reported"), + ) + } + + @Test + fun `io keeps the progress so the framework retries at once`() { + val outcome = SyncReports.outcome(report("io"), "reported") + assertEquals(1L, outcome.ioErrors) + assertEquals(4L, outcome.inserts) + } + + @Test + fun `auth is hard and reports no progress, or the framework would retry it at once`() { + assertEquals( + SyncOutcome(authErrors = 1, entries = 13, skipped = 1), + SyncReports.outcome(report("auth"), "reported"), + ) + } + + @Test + fun `permission, unsupported and safety aborts are database errors without progress`() { + for (outcome in listOf("permission", "unsupported", "safetyAbort")) { + assertEquals( + outcome, + SyncOutcome(databaseError = true, entries = 13, skipped = 1), + SyncReports.outcome(report(outcome), "reported"), + ) + } + } + + @Test + fun `too many deletions reports the pending deletions for the system notification`() { + assertEquals( + SyncOutcome(tooManyDeletions = true, deletes = 42, entries = 13, skipped = 1), + SyncReports.outcome(report("tooManyDeletions", ""","tooManyDeletions":{"count":42,"threshold":20}"""), "reported"), + ) + } + + @Test + fun `delayUntil and moreRecordsToGet reach the framework`() { + val outcome = SyncReports.outcome(report("ok", ""","delayUntil":1790000000,"moreRecordsToGet":true"""), "reported") + assertEquals(1790000000L, outcome.delayUntil) + assertEquals(true, outcome.fullSyncRequested) + } + + @Test + fun `cancelled and disabled runs are not errors`() { + for (outcome in listOf("cancelled", "disabled")) { + assertEquals(0L, SyncReports.outcome(report(outcome), "reported").ioErrors) + } + } + + @Test + fun `an outcome this build does not know is retried`() { + assertEquals(1L, SyncReports.outcome(report("somethingNew"), "reported").ioErrors) + } + + @Test + fun `the log summary names the outcome, failed items and the message`() { + assertEquals("outcome=ok", SyncReports.summary(report("ok"))) + assertEquals( + "outcome=internal message=Invalid column x", + SyncReports.summary(report("internal", ",\"message\":\"Invalid column x\"")), + ) + val withItems = report("ok").replace("\"itemErrors\":[]", "\"itemErrors\":[{\"ref\":\"a\"},{\"ref\":\"b\"}]") + assertEquals("outcome=ok itemErrors=2", SyncReports.summary(withItems)) + assertEquals("no report", SyncReports.summary(null)) + assertEquals("unreadable report", SyncReports.summary("{")) + val long = SyncReports.summary(report("io", ",\"message\":\"" + "m".repeat(400) + "\"")) + assertEquals("outcome=io message=".length + 300, long.length) + } +} diff --git a/app.config.js b/app.config.js index 5e8015db..2e8dbadf 100644 --- a/app.config.js +++ b/app.config.js @@ -22,14 +22,23 @@ COMMIT = COMMIT.slice(0, 7); // fall back to 1 (never uploaded). const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1'; +// Apple team: prefixes the keychain groups the app shares with its +// notification service extension (plugins/ios-push). Not a secret. +const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39'; +// Which APNs host the device tokens belong to: TestFlight and App Store builds +// use production, simulators and development builds the sandbox. +const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production'; + module.exports = { expo: { name: 'Bulwark Mobile', slug: 'bulwark-mobile', - // mailto: lets Android/iOS offer the app for mail links in other apps. - scheme: ['bulwarkmobile', 'mailto'], + // mailto: lets Android/iOS offer the app for mail links in other apps; + // bulwarkmail: is the webmail's sign-in link (bulwarkmail://pair?...). + // Android's manifest is maintained by hand and lists the same schemes. + scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'], version: VERSION, - orientation: 'portrait', + orientation: 'default', icon: './assets/icon.png', userInterfaceStyle: 'automatic', newArchEnabled: true, @@ -56,7 +65,7 @@ module.exports = { android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png', - backgroundColor: '#09090b', + backgroundColor: '#db2d54', }, edgeToEdgeEnabled: true, predictiveBackGestureEnabled: false, @@ -94,9 +103,17 @@ module.exports = { microphonePermission: false, }, ], + ['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }], ], extra: { commit: COMMIT, + // Settings → About → Source code. A modified build sets SOURCE_CODE_URL + // to where its own source is (AGPL §13); the default is upstream. + sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native', + // src/lib/ios-push-shared.ts + iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`, + iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`, + apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production', }, }, }; diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png index 18065877..70d3bc43 100644 Binary files a/assets/adaptive-icon.png and b/assets/adaptive-icon.png differ diff --git a/assets/favicon.png b/assets/favicon.png index c9244d56..b636fb67 100644 Binary files a/assets/favicon.png and b/assets/favicon.png differ diff --git a/assets/icon.png b/assets/icon.png index c2ee6729..5af9445e 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/babel.config.js b/babel.config.js index 755e6f71..6ab2f75a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -10,6 +10,5 @@ module.exports = function babelConfig(api) { api.cache(true); return { presets: [presetExpo], - plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')], }; }; diff --git a/docs/android-release.md b/docs/android-release.md index 3e5769e3..7edc99ea 100644 --- a/docs/android-release.md +++ b/docs/android-release.md @@ -3,7 +3,8 @@ The release APK is built by [.github/workflows/release-android.yml](../.github/workflows/release-android.yml) on an Ubuntu runner whenever a GitHub release is published, and attached to that -release. Installed apps pick it up through the in-app updater. +release. Installed apps pick it up through the in-app updater. The same workflow +builds the Google Play bundle; see [Google Play](#google-play). Every APK up to and including 0.1.62 was signed with `android/app/debug.keystore`, the debug key that ships with the React Native template. Its password is @@ -199,6 +200,44 @@ Once you delete it, every APK is signed with the release key alone: the update. Those users have to uninstall, reinstall and sign in again, and they lose local data. Afterwards their devices trust only the release key. +## Google Play + +The app has two product flavors (`android/app/build.gradle`). They install +side by side as separate apps: + +| Flavor | Package | Output | Updated by | Firebase project | +|---|---|---|---|---| +| `github` | `com.anonymous.bulwarkmobile` | APK on GitHub Releases | in-app updater | `bulwark-mobile` (`src/github/google-services.json`) | +| `play` | `org.bulwarkmail.mobile` | AAB for Google Play | Google Play | `bulwark-20c72` (`google-services.json`) | + +Play forbids an app updating itself outside Play, so the `play` flavor has no +`REQUEST_INSTALL_PACKAGES` (only `src/github/AndroidManifest.xml` declares it) +and `BuildConfig.DISTRIBUTION` is `"play"`, which the `BulwarkBuild` native +module hands to JS. `supportsSideloadUpdates` in `src/lib/platform-capabilities.ts` +is false there, which hides the updater. Neither flavor declares +`READ_MEDIA_IMAGES`: photos come from the system photo picker, and Play's photo +and video policy rejects the permission for that use. `RECORD_AUDIO`, which +expo-camera adds, is removed as well: QR scanning never records audio. + +Play App Signing holds the key that signs what Play delivers (generated by +Google, certificate SHA-256 `7e:e1:fd:35:…:91:fa:e3`). The workflow signs the +bundle with our release key, which Play registered as the upload key with the +first upload. The two flavors therefore have different signers too. + +The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and +x86_64 (Play splits the bundle per ABI), checks the signer, the package name and +the permissions, and keeps the AAB and its R8 mapping as a workflow artifact. +With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service +account bulwark-google-play-api") it also uploads the bundle and rolls it out +on the internal and closed (beta) testing tracks, so testers get every release +without a step in the Play Console; closed testing may wait for Google's +review. Play only accepts API uploads once a first bundle has been uploaded by +hand in the Play Console. + +The `github` flavor stays on the old Firebase project until the app registers +its new FCM token with the relay when the token changes; moving it before that +would silence push for every updated install. + ## Local release builds CI signs releases, so you normally don't need the release key on your machine. @@ -206,7 +245,8 @@ For a local release build, for profiling or checking R8, opt in to the debug key ```bash cd android -./gradlew assembleRelease -PallowDebugSignedRelease=true +./gradlew assembleGithubRelease -PallowDebugSignedRelease=true +./gradlew bundlePlayRelease -PallowDebugSignedRelease=true ``` ```powershell @@ -252,7 +292,7 @@ retrace bulwark-mobile-0.1.63-abc1234-mapping.txt stacktrace.txt `retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`). A local build writes its mapping to -`android/app/build/outputs/mapping/release/mapping.txt`. +`android/app/build/outputs/mapping/Release/mapping.txt`. ## versionCode diff --git a/docs/audit-2026-09.md b/docs/audit-2026-09.md index 0cfcdc48..2c0ccc98 100644 --- a/docs/audit-2026-09.md +++ b/docs/audit-2026-09.md @@ -1020,7 +1020,8 @@ ones moved to the N/A lists, and the wrong text corrected. | #46 email preview shows CSS | Not reproduced: HTML-only mail with `${renderBlocks(lines)}`; +} diff --git a/src/lib/modal-orientations.ts b/src/lib/modal-orientations.ts new file mode 100644 index 00000000..aa54e5aa --- /dev/null +++ b/src/lib/modal-orientations.ts @@ -0,0 +1,14 @@ +import type { ModalProps } from 'react-native'; + +/** + * Every RN `` passes this. Without it iOS presents a modal in portrait + * only, which rotates an iPad (or a landscape iPhone) back to portrait while + * the modal is open. + */ +export const MODAL_ORIENTATIONS: NonNullable = [ + 'portrait', + 'portrait-upside-down', + 'landscape', + 'landscape-left', + 'landscape-right', +]; diff --git a/src/lib/notification-mutes.ts b/src/lib/notification-mutes.ts new file mode 100644 index 00000000..90580b56 --- /dev/null +++ b/src/lib/notification-mutes.ts @@ -0,0 +1,104 @@ +/** + * Which accounts and folders stay silent when new mail arrives (#78, + * webmail #1169). Port of the webmail's lib/notification-mutes.ts - keep in sync. + * + * An account is either muted or not. A folder can follow its account or carry + * its own setting, and its own setting always wins: mute one folder in a + * normal account, or mute a whole account and unmute the folders that still + * matter. Ids are the server's JMAP account and mailbox ids, which is what the + * push filter needs; shared-account mailboxes must pass their originalId. + */ + +export interface NotificationMutes { + /** JMAP account ids muted as a whole. */ + accounts: string[]; + /** `accountId/mailboxId` -> true (muted) or false (unmuted). Missing = follow the account. */ + mailboxes: Record; +} + +export const EMPTY_NOTIFICATION_MUTES: NotificationMutes = { accounts: [], mailboxes: {} }; + +function mailboxKey(accountId: string, mailboxId: string): string { + return `${accountId}/${mailboxId}`; +} + +/** Repairs a value from storage or another build into a usable mutes object. */ +export function normalizeNotificationMutes(value: unknown): NotificationMutes { + if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_NOTIFICATION_MUTES; + const raw = value as { accounts?: unknown; mailboxes?: unknown }; + const accounts = Array.isArray(raw.accounts) + ? raw.accounts.filter((id): id is string => typeof id === 'string' && id !== '') + : []; + const mailboxes: Record = {}; + if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) { + for (const [key, muted] of Object.entries(raw.mailboxes as Record)) { + if (typeof muted === 'boolean') mailboxes[key] = muted; + } + } + return { accounts, mailboxes }; +} + +export function isAccountMuted(mutes: NotificationMutes, accountId: string): boolean { + return mutes.accounts.includes(accountId); +} + +/** The folder's own setting, or undefined when it follows its account. */ +export function mailboxMuteOverride( + mutes: NotificationMutes, + accountId: string, + mailboxId: string, +): boolean | undefined { + return mutes.mailboxes[mailboxKey(accountId, mailboxId)]; +} + +export function isMailboxMuted(mutes: NotificationMutes, accountId: string, mailboxId: string): boolean { + return mailboxMuteOverride(mutes, accountId, mailboxId) ?? isAccountMuted(mutes, accountId); +} + +/** Mailbox ids of one account with their own setting, split by that setting. */ +export function mailboxOverridesForAccount( + mutes: NotificationMutes, + accountId: string, +): { muted: string[]; unmuted: string[] } { + const prefix = mailboxKey(accountId, ''); + const muted: string[] = []; + const unmuted: string[] = []; + for (const [key, value] of Object.entries(mutes.mailboxes)) { + if (!key.startsWith(prefix)) continue; + (value ? muted : unmuted).push(key.slice(prefix.length)); + } + return { muted, unmuted }; +} + +export function setAccountMuted(mutes: NotificationMutes, accountId: string, muted: boolean): NotificationMutes { + const accounts = mutes.accounts.filter((id) => id !== accountId); + if (muted) accounts.push(accountId); + return { ...mutes, accounts }; +} + +/** + * Mutes or unmutes one folder. Setting it back to what the account says drops + * the folder's own setting, so a folder muted and unmuted again follows the + * account later instead of staying pinned to "unmuted". + */ +export function setMailboxMuted( + mutes: NotificationMutes, + accountId: string, + mailboxId: string, + muted: boolean, +): NotificationMutes { + const mailboxes = { ...mutes.mailboxes }; + const key = mailboxKey(accountId, mailboxId); + if (muted === isAccountMuted(mutes, accountId)) delete mailboxes[key]; + else mailboxes[key] = muted; + return { ...mutes, mailboxes }; +} + +/** Drops every folder setting of one account so all its folders follow the account again. */ +export function resetMailboxMutes(mutes: NotificationMutes, accountId: string): NotificationMutes { + const prefix = mailboxKey(accountId, ''); + const mailboxes = Object.fromEntries( + Object.entries(mutes.mailboxes).filter(([key]) => !key.startsWith(prefix)), + ); + return { ...mutes, mailboxes }; +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index ccf0c92e..a31db07c 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -19,9 +19,12 @@ export interface OAuthTokens { expiresAt?: number; // epoch ms tokenEndpoint: string; clientId: string; - // Where the bundle came from. A paired phone shares the desktop's refresh - // token, so revoking it on sign-out would also sign the desktop out; - // every other source is safe to revoke. + // Where the bundle came from. A paired phone gets its own grant: the + // webmail mints a separate one for the device rather than handing over + // the desktop's session. Its refresh token may be an opaque string only + // the webmail's token proxy understands, and webmail up to 1.11 did share + // the desktop's token, so sign-out leaves `pairing` bundles alone instead + // of revoking them at the mail server; every other source is revoked. source?: OAuthTokenSource; } @@ -56,6 +59,44 @@ export class TransientRefreshError extends Error { } } +// Why a QR / link sign-in failed, so the login screen can say what to do next +// instead of one catch-all "expired" message. +// expired / used / expired_or_used / invalid: the code itself (webmail up to +// 1.11 does not say which, hence `expired_or_used`) +// unsupported: no redeem route at that address (old webmail, wrong link) +// untrusted / insecure: the answer names a token endpoint or server the app +// will not send credentials to +// network / server / rate_limited / bad_response: the redeem request itself +// connect_failed: the code was redeemed, but signing in to the mail server +// with what it returned failed; the code is gone, a new one is needed +export type PairingErrorReason = + | 'expired' + | 'used' + | 'expired_or_used' + | 'invalid' + | 'unsupported' + | 'untrusted' + | 'insecure' + | 'network' + | 'server' + | 'rate_limited' + | 'bad_response' + | 'connect_failed'; + +export class PairingError extends Error { + readonly reason: PairingErrorReason; + /** Host the message is about: the webmail, or the mail server for `connect_failed`. */ + readonly host?: string; + + constructor(reason: PairingErrorReason, message?: string, opts?: { host?: string; cause?: unknown }) { + super(message ?? `Pairing failed: ${reason}`); + this.name = 'PairingError'; + this.reason = reason; + this.host = opts?.host; + if (opts?.cause !== undefined) this.cause = opts.cause; + } +} + // The state is the only guard against a forged `bulwarkmobile://` redirect // delivering foreign credentials, so it comes from the platform CSPRNG. function randomState(): string { @@ -63,8 +104,8 @@ function randomState(): string { } // Anything from the redirect fragment is attacker-influenced: another app can -// register the same custom scheme. Only accept https endpoints, and only a -// token endpoint that lives on the mail server's own host (or the webmail's). +// register the same custom scheme. External token endpoints need verification +// through the selected webmail, rather than trusting the redirect alone. function isHttpsUrl(value: string | null): value is string { return !!value && /^https:\/\/[^/?#\s]+/i.test(value); } @@ -92,6 +133,60 @@ export function isAcceptableTokenEndpoint( return allowed.some((h) => host === h || host.endsWith(`.${h}`) || h.endsWith(`.${host}`)); } +// External IdPs must be authorized by the selected webmail over TLS, never +// by callback fields or a shared DNS suffix. Bind all returned identifiers. +async function isTrustedHandoffEndpoint( + tokenEndpoint: string, + serverUrl: string, + webmailUrl: string, + clientId: string, +): Promise { + if (isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) return true; + function httpsUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.hash; + } catch { + return false; + } + } + if (!httpsUrl(webmailUrl) || !httpsUrl(tokenEndpoint) || !httpsUrl(serverUrl)) return false; + const base = webmailUrl.replace(/\/+$/, ''); + try { + const load = async (path: string) => { + const url = `${base}${path}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + try { + const response = await secureFetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'error', + signal: controller.signal, + timeoutMs: 8000, + }); + // The native client-certificate bridge omits the final URL. Without + // transport evidence of its destination, external-IdP trust must fail. + if (!response.ok || response.url !== url || response.redirected) { + throw new Error('Untrusted metadata response'); + } + return await response.json(); + } finally { + clearTimeout(timeout); + } + }; + const config = await load('/api/config'); + if ( + config.oauthEnabled !== true || config.oauthClientId !== clientId || + config.jmapServerUrl !== serverUrl || !httpsUrl(config.oauthIssuerUrl) + ) return false; + const metadata = await load('/api/auth/oauth/metadata'); + return metadata.issuer === config.oauthIssuerUrl && metadata.token_endpoint === tokenEndpoint; + } catch { + return false; + } +} + function buildHandoffUrl(webmailUrl: string, state: string): string { const base = webmailUrl.replace(/\/+$/, ''); const params = new URLSearchParams({ @@ -165,7 +260,7 @@ export async function runWebmailHandoff( if (!accessToken || !tokenEndpoint || !clientId) { throw new HandoffError('Sign-in response missing OAuth tokens'); } - if (!isAcceptableTokenEndpoint(tokenEndpoint, serverUrl, webmailUrl)) { + if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, webmailUrl, clientId))) { throw new HandoffError('Sign-in response token endpoint is not trusted'); } const refreshToken = params.get('refresh_token') ?? undefined; @@ -191,24 +286,35 @@ export async function runWebmailHandoff( // server URL for the normal webmail handoff (`connect`), or carries a one-time // cross-device pairing code minted by an already-signed-in webmail (`pair`). // The payload never contains credentials — the `pair` code is redeemed for -// tokens over the network, once. +// tokens over the network, once. The same `bulwarkmail://` links also arrive +// as deep links (a tapped link) and as pasted text. export type QrLoginPayload = | { kind: 'connect'; webmailUrl: string } | { kind: 'pair'; webmailUrl: string; code: string }; +// `bulwarkmail://pair?…` and `bulwarkmail://pair/?…` (Android and some QR +// readers add the slash after the host). +const SIGN_IN_LINK_RE = /^bulwarkmail:\/\/(connect|pair)\/?\?([^#]*)(?:#.*)?$/i; +// The webmail mints 64 hex characters; accept any URL-safe token of sane +// length so a future format does not break older apps. +const PAIRING_CODE_RE = /^[A-Za-z0-9_-]{8,512}$/; + export function parseQrLoginPayload(raw: string): QrLoginPayload | null { const trimmed = raw.trim(); // Custom scheme: bulwarkmail://connect?server=... | bulwarkmail://pair?server=...&code=... - const match = /^bulwarkmail:\/\/(connect|pair)\?(.*)$/i.exec(trimmed); + const match = SIGN_IN_LINK_RE.exec(trimmed); if (match) { const kind = match[1].toLowerCase(); const params = new URLSearchParams(match[2]); - const server = params.get('server'); - if (!server || !/^https?:\/\//i.test(server)) return null; + const server = params.get('server')?.trim(); + if (!server || !/^https?:\/\/[^/?#\s]+/i.test(server)) return null; if (kind === 'pair') { - const code = params.get('code'); - if (!code) return null; + // The code is redeemed at this address, and the answer carries + // credentials: only over TLS, or plain http on loopback for development. + if (!isHttpsUrl(server) && !isLoopbackHttp(server)) return null; + const code = params.get('code')?.trim(); + if (!code || !PAIRING_CODE_RE.test(code)) return null; return { kind: 'pair', webmailUrl: server, code }; } return { kind: 'connect', webmailUrl: server }; @@ -223,59 +329,208 @@ export function parseQrLoginPayload(raw: string): QrLoginPayload | null { return null; } +/** + * The code is redeemed at the webmail, and the answer carries credentials: + * only over TLS, or plain http on loopback for development. + */ +export function isInsecurePairingUrl(webmailUrl: string): boolean { + return !isHttpsUrl(webmailUrl) && !isLoopbackHttp(webmailUrl); +} + +/** + * The host a sign-in link talks to, for the user to check. Read the way the + * network stack reads the address: `https://mail.example.com@evil.example` + * and `https://evil.example\@mail.example.com` both go to evil.example, so + * that is the host named. + */ +export function signInLinkHost(url: string): string { + const authority = url.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/\\?#]/)[0]; + return authority.slice(authority.lastIndexOf('@') + 1).toLowerCase(); +} + +/** + * Why parseQrLoginPayload refused a pairing link, when the reason is its + * plain-http webmail (the code, and the credentials it buys, would cross the + * network in clear), so a scanned, pasted or tapped link can say so instead + * of "not a Bulwark sign-in code". Only ever an error to show: the parser + * stays the one gate and never yields such a link. Takes the link alone or + * pasted text around it; null for anything else. + */ +export function insecurePairingLinkError(text: string): PairingError | null { + const link = (/bulwarkmail:\/\/\S+/i.exec(text)?.[0] ?? '').replace(/[)\]>.,;'"]+$/, ''); + const match = SIGN_IN_LINK_RE.exec(link); + if (!match || match[1].toLowerCase() !== 'pair') return null; + const params = new URLSearchParams(match[2]); + const server = params.get('server')?.trim(); + const code = params.get('code')?.trim(); + if (!server || !/^http:\/\/[^/?#\s]+/i.test(server) || !isInsecurePairingUrl(server)) return null; + if (!code || !PAIRING_CODE_RE.test(code)) return null; + return new PairingError('insecure', 'Pairing needs an https webmail address', { + host: signInLinkHost(server), + }); +} + +/** + * Pasted text: the link on its own, or a message that contains it ("Open + * bulwarkmail://pair?… on your phone"). Falls back to the whole text so a + * pasted webmail address still works like a scanned one. + */ +export function parsePastedSignInLink(text: string): QrLoginPayload | null { + const embedded = /bulwarkmail:\/\/\S+/i.exec(text)?.[0]; + if (embedded) { + const payload = parseQrLoginPayload(embedded.replace(/[)\]>.,;'"]+$/, '')); + if (payload) return payload; + } + return parseQrLoginPayload(text); +} + +export const PAIRING_REDEEM_TIMEOUT_MS = 15_000; + +// Host (and port) for messages: "Can't reach mail.example.com". +function displayHost(url: string): string { + return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split(/[/?#]/)[0]; +} + +// A JSON body, or null for an empty, HTML or otherwise unparseable one. A +// proxy error page or a webmail too old to have the route answers with HTML. +async function readJsonBody(response: Response): Promise | null> { + try { + const data: unknown = await response.json(); + return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record) : null; + } catch { + return null; + } +} + +function stringField(data: Record, key: string): string | undefined { + const value = data[key]; + return typeof value === 'string' && value ? value : undefined; +} + +// Map a failed redeem onto what the user should do about it. +function redeemFailure(status: number, body: Record | null, host: string): PairingError { + const error = body ? stringField(body, 'error') : undefined; + if (status === 410) { + if (error === 'expired_code') return new PairingError('expired', 'The pairing code has expired', { host }); + if (error === 'used_code') return new PairingError('used', 'The pairing code was already used', { host }); + return new PairingError('expired_or_used', 'The pairing code has expired or was already used', { host }); + } + if (status === 400) { + if (error === 'invalid_code') return new PairingError('invalid', 'The pairing code is not valid', { host }); + // Webmail up to 1.11 answers every failure with a 400 and + // "Invalid or expired pairing code" / "Missing pairing code". + return new PairingError('expired_or_used', 'The pairing code has expired or was already used', { host }); + } + if (status === 404 || status === 405 || (status >= 300 && status < 400)) { + return new PairingError('unsupported', `No pairing endpoint at ${host} (${status})`, { host }); + } + if (status === 429) return new PairingError('rate_limited', 'Too many pairing attempts (429)', { host }); + return new PairingError('server', `Pairing failed with status ${status}`, { host }); +} + // Cross-device pairing redemption. Posts the scanned code to the webmail that -// minted it and maps the token bundle into the same shape the in-browser OAuth -// handoff produces, so the caller can reuse the existing connectWithOAuth path. +// minted it and maps the answer into the same shape the in-browser handoff +// produces: an OAuth token bundle for connectWithOAuth, or an app password +// (the account password on servers without app passwords) for login(). export async function redeemPairingCode(webmailUrl: string, code: string): Promise { const base = webmailUrl.replace(/\/+$/, ''); - let response: Response; + const host = displayHost(base); + // The login screen refuses these links already; never send a code in clear. + if (isInsecurePairingUrl(base)) { + throw new PairingError('insecure', 'Pairing needs an https webmail address', { host }); + } + + // secureFetch's native (client-certificate) path ignores `signal`, so the + // deadline is also passed as `timeoutMs` and enforced here with a race; the + // body read counts against it too. + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new PairingError('network', `Timed out reaching ${host}`, { host })); + }, PAIRING_REDEEM_TIMEOUT_MS); + }); + + let status: number; + let ok: boolean; + let data: Record | null; try { - response = await secureFetch(`${base}/api/auth/pair/redeem`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ pairing_code: code }), - }); - } catch { - throw new HandoffError('Could not reach the server to complete pairing'); + const exchange = (async () => { + let response: Response; + try { + response = await secureFetch(`${base}/api/auth/pair/redeem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ pairing_code: code }), + signal: controller.signal, + timeoutMs: PAIRING_REDEEM_TIMEOUT_MS, + }); + } catch (err) { + throw new PairingError( + 'network', + `Could not reach ${host}: ${err instanceof Error ? err.message : String(err)}`, + { host, cause: err }, + ); + } + return { status: response.status, ok: response.ok, data: await readJsonBody(response) }; + })(); + ({ status, ok, data } = await Promise.race([exchange, deadline])); + } finally { + clearTimeout(timer); } - if (!response.ok) { - // The code was unknown, already used, or expired (server returns 400) — - // or something else went wrong. Either way the user needs a fresh QR. - throw new HandoffError('Pairing code is invalid or has expired'); + if (!ok) throw redeemFailure(status, data, host); + if (!data) throw new PairingError('bad_response', 'The pairing response was not JSON', { host }); + + const serverUrl = stringField(data, 'server_url'); + if (!serverUrl) throw new PairingError('bad_response', 'Pairing response missing server URL', { host }); + if (!isHttpsUrl(serverUrl) && !isLoopbackHttp(serverUrl)) { + throw new PairingError('insecure', 'Pairing response server URL must use https', { host }); } - const data = (await response.json()) as { - server_url?: string; - access_token?: string; - refresh_token?: string; - expires_in?: number; - token_endpoint?: string; - client_id?: string; - }; + // Webmail up to 1.11 always sends `flow: 'oauth'`; a bundle without the + // field but with a token is the same thing. + const flow = stringField(data, 'flow') ?? (stringField(data, 'access_token') ? 'oauth' : undefined); - if (!data.server_url || !data.access_token || !data.token_endpoint || !data.client_id) { - throw new HandoffError('Pairing response missing token material'); - } - if (!isHttpsUrl(data.server_url) && !isLoopbackHttp(data.server_url)) { - throw new HandoffError('Pairing response server URL must use https'); - } - if (!isAcceptableTokenEndpoint(data.token_endpoint, data.server_url, base)) { - throw new HandoffError('Pairing response token endpoint is not trusted'); + if (flow === 'password') { + const username = stringField(data, 'username'); + const password = stringField(data, 'password'); + if (!username || !password) { + throw new PairingError('bad_response', 'Pairing response missing credentials', { host }); + } + return { flow: 'password', serverUrl, username, password }; } - return { - flow: 'oauth', - serverUrl: data.server_url, - tokens: { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? undefined, - expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, - tokenEndpoint: data.token_endpoint, - clientId: data.client_id, - source: 'pairing', - }, - }; + if (flow === 'oauth') { + const accessToken = stringField(data, 'access_token'); + const tokenEndpoint = stringField(data, 'token_endpoint'); + const clientId = stringField(data, 'client_id'); + if (!accessToken || !tokenEndpoint || !clientId) { + throw new PairingError('bad_response', 'Pairing response missing token material', { host }); + } + // The mail server's own host, the webmail's token proxy + // (`/api/auth/pair/token`), or an external identity provider the + // webmail itself names; nothing else gets the refresh token. + if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, base, clientId))) { + throw new PairingError('untrusted', 'Pairing response token endpoint is not trusted', { host }); + } + const expiresIn = typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) ? data.expires_in : undefined; + return { + flow: 'oauth', + serverUrl, + tokens: { + accessToken, + refreshToken: stringField(data, 'refresh_token'), + expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined, + tokenEndpoint, + clientId, + source: 'pairing', + }, + }; + } + + throw new PairingError('bad_response', `Unknown pairing flow: ${flow ?? 'missing'}`, { host }); } const activeRefreshes = new Map>(); @@ -323,11 +578,24 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise; + renderPage(fileUri: string, index: number, width: number, outUri: string): Promise; + close(fileUri: string): Promise; +} + +function native(): NativePdf | null { + if (Platform.OS !== 'android') return null; + return (NativeModules.BulwarkPdf as NativePdf | undefined) ?? null; +} + +/** Whether this build renders PDFs in-app. */ +export function canRenderPdfPages(): boolean { + return native() !== null; +} + +export type PdfOpenFailure = 'protected' | 'unreadable'; + +export class PdfOpenError extends Error { + constructor(readonly reason: PdfOpenFailure, message: string) { + super(message); + } +} + +/** The pages of a PDF, or a {@link PdfOpenError} saying why it can't be shown. */ +export async function openPdfPages(fileUri: string): Promise { + const pdf = native(); + if (!pdf) throw new PdfOpenError('unreadable', 'PDF rendering is not available'); + try { + const { pages } = await pdf.open(fileUri); + if (!pages.length) throw new PdfOpenError('unreadable', 'The PDF has no pages'); + return pages; + } catch (e) { + if (e instanceof PdfOpenError) throw e; + const code = (e as { code?: string } | null)?.code; + throw new PdfOpenError(code === 'E_PDF_PROTECTED' ? 'protected' : 'unreadable', e instanceof Error ? e.message : String(e)); + } +} + +/** Render page `index` (0-based) `width` pixels wide into `outUri`. */ +export function renderPdfPage(fileUri: string, index: number, width: number, outUri: string): Promise { + const pdf = native(); + if (!pdf) return Promise.reject(new Error('PDF rendering is not available')); + return pdf.renderPage(fileUri, index, Math.round(width), outUri); +} + +/** Let go of the open document. */ +export function closePdfPages(fileUri: string): void { + void native()?.close(fileUri).catch(() => undefined); +} + +/** Name of the image a page is rendered into, next to the viewer page. */ +export function pdfPageFileName(index: number): string { + return `__page-${index + 1}.png`; +} diff --git a/src/lib/perf-marks.ts b/src/lib/perf-marks.ts new file mode 100644 index 00000000..50ab3510 --- /dev/null +++ b/src/lib/perf-marks.ts @@ -0,0 +1,140 @@ +// Timing marks for the performance harness (scripts/e2e/perf.mjs). +// +// Off unless the app was launched with the `-perfMarks YES` launch argument +// (iOS puts launch arguments into NSUserDefaults, which React Native's +// Settings module reads). When on, every mark is one `[perf]` line on the +// console; on iOS that lands in the system log, where the harness reads it +// with `log show --info`. Nothing is stored or sent anywhere. + +import { Platform, Settings } from 'react-native'; + +function readEnabled(): boolean { + try { + if (Platform.OS !== 'ios') return false; + const value = Settings.get('perfMarks') as unknown; + return value === true || value === 'YES' || value === 'true' || value === 1 || value === '1'; + } catch { + return false; + } +} + +const enabled = readEnabled(); + +export function perfMarksEnabled(): boolean { + return enabled; +} + +/** Log a mark with the wall-clock time (the harness compares across processes). */ +export function perfMark(name: string, detail?: Record): void { + if (!enabled) return; + console.log(`[perf] ${JSON.stringify({ n: name, t: Date.now(), ...detail })}`); +} + +const started = new Map(); + +/** Start a timed span; `perfEnd` with the same name logs its duration. */ +export function perfStart(name: string): void { + if (!enabled) return; + started.set(name, Date.now()); +} + +export function perfEnd(name: string, detail?: Record): void { + if (!enabled) return; + const t0 = started.get(name); + if (t0 === undefined) return; + started.delete(name); + perfMark(name, { ms: Date.now() - t0, ...detail }); +} + +/** Log how far into a running span a step got, without ending it. */ +export function perfLap(name: string, lap: string): void { + if (!enabled) return; + const t0 = started.get(name); + if (t0 === undefined) return; + perfMark(`${name}:${lap}`, { ms: Date.now() - t0 }); +} + +/** + * End a span once the next frames are drawn: two animation frames after the + * state change that rendered the result, so the time includes commit and + * paint, not just the JS render. + */ +export function perfEndAfterPaint(name: string, detail?: Record): void { + if (!enabled || !started.has(name)) return; + requestAnimationFrame(() => requestAnimationFrame(() => perfEnd(name, detail))); +} + +let onceDone = new Set(); +/** A mark that is logged once per process (first inbox render, ...). */ +export function perfOnce(name: string, detail?: Record): void { + if (!enabled || onceDone.has(name)) return; + onceDone.add(name); + perfMark(name, detail); +} + +/** + * The modules whose own evaluation took longest so far, when the bundle was + * built with BULWARK_MODULE_TIMINGS (scripts/perf/module-timings.js): the + * 60 slowest as "id:ms" pairs, plus the total. Logged once, at first inbox. + */ +export function logModuleTimes(): void { + if (!enabled || onceDone.has('module-times')) return; + const times = (globalThis as { __bulwarkModuleTimes?: Record }).__bulwarkModuleTimes; + if (!times) return; + onceDone.add('module-times'); + const entries = Object.entries(times); + const total = entries.reduce((sum, [, ms]) => sum + ms, 0); + const top = entries + .sort((a, b) => b[1] - a[1]) + .slice(0, 60) + .map(([id, ms]) => `${id}:${Math.round(ms * 10) / 10}`) + .join(','); + perfMark('module-times', { total: Math.round(total), count: entries.length, top }); +} + +/** Test hook. */ +export function resetPerfMarks(): void { + started.clear(); + onceDone = new Set(); +} + +/** + * Frame monitor for scrolling: counts animation frames between `start` and + * `stop` and how many of them took longer than two vsyncs (a visible hitch + * at 60 Hz). JS-thread frames, so it shows JS work blocking during a scroll. + */ +export function createFrameMonitor(name: string): { start: () => void; stop: () => void } { + let raf: number | null = null; + let last = 0; + let frames = 0; + let long = 0; + let worst = 0; + let t0 = 0; + const tick = (now: number) => { + if (last) { + const dt = now - last; + frames += 1; + if (dt > 34) long += 1; + if (dt > worst) worst = dt; + } + last = now; + raf = requestAnimationFrame(tick); + }; + return { + start() { + if (!enabled || raf !== null) return; + frames = 0; + long = 0; + worst = 0; + last = 0; + t0 = Date.now(); + raf = requestAnimationFrame(tick); + }, + stop() { + if (raf === null) return; + cancelAnimationFrame(raf); + raf = null; + perfMark(name, { ms: Date.now() - t0, frames, long, worst: Math.round(worst) }); + }, + }; +} diff --git a/src/lib/platform-capabilities.ts b/src/lib/platform-capabilities.ts index 785786c6..5d548809 100644 --- a/src/lib/platform-capabilities.ts +++ b/src/lib/platform-capabilities.ts @@ -1,16 +1,37 @@ // Single source of truth for "this feature only exists on some platforms", so // the UI, the stores and the native bridges all agree on what is reachable. -import { Platform } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; + +/** + * Where this Android build is distributed: the product flavor in + * android/app/build.gradle, read from the BulwarkBuild native module. A build + * without the module (older native code) is the sideloaded GitHub APK. + */ +export const androidDistribution: 'github' | 'play' = + Platform.OS === 'android' && NativeModules.BulwarkBuild?.distribution === 'play' ? 'play' : 'github'; /** * In-app sideload updates: download the release APK from GitHub and hand it to * the system package installer (see `lib/install-update`). * - * Android only. App Store Review guideline 2.5.2 forbids an iOS app from - * downloading and installing executable code, and there is no iOS equivalent of - * the installer intent anyway - iOS builds are updated through TestFlight or - * the App Store. The whole updater surface (banner, settings pane, launch-time - * check) is hidden when this is false. + * Only the GitHub build on Android. App Store Review guideline 2.5.2 forbids an + * iOS app from downloading and installing executable code, and Google Play + * forbids an app updating itself outside Play; the Play build also lacks the + * installer permission. iOS builds are updated through TestFlight or the App + * Store, the Play build through Play. The whole updater surface (banner, + * settings pane, launch-time check) is hidden when this is false. */ -export const supportsSideloadUpdates = Platform.OS === 'android'; +export const supportsSideloadUpdates = Platform.OS === 'android' && androidDistribution === 'github'; + +/** + * Device sync: address books and calendars in Android's Contacts and Calendar + * providers, under an account of the app's own type (#34, + * docs/device-sync.md). + * + * Android only: iOS has no third-party sync-provider API for either. The UI + * also needs the native module (`isDeviceSyncAvailable()` in + * `device-sync/native`), so a build without it hides the feature instead of + * failing. + */ +export const supportsDeviceSync = Platform.OS === 'android'; diff --git a/src/lib/preview-viewer-html.ts b/src/lib/preview-viewer-html.ts new file mode 100644 index 00000000..c98b6134 --- /dev/null +++ b/src/lib/preview-viewer-html.ts @@ -0,0 +1,142 @@ +// The pages the file preview shows images and media in. Each is written next +// to the downloaded file and loaded from there, so the WebView reads the +// file by a relative URL and nothing else on the device. They post 'error' +// when the WebView can't decode the file, so the preview can offer another +// app instead of a blank page or a dead player. + +export interface ViewerColors { + /** The app's theme, so the WebView draws its own controls to match. */ + scheme: 'light' | 'dark'; + background: string; + text: string; + textMuted: string; + surface: string; +} + +const ESCAPES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]); +} + +/** A file name as a relative URL: every reserved character encoded. */ +export function relativeFileUrl(fileName: string): string { + return encodeURIComponent(fileName).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`); +} + +const REPORT = `function report(m){try{window.ReactNativeWebView.postMessage(m)}catch(e){}}`; + +function page(title: string, colors: ViewerColors, viewport: string, style: string, body: string): string { + return '' + + `` + + `${escapeHtml(title)}` + + `` + + `${body}`; +} + +/** + * An image fitted to the screen, with the WebView's own pinch zoom. Used on + * Android, where React Native's Image can't zoom, and for SVG and ICO, which + * it can't draw. SVG is shown through , so its scripts never run and it + * loads nothing from the network. + */ +export function imageViewerHtml(fileName: string, colors: ViewerColors): string { + // Raster icons scaled up stay crisp instead of blurring. + const fillClass = /\.(ico|bmp|gif|png)$/i.test(fileName) ? 'fill pixel' : 'fill'; + return page( + fileName, + colors, + 'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=10,user-scalable=yes', + 'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:16px}' + + 'img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}' + // A vector or a tiny icon has no useful natural size; fill the screen. + + 'img.fill{width:100%;height:100%}' + + 'img.pixel{image-rendering:pixelated}', + `${escapeHtml(fileName)}` + + ``, + ); +} + +/** + * The platform's own player for a video or a sound. A video fills the + * screen on black, with fullscreen and picture in picture where the WebView + * offers them; a sound gets a card with its name. Playback starts at once. + */ +export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, colors: ViewerColors): string { + const src = relativeFileUrl(fileName); + const script = ``; + if (kind === 'video') { + return page( + fileName, + { ...colors, scheme: 'dark', background: '#000' }, + 'width=device-width,initial-scale=1', + 'body{display:flex;align-items:center;justify-content:center}video{width:100%;height:100%;object-fit:contain;background:#000}', + `${script}`, + ); + } + const note = ''; + return page( + fileName, + colors, + 'width=device-width,initial-scale=1', + 'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:24px}' + + `.card{width:100%;max-width:480px;display:flex;flex-direction:column;align-items:center;gap:20px}` + + `.art{width:160px;height:160px;border-radius:24px;background:${colors.surface};color:${colors.textMuted};` + + 'display:flex;align-items:center;justify-content:center}' + + '.name{font-size:17px;font-weight:600;text-align:center;word-break:break-word}' + + 'audio{width:100%}', + `
${note}
${escapeHtml(fileName)}
` + + `
${script}`, + ); +} + +/** + * Android's PDF viewer: one placeholder per page at the page's proportions, + * filled with an image the app renders when the page comes near the screen + * (it posts `page:` and the app answers by calling + * `window.__pdfPage(index, src)`). The WebView zooms the whole document, and a + * badge shows the page at the middle of the screen. + */ +export function pdfViewerHtml( + fileName: string, + pages: ReadonlyArray<{ width: number; height: number }>, + colors: ViewerColors, +): string { + const sizes = JSON.stringify(pages.map((p) => [Math.max(1, Math.round(p.width)), Math.max(1, Math.round(p.height))])); + return page( + fileName, + { ...colors, background: colors.surface }, + 'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=6,user-scalable=yes', + 'body{height:auto;min-height:100%}' + + '#doc{padding:8px 8px 56px;display:flex;flex-direction:column;align-items:center;gap:8px}' + + '.page{width:100%;max-width:1000px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);position:relative}' + + '.page img{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .15s}' + + '.page img.on{opacity:1}' + + `#badge{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);padding:4px 12px;border-radius:999px;` + + `background:rgba(0,0,0,.65);color:#fff;font-size:13px;font-variant-numeric:tabular-nums;transition:opacity .3s}` + + '#badge.hide{opacity:0}', + '
' + + ``, + ); +} diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index dacd2778..4afcad23 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -12,6 +12,7 @@ import { getFaviconDomain, getFaviconUrl, } from './avatar-utils'; +import { singleLine } from './single-line'; import { lastNotifiedKey, migrateLegacyPushKeys, @@ -87,6 +88,7 @@ interface ShowNotificationOptions { notificationId: string; title: string; body: string; + preview?: string; initials: string; bgColorHex: string; iconUrl?: string; @@ -101,6 +103,9 @@ interface ShowNotificationOptions { // several deliveries under a "+N more" summary instead of stacking them. groupKey: string; groupTitle: string; + markReadLabel?: string; + deleteLabel?: string; + replyLabel?: string; } interface BulwarkFcmNative { @@ -154,7 +159,7 @@ export function parseRelayPushData(data: unknown): RelayPushData { } catch { // ignore } - } else if (d.changed && typeof d.changed === 'object') { + } else if (d.changed && typeof d.changed === 'object' && !Array.isArray(d.changed)) { out.changed = d.changed as Record>; } // Older relays only sent `changed`; the account id is its first key. @@ -378,12 +383,39 @@ async function detachedNewestUnreadInboxIds( return (qBody.ids as string[]) ?? []; } +// Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`, which +// new mail changes too. A subscription made by an older build listens to them +// until the launch-time resync replaces its types, and a server without +// `EmailDelivery` has only them. Keep in sync with MAIL_TYPES in PushRouting.kt. +const MAIL_TYPES = ['EmailDelivery', 'Email', 'Mailbox']; + +/** + * A StateChange push in which no account got new mail: only contact or + * calendar changes, which the native push router already turned into device + * syncs (#34, docs/device-sync.md "Triggers"). Without a mail type there is + * nothing to notify, and the legacy path (the newest unread mail) must not + * run for it. A missing map, or one that names no type, keeps the old + * behaviour, and an account entry it can't read may be mail. The same rules + * as PushRouting.decide, so the two never disagree about a push. + */ +export function carriesNoMail(payload: RelayPushData): boolean { + if (payload.kind !== 'jmap-state-change' || !payload.changed) return false; + const entries: unknown[] = Object.values(payload.changed); + if (entries.some((types) => !types || typeof types !== 'object' || Array.isArray(types))) return false; + const types = entries.flatMap((entry) => Object.keys(entry as Record)); + if (types.length === 0) return false; + return !types.some((type) => MAIL_TYPES.includes(type)); +} + // Fired by BulwarkPushTaskService when a data FCM message arrives. Runs in a // short-lived headless JS runtime - keep it fast, catch all errors, and always // resolve so the native service can release its wake lock. Never touches the // singleton jmapClient's session (see "Detached JMAP access" above). export async function pushBackgroundTask(data: unknown): Promise { try { + const payload = parseRelayPushData(data); + if (carriesNoMail(payload)) return; + if (!(await emailNotificationsAllowed())) return; await migrateLegacyPushKeys(); @@ -392,7 +424,6 @@ export async function pushBackgroundTask(data: unknown): Promise { if (accountIds.length === 0) return; const registry = await readAccountRegistry(); - const payload = parseRelayPushData(data); const jmapAccountIds = await readPushJmapAccountIds(); const accountsToCheck = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry); @@ -479,10 +510,16 @@ async function processAccountForPush(accountId: string, payload: RelayPushData): const faviconDomain = favicons ? getFaviconDomain(address) : null; const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; + const preview = email.preview ? singleLine(email.preview) : undefined; + const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read'); + const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete'); + const replyLabel = translate(locale, 'email_viewer.reply', 'Reply'); + await native.showNotification({ notificationId: `mail:${email.id}`, title, body, + preview, initials, bgColorHex, iconUrl, @@ -493,6 +530,9 @@ async function processAccountForPush(accountId: string, payload: RelayPushData): jmapAccountId: emailAccountId, groupKey, groupTitle, + markReadLabel, + deleteLabel, + replyLabel, }); } @@ -513,3 +553,94 @@ function hslToHex(hsl: string): string { }; return `#${component(0)}${component(8)}${component(4)}`; } + +export interface NotificationActionPayload { + action: 'markRead' | 'delete'; + emailId: string; + accountId: string; + jmapAccountId?: string; +} + +export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null { + if (!data || typeof data !== 'object') return null; + const d = data as Record; + const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined; + const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined; + const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined; + const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined; + + if ((action === 'markRead' || action === 'delete') && emailId && accountId) { + return { action, emailId, accountId, jmapAccountId }; + } + return null; +} + +export async function handleNotificationAction(data: unknown): Promise { + const payload = parseNotificationActionPayload(data); + if (!payload) { + console.warn('[push] invalid notification action payload', data); + return; + } + const { action, emailId, accountId, jmapAccountId } = payload; + console.log(`[push] executing notification action "${action}" for email ${emailId}`); + + try { + const session = await openDetachedSession(accountId); + if (!session) { + console.warn('[push] failed to open detached session for account', accountId); + return; + } + const emailAccountId = jmapAccountId ?? session.jmapAccountId; + + if (action === 'markRead') { + const res = await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { 'keywords/$seen': true }, + }, + }, + '0', + ], + ]); + assertEmailUpdated(res, emailId); + } else if (action === 'delete') { + const mbRes = await jmapPost(session, [ + ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'], + ]); + const [mbName, mbBody] = mbRes[0] ?? []; + const mailboxes = mbName === 'Mailbox/get' ? ((mbBody?.list as Mailbox[]) ?? []) : []; + const trash = mailboxes.find((m) => m.role === 'trash'); + // Never destroy from a notification: without a Trash (a shared + // account, or a Mailbox/get that failed) the message stays put. + if (!trash) { + console.warn('[push] no trash mailbox for notification delete', emailAccountId); + return; + } + const res = await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { mailboxIds: { [trash.id]: true } }, + }, + }, + '0', + ], + ]); + assertEmailUpdated(res, emailId); + } + } catch (err) { + console.warn('[push] notification action failed', action, emailId, err); + } +} + +function assertEmailUpdated(responses: Array<[string, Record, string]>, emailId: string): void { + const [name, body] = responses[0] ?? []; + if (name !== 'Email/set' || !body?.updated || !(emailId in body.updated)) { + throw new Error(`Email/set did not update ${emailId}: ${JSON.stringify(body?.notUpdated?.[emailId] ?? body ?? null)}`); + } +} diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 332c6426..14e2d21a 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -9,12 +9,29 @@ import { } from '../api/push'; import { getMailboxes, getSharedMailboxes } from '../api/email'; import { loadedMailboxes } from './mailbox-source'; -import { jmapClient } from '../api/jmap-client'; -import { isJMAPSetError, JMAPMethodError } from '../api/jmap-result'; +import { jmapClient, JMAPClient } from '../api/jmap-client'; +import { isJMAPSetError, JMAPMethodError, requireMethodResult } from '../api/jmap-result'; import { CAPABILITIES } from '../api/types'; -import type { EmailPushConfig, JMAPAccountInfo, Mailbox, PushSubscription } from '../api/types'; +import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox, PushSubscription } from '../api/types'; import { generateAccountId } from './account-utils'; +import { + EMPTY_NOTIFICATION_MUTES, + isAccountMuted, + mailboxOverridesForAccount, + type NotificationMutes, +} from './notification-mutes'; +import { useSettingsStore } from '../stores/settings-store'; import { t } from '../stores/locale-store'; +import { + authorityOfType, + DEVICE_SYNC_STORAGE_KEY, + deviceSyncPushTypes, + persistedAccounts, +} from '../device-sync/app/prefs'; +import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../device-sync/types'; +import * as Notifications from 'expo-notifications'; +import { credentialsKey } from '../api/jmap-client'; +import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared'; import { getUnifiedPushDistributors, isUnifiedPushSupported, @@ -207,6 +224,38 @@ export function isValidRelayUrl(value: string): boolean { // In-app sync uses the separate SSE channel and is unaffected. export const PUSH_TYPES = ['EmailDelivery'] as const; +// The capability a server advertises for the data types of an authority. +const DEVICE_SYNC_CAPABILITIES: Record = { + [CONTACTS_AUTHORITY]: CAPABILITIES.CONTACTS, + [CALENDAR_AUTHORITY]: CAPABILITIES.CALENDARS, +}; + +/** + * The push types of one account's subscription: `EmailDelivery`, plus the + * contact and calendar types while the account syncs them to the device + * (#34, docs/device-sync.md "Triggers"): the native push router turns those + * into device syncs, and a push carrying only them never notifies. Only the + * types of a capability the account's session advertises: a server without + * contacts or calendars may refuse the whole subscription over them. + * + * Read from the device sync store's persisted JSON rather than the store, so + * code that runs without it (the push task) never creates it. + */ +export async function pushTypesFor(accountId: string, session: JMAPSession | null): Promise { + let extra: string[] = []; + try { + const capabilities = session?.capabilities ?? {}; + extra = deviceSyncPushTypes(persistedAccounts(await AsyncStorage.getItem(DEVICE_SYNC_STORAGE_KEY))[accountId]) + .filter((type) => { + const authority = authorityOfType(type); + return !!authority && DEVICE_SYNC_CAPABILITIES[authority] in capabilities; + }); + } catch { + extra = []; + } + return [...PUSH_TYPES, ...extra]; +} + // draft-ietf-jmap-emailpush (Stalwart >= 0.16.16). `EmailDelivery` alone // still fires for every ingested message - including spam the server files // straight into Junk - because the server can't know which folders a client @@ -294,33 +343,63 @@ function emailPushCapabilityIsExact(): boolean { * `forbidden` when it names a mailbox shared by ACL. 1.0 lists emailpush on * exactly those accounts; 0.16.16 to 0.16.23 list it on every account, and * the setup narrows the map when the server refuses it. + * + * Muted accounts and folders (#78) are cut out of the same filter: a muted + * folder joins Junk in the "not only in" list; a muted account only lets + * through mail in the folders the user unmuted again, or nothing at all. */ -export async function buildEmailPushConfig(): Promise> { +export async function buildEmailPushConfig( + mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES, +): Promise> { const primary = jmapClient.accountId; const junkByAccount = new Map([[primary, []]]); + const mailboxIdsByAccount = new Map>(); // The mail store loads the same folders at the same moment (sign-in, // start): reuse its list rather than a second Mailbox/get of our own. const username = jmapClient.username; const serverUrl = jmapClient.serverUrl; const loaded = username && serverUrl ? await loadedMailboxes(generateAccountId(username, serverUrl)) : null; - const all = loaded ?? [ - ...(await getMailboxes().catch(() => [] as Mailbox[])), - ...(await getSharedMailboxes().catch(() => [] as Mailbox[])), - ]; + // With mutes a swallowed fetch failure would look like "no such folder" + // and unmute everything, so let the real error surface; otherwise the + // filter can still degrade to keyword-only on a transient miss. + const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0; + const all = loaded ?? (hasMutes + ? [...(await getMailboxes()), ...(await getSharedMailboxes())] + : [ + ...(await getMailboxes().catch(() => [] as Mailbox[])), + ...(await getSharedMailboxes().catch(() => [] as Mailbox[])), + ]); for (const m of all) { const accountId = m.accountId || primary; if (accountId !== primary && !accountListsEmailPush(accountId)) continue; - const junk = junkByAccount.get(accountId) ?? []; // Shared-account mailboxes carry a client-side ":" id; // the server only knows the original. - if (m.role === 'junk') junk.push(m.originalId ?? m.id); + const serverId = m.originalId ?? m.id; + const ids = mailboxIdsByAccount.get(accountId) ?? new Set(); + ids.add(serverId); + mailboxIdsByAccount.set(accountId, ids); + const junk = junkByAccount.get(accountId) ?? []; + if (m.role === 'junk') junk.push(serverId); junkByAccount.set(accountId, junk); } const config: Record = {}; for (const [accountId, junkIds] of junkByAccount) { + // Settings for folders that no longer exist are left out. + const existing = mailboxIdsByAccount.get(accountId) ?? new Set(); + const overrides = mailboxOverridesForAccount(mutes, accountId); const conditions: Record[] = [{ notKeyword: '$junk' }]; - if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() }); + if (isAccountMuted(mutes, accountId)) { + const allowed = overrides.unmuted + .filter((id) => existing.has(id) && !junkIds.includes(id)) + .sort(); + if (allowed.length === 0) conditions.push(NEVER_MATCH); + else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] }); + else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) }); + } else { + const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]); + if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() }); + } config[accountId] = { // Always the operator form: that's how the server echoes it back, so a // stored config compares equal to a freshly built one. @@ -332,6 +411,10 @@ export async function buildEmailPushConfig(): Promise { if (Array.isArray(v)) return v.map(sortKeys); @@ -425,6 +508,24 @@ async function writeWithEmailPush( } } +/** + * Run a subscription write with the account's push types and, when the server + * refuses it while they include device sync's, once more with the mail types + * alone: mail push never depends on device sync (#34). + */ +async function writeWithPushTypes( + types: readonly string[], + write: (types: readonly string[]) => Promise, +): Promise { + try { + return await write(types); + } catch (err) { + if (sameTypes(types, PUSH_TYPES)) throw err; + logPhase('jmap', 'push types refused, retrying with the mail types alone'); + return write(PUSH_TYPES); + } +} + async function readRefusedEmailPushAccounts(accountId: string): Promise { try { const parsed = JSON.parse((await AsyncStorage.getItem(emailPushRefusedKey(accountId))) ?? '[]'); @@ -503,12 +604,12 @@ function getNative(): BulwarkFcmNative | null { return (NativeModules as Record).BulwarkFcm as BulwarkFcmNative | undefined ?? null; } -/** True on platforms that have a push transport wired up (Android only). */ +/** True on platforms that have a push transport wired up: FCM or UnifiedPush on Android, APNs on iOS. */ export function isPushSupported(): boolean { - return getNative() !== null || isUnifiedPushSupported(); + return Platform.OS === 'ios' || getNative() !== null || isUnifiedPushSupported(); } -export type PushTransport = 'fcm' | 'unifiedpush'; +export type PushTransport = 'fcm' | 'unifiedpush' | 'apns'; export async function getStoredPushTransport(): Promise { const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY); @@ -524,6 +625,8 @@ export async function setStoredPushTransport(transport: PushTransport | null): P } export async function getEffectivePushTransport(): Promise { + // iOS has one way in: APNs, through the relay. + if (Platform.OS === 'ios') return 'apns'; return (await getStoredPushTransport()) ?? 'fcm'; } @@ -645,6 +748,14 @@ export async function dismissPushPrompt(accountId: string): Promise { } export async function requestNotificationPermission(): Promise { + if (Platform.OS === 'ios') { + const current = await Notifications.getPermissionsAsync(); + if (current.granted) return true; + const asked = await Notifications.requestPermissionsAsync({ + ios: { allowAlert: true, allowBadge: true, allowSound: true }, + }); + return asked.granted; + } if (Platform.OS !== 'android') return true; if (Platform.Version < 33) return true; const status = await PermissionsAndroid.request( @@ -696,6 +807,26 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise { return token; } +// The APNs device token (hex). iOS hands it out once notifications are +// allowed; expo-notifications asks the OS for it. +async function getApnsTokenOrThrow(): Promise { + let token: string | null = null; + try { + const result = await Notifications.getDevicePushTokenAsync(); + token = typeof result.data === 'string' ? result.data : null; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError( + 'token', + t('settings.notifications.push.err_apns_token', 'iOS could not issue a push token ({detail}).', { detail }), + ); + } + if (!token) { + throw new PushSetupError('token', t('settings.notifications.push.err_apns_empty', 'iOS returned an empty push token.')); + } + return token; +} + /** * The relay's VAPID public key, when it has one. Passed to the UnifiedPush * distributor at registration so distributors that pin the application server @@ -786,6 +917,64 @@ async function registerWithRelay(params: { } } +async function registerWithRelayApns(params: { + relayBaseUrl: string; + subscriptionId: string; + deviceToken: string; + accountLabel?: string; +}): Promise { + let res: Response; + try { + res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/apns'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: params.subscriptionId, + deviceToken: params.deviceToken, + environment: apnsEnvironment(), + accountLabel: params.accountLabel, + }), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new PushSetupError( + 'relay', + t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', { + url: params.relayBaseUrl, + detail, + }), + ); + } + if (res.status === 503) { + throw new PushSetupError( + 'relay', + t('settings.notifications.push.err_relay_no_apns', 'The push relay at {url} does not deliver to iPhones and iPads yet.', { + url: params.relayBaseUrl, + }), + ); + } + if (!res.ok) { + throw new PushSetupError( + 'relay', + t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', { + detail: await readRelayError(res), + }), + ); + } +} + +/** + * Tell the iOS notification extension how to reach an account: which stored + * credentials to use and the JMAP API URL. Without it the extension shows the + * relay's placeholder text. + */ +export async function shareAccountWithExtension(accountId: string, jmapAccountId: string | null): Promise { + if (Platform.OS !== 'ios' || !jmapAccountId) return; + const apiUrl = jmapClient.currentSession?.apiUrl; + if (!apiUrl) return; + await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined); +} + async function registerWithRelayUnifiedPush(params: { relayBaseUrl: string; subscriptionId: string; @@ -956,6 +1145,9 @@ async function setupPushNotificationsInner( if (transport === 'fcm' && !native) { throw new PushSetupError('platform', t('settings.notifications.push.err_android_only', 'Push notifications are only available on Android.')); } + if (transport === 'apns' && Platform.OS !== 'ios') { + throw new PushSetupError('platform', 'APNs is only available on iOS.'); + } if (transport === 'unifiedpush' && !isUnifiedPushSupported()) { throw new PushSetupError('platform', t('settings.notifications.push.err_up_android_only', 'UnifiedPush is only available on Android.')); } @@ -975,8 +1167,11 @@ async function setupPushNotificationsInner( logPhase('token', transport); let fcmToken: string | null = null; let upEndpoint: UnifiedPushEndpoint | null = null; + let apnsToken: string | null = null; if (transport === 'unifiedpush') { upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl); + } else if (transport === 'apns') { + apnsToken = await getApnsTokenOrThrow(); } else { fcmToken = await getFcmTokenOrThrow(native!); } @@ -1001,7 +1196,14 @@ async function setupPushNotificationsInner( // individually and tags it with the JMAP account id so the headless task // can route it. logPhase('relay', relayBaseUrl); - if (upEndpoint) { + if (apnsToken) { + await registerWithRelayApns({ + relayBaseUrl, + subscriptionId: deviceClientId, + deviceToken: apnsToken, + accountLabel: params.accountLabel, + }); + } else if (upEndpoint) { await registerWithRelayUnifiedPush({ relayBaseUrl, subscriptionId: deviceClientId, @@ -1044,10 +1246,11 @@ async function setupPushNotificationsInner( // a group out. const refusedBefore = emailPushCapabilityIsExact() ? [] : await readRefusedEmailPushAccounts(accountId); const emailPush = serverSupportsEmailPush() - ? withoutAccounts(await buildEmailPushConfig(), refusedBefore) + ? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore) : null; const subKey = subscriptionIdKey(accountId); const storedServerId = await AsyncStorage.getItem(subKey); + const types = await pushTypesFor(accountId, jmapClient.currentSession); let jmapAccountId: string | null = null; try { jmapAccountId = jmapClient.accountId; @@ -1067,7 +1270,7 @@ async function setupPushNotificationsInner( if (!params.forceRecreate) { let refreshed: Awaited>; try { - refreshed = await refreshSubscriptionExpires(match, emailPush); + refreshed = await refreshSubscriptionExpires(match, emailPush, types); } catch (err) { // Nothing the server said about the subscription (a failed request, // rate limiting, a refused method call): it keeps delivering, and @@ -1078,6 +1281,7 @@ async function setupPushNotificationsInner( await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); + await shareAccountWithExtension(accountId, jmapAccountId); await AsyncStorage.removeItem(optedOutKey(accountId)); logPhase('done', 'reused existing subscription'); return { subscriptionId: storedServerId, verified: true }; @@ -1123,14 +1327,16 @@ async function setupPushNotificationsInner( let serverAssignedId: string; let serverExpires: string | null; - const create = () => writeWithEmailPush(emailPush, (filter) => - createPushSubscription({ - deviceClientId, - url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), - types: [...PUSH_TYPES], - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - ...(filter ? { emailPush: filter } : {}), - }), + const create = () => writeWithPushTypes(types, (wanted) => + writeWithEmailPush(emailPush, (filter) => + createPushSubscription({ + deviceClientId, + url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), + types: [...wanted], + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + ...(filter ? { emailPush: filter } : {}), + }), + ), ); try { let created: Awaited>; @@ -1175,6 +1381,7 @@ async function setupPushNotificationsInner( } await addPushAccountId(accountId); await writePushJmapAccountId(accountId, jmapAccountId); + await shareAccountWithExtension(accountId, jmapAccountId); await AsyncStorage.removeItem(optedOutKey(accountId)); logPhase('done', 'subscription verified'); @@ -1191,13 +1398,14 @@ async function addPushAccountId(accountId: string): Promise { // Push the subscription's expires forward when it's getting close to the // server's ceiling, and re-sync `types` / the delivery filter when they drift // from what this client wants (a subscription created by an older build still -// listens to `Email`/`Mailbox`; a Junk mailbox id can change under us). -// Returns false when the server refuses the update with a SetError (the -// subscription is gone, or no narrower map is accepted), which the caller -// answers with a replacement; otherwise the emailPush map the update -// installed (null when it left the filter alone). Every other failure - the -// network, a timeout, rate limiting, a refused method call - says nothing -// about the subscription and is thrown. +// listens to `Email`/`Mailbox`; device sync was turned on or off; a Junk +// mailbox id can change under us). Returns false when the server refuses the +// update with a SetError, also with the mail types alone (the subscription is +// gone, or no narrower map is accepted), which the caller answers with a +// replacement; otherwise the emailPush map the update installed (null when it +// left the filter alone). Every other failure - the network, a timeout, rate +// limiting, a refused method call - says nothing about the subscription and +// is thrown. async function refreshSubscriptionExpires( sub: { id: string; @@ -1207,8 +1415,9 @@ async function refreshSubscriptionExpires( }, // null when the server has no emailPush support - leave the property alone. desiredEmailPush: Record | null, + types: readonly string[], ): Promise | null }> { - const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES); + const typesNeedUpdate = !sameTypes(sub.types, types); const emailPushNeedsUpdate = desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush); if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) { @@ -1220,15 +1429,17 @@ async function refreshSubscriptionExpires( } } try { - const patch: { expires?: string; types?: string[] } = { - expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), - }; - if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; - const { emailPush } = await writeWithEmailPush( - emailPushNeedsUpdate ? desiredEmailPush : null, - (filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch), - ); - return { emailPush }; + return await writeWithPushTypes(types, async (wanted) => { + const patch: { expires?: string; types?: string[] } = { + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + }; + if (!sameTypes(sub.types, wanted)) patch.types = [...wanted]; + const { emailPush } = await writeWithEmailPush( + emailPushNeedsUpdate ? desiredEmailPush : null, + (filter) => updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch), + ); + return { emailPush }; + }); } catch (err) { if (isJMAPSetError(err)) return false; throw err; @@ -1246,6 +1457,10 @@ async function deregisterFromRelay( } async function clearAccountPushKeys(accountId: string): Promise { + if (Platform.OS === 'ios') { + const jmapAccountId = (await readPushJmapAccountIds())[accountId]; + if (jmapAccountId) await removeIosPushAccount(jmapAccountId); + } await AsyncStorage.multiRemove([ subscriptionIdKey(accountId), deviceClientIdKey(accountId), @@ -1387,6 +1602,48 @@ export async function resyncPushNotifications( return setupPushNotifications(params); } +/** + * Re-apply an account's push types to its subscription after device sync was + * turned on or off for it (#34). The account the client serves goes through + * the api/push helpers; another signed-in account through a client of its + * own, never the singleton. Best effort: the next resync of the account + * applies them anyway. + */ +export async function refreshPushSubscriptionTypes(accountId: string): Promise { + if (await AsyncStorage.getItem(optedOutKey(accountId))) return; + const subscriptionId = await AsyncStorage.getItem(subscriptionIdKey(accountId)); + if (!subscriptionId) return; + const username = jmapClient.username; + const serverUrl = jmapClient.serverUrl; + const served = !!username && !!serverUrl && generateAccountId(username, serverUrl) === accountId; + try { + if (served) { + const types = await pushTypesFor(accountId, jmapClient.currentSession); + const current = (await listPushSubscriptions()).find((s) => s.id === subscriptionId); + if (!current || sameTypes(current.types, types)) return; + await updatePushSubscription(subscriptionId, { types: [...types] }); + return; + } + const client = new JMAPClient(); + if (!(await client.loadAccount(accountId))) return; + const types = await pushTypesFor(accountId, client.currentSession); + const using = [CAPABILITIES.CORE]; + const res = await client.request( + [['PushSubscription/get', { ids: [subscriptionId], properties: ['id', 'types'] }, '0']], + using, + ); + const current = (requireMethodResult(res, '0', 'PushSubscription/get').list as Array<{ id: string; types?: string[] | null }> | undefined) + ?.find((s) => s.id === subscriptionId); + if (!current || sameTypes(current.types, types)) return; + await client.request( + [['PushSubscription/set', { update: { [subscriptionId]: { types: [...types] } } }, '0']], + using, + ); + } catch { + // The next resync re-applies them. + } +} + /** * Tear down push for ALL accounts on this device. Used by the logout-all * flow; best-effort because we typically aren't authenticated to every @@ -1526,6 +1783,8 @@ export interface NotificationTapPayload { // The JMAP account the message lives in: a group or shared mailbox's account // when it isn't the user's own. Absent on older notifications. jmapAccountId?: string; + // Notification action tapped (e.g. 'reply' to jump straight to Compose) + action?: 'reply'; } /** @@ -1549,6 +1808,17 @@ export function notificationTapJmapAccountId(payload: NotificationTapPayload): s // before JS was ready to handle it. Subsequent taps while running are delivered // via addNotificationTapListener. export async function getInitialNotificationTap(): Promise { + if (Platform.OS === 'ios') { + const response = await Notifications.getLastNotificationResponseAsync().catch(() => null); + if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return null; + const handledKey = 'push:lastHandledResponse:v1'; + const id = response.notification.request.identifier; + // The last response survives app restarts; open it once. + if ((await AsyncStorage.getItem(handledKey)) === id) return null; + const payload = await iosTapPayload(response.notification.request.content.data); + if (payload) await AsyncStorage.setItem(handledKey, id); + return payload; + } if (Platform.OS !== 'android') return null; const native = (NativeModules as Record).BulwarkFcm as | { getInitialNotification?: () => Promise } @@ -1564,8 +1834,47 @@ export async function getInitialNotificationTap(): Promise void, ): () => void { + if (Platform.OS === 'ios') { + const sub = Notifications.addNotificationResponseReceivedListener((response) => { + if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return; + const id = response.notification.request.identifier; + void iosTapPayload(response.notification.request.content.data).then((payload) => { + if (!payload) return; + void AsyncStorage.setItem('push:lastHandledResponse:v1', id); + listener(payload); + }); + }); + return () => sub.remove(); + } if (Platform.OS !== 'android') return () => undefined; const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm); const sub = emitter.addListener('fcm:notificationTap', listener); return () => sub.remove(); } + +/** + * A tapped iOS mail notification as the app opens it. The relay puts the + * JMAP account and message ids into the payload's `body`, which + * expo-notifications hands over as `data`; the notification extension adds + * the message (and thread) it showed. Calendar reminders and other local + * notifications carry no `kind` and are left to their own handlers. + */ +export async function iosTapPayload(data: unknown): Promise { + if (!data || typeof data !== 'object') return null; + const d = data as Record; + if (d.kind !== 'jmap-email-push' && d.kind !== 'jmap-state-change') return null; + const jmapAccountId = typeof d.accountId === 'string' ? d.accountId : ''; + const ids = Array.isArray(d.emailIds) ? d.emailIds.filter((v): v is string => typeof v === 'string') : []; + const emailId = typeof d.emailId === 'string' && d.emailId ? d.emailId : ids[0]; + if (!emailId) return null; + // The local account the subscription belongs to: the one whose push + // setup recorded this JMAP account id. + const byLocal = await readPushJmapAccountIds(); + const accountId = Object.keys(byLocal).find((local) => byLocal[local] === jmapAccountId); + return { + emailId, + threadId: typeof d.threadId === 'string' ? d.threadId : '', + accountId, + jmapAccountId: jmapAccountId || undefined, + }; +} diff --git a/src/lib/quote-header.ts b/src/lib/quote-header.ts index 7b62edcc..048a2cec 100644 --- a/src/lib/quote-header.ts +++ b/src/lib/quote-header.ts @@ -8,6 +8,7 @@ import { escapeHtml } from './email-html'; import type { TimeFormat } from '../stores/settings-store'; +import { intlLocale } from './intl-locale'; /** Localized label set the caller passes in (from the locale catalog). */ export interface QuoteHeaderLabels { @@ -65,9 +66,8 @@ export function formatQuoteDate(iso: string | undefined, timeFormat: TimeFormat, if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; - const intlLocale = !locale || locale === 'en' ? 'en-US' : locale; try { - return d.toLocaleString(intlLocale, { + return d.toLocaleString(intlLocale(locale), { weekday: 'short', year: 'numeric', month: 'short', diff --git a/src/lib/reply-compose.ts b/src/lib/reply-compose.ts new file mode 100644 index 00000000..20fe3a3b --- /dev/null +++ b/src/lib/reply-compose.ts @@ -0,0 +1,54 @@ +import type { Email } from '../api/types'; +import type { RootStackParamList } from '../navigation/types'; +import { pickEmailBody, plainTextBody } from './email-body'; + +/** + * Composer params for a reply / reply-all / forward of `source`. Shared by + * the thread viewer and the `mail/message/?action=reply` deep link (the + * triage widget). Null when there is nobody to reply to. `typed` is text + * the user already wrote (quick reply's "More options"); it goes above the + * quote. + */ +export function replyComposeParams( + mode: 'reply' | 'replyAll' | 'forward', + source: Email, + ownerAccountId?: string, + typed?: string, +): NonNullable | null { + const from = source.from?.[0]; + if (!from && mode !== 'forward') return null; + // Quote the HTML part when there is one so layout and inline images + // survive (#163). RFC 8621 §4.1.4: an HTML-only message exposes the same + // part in `textBody` and `htmlBody`, so the text part is only a real + // alternative when its partId differs - otherwise the composer would be + // handed raw HTML source (#649, native #46). + const picked = pickEmailBody(source); + const quoteHtml = picked.html ?? undefined; + const body = !quoteHtml || picked.text ? plainTextBody(source) : undefined; + return { + mode, + ...(typed?.trim() ? { prefillBody: typed } : null), + replyTo: { + from: from ?? { email: '' }, + to: source.to, + cc: source.cc, + // RFC 5322: a reply goes to Reply-To when the sender set one. + replyToAddresses: source.replyTo, + subject: source.subject ?? '', + body, + htmlBody: quoteHtml, + receivedAt: source.receivedAt, + sentAt: source.sentAt, + // Threading needs the RFC Message-ID, never the JMAP object id (#234). + messageId: source.messageId ?? undefined, + references: source.references ?? undefined, + // Forward carries the original attachments as blob refs; cid-embedded + // inline images are already part of the quoted HTML. + attachments: mode === 'forward' + ? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid)) + : undefined, + originalEmailId: source.id, + jmapAccountId: ownerAccountId, + }, + }; +} diff --git a/src/lib/report-failure.ts b/src/lib/report-failure.ts new file mode 100644 index 00000000..6deeffeb --- /dev/null +++ b/src/lib/report-failure.ts @@ -0,0 +1,17 @@ +import { toast } from '../stores/toast-store'; +import { useLocaleStore } from '../stores/locale-store'; + +/** + * Runs a store action the screen doesn't wait for (a swipe, a bulk button) + * and says when it fails. These used to be `void action()`: a change the + * server refused (not a dropped connection, which the outbox queues) went + * unnoticed. + */ +export function reportFailure(action: Promise): void { + action.catch((err: unknown) => { + toast.error( + useLocaleStore.getState().t('notifications.error_updating', 'Failed to update email'), + err instanceof Error ? err.message : undefined, + ); + }); +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index c8bdb69a..58fdc75d 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -11,14 +11,18 @@ // through t() for the current locale, with the English fallback. // Kept free of React so it can be unit-tested. +import { supportsDeviceSync } from './platform-capabilities'; + export type SettingsTabId = | 'account' | 'language' | 'notifications' | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' - | 'security' | 'encryption' | 'content_senders' + | 'security' | 'content_senders' | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' - | 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug'; + // about_data is Storage & backup (the id predates the split from About); + // debug, licences and privacy are pages opened from inside another one. + | 'about_data' | 'about' | 'licences' | 'privacy' | 'themes' | 'updates' | 'debug'; // Translation paths per pane. A path may point at a whole namespace, at one // setting's object ({ label, description, ...options }) or at a single label @@ -59,6 +63,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.appearance.list_density', 'settings.appearance.toolbar_labels', 'settings.appearance.animations', + 'settings.advanced.sender_favicons', ], layout: [ 'settings.layout.quick_actions', @@ -150,7 +155,6 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.security.encryption.encrypt_on_append', 'settings.security.encryption.allow_spam_training', ], - encryption: ['settings.smime'], content_senders: [ 'settings.email_behavior.external_content', 'settings.email_behavior.always_light_mode', @@ -166,6 +170,8 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'calendar.settings.show_birthday_calendar', 'calendar.settings.enable_tasks', 'calendar.settings.show_tasks_on_calendar', + // Android only: calendars in the phone's Calendar app (#34). + ...(supportsDeviceSync ? ['calendar.settings.device_sync_title'] : []), ], contacts: [ 'settings.contacts.group_by_letter_label', @@ -173,6 +179,8 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.contacts.import_label', 'settings.contacts.export_label', 'settings.contacts.manage_title', + // Android only: address books in the phone's Contacts app (#34). + ...(supportsDeviceSync ? ['settings.contacts.device_sync_title'] : []), ], files: [ 'files.settings_display', @@ -192,20 +200,25 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.sidebar_apps.add', ], about_data: [ - 'settings.advanced.debug_mode', - 'settings.advanced.sender_favicons', - 'settings.advanced.refresh_cache', - 'settings.advanced.export_settings', - 'settings.advanced.import_settings', - 'settings.advanced.reset_settings', 'settings.offline.title', 'settings.offline.enabled', - 'settings.offline.window', - 'settings.offline.max_size', + 'settings.offline.keep_from', + 'settings.offline.use_at_most', + 'settings.backup.title', + 'settings.backup.export_import', + 'settings.advanced.reset_settings', + 'settings.troubleshooting.title', ], + about: [ + 'settings.about.server', + 'settings.about.licences', + 'settings.about.privacy', + 'settings.about.report_problem', + ], + licences: [], + privacy: [], // The theme cards are added by the caller (built-in theme names). themes: [], - plugins: [], updates: [ 'updates.current_version', 'updates.latest_version', @@ -215,7 +228,10 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'updates.advisory', 'updates.release_page', ], - debug: [], + debug: [ + 'settings.advanced.refresh_cache', + 'settings.advanced.debug_mode', + ], }; // Extra English keywords per pane so common search terms hit even when the @@ -236,17 +252,18 @@ export const SETTINGS_SEARCH_KEYWORDS: Record = { keywords: 'tags labels colors', downloads: 'download filename template eml attachment save export', security: 'password 2fa two-factor totp app password mfa api key certificate encryption', - encryption: 's/mime smime certificate sign encrypt', content_senders: 'block sender remote images privacy tracking', - calendar: 'event schedule appointment meeting timezone', - contacts: 'address book contact', + calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`, + contacts: `address book contact${supportsDeviceSync ? ' android phone device sync' : ''}`, files: 'attachments cloud drive storage upload', sidebar_apps: 'apps webview iframe', - about_data: 'export import backup offline cache debug logs reset version', + about_data: 'export import backup offline cache storage reset', + about: 'version licence license privacy bug issue report source github', + licences: 'licence license open source', + privacy: 'privacy data relay push', themes: 'theme color colour palette skin appearance', - plugins: 'extensions addons', updates: 'update upgrade version apk install release', - debug: 'logs developer console diagnostic', + debug: 'troubleshooting refresh logs developer console diagnostic', }; export interface SubResult { diff --git a/src/lib/settings-summary.ts b/src/lib/settings-summary.ts new file mode 100644 index 00000000..49d88687 --- /dev/null +++ b/src/lib/settings-summary.ts @@ -0,0 +1,103 @@ +// The second line of each row in the Settings list: the current value where a +// pane has one worth showing ("Dark · Bulwark", "English · 24-hour"), and a +// short description of what's inside otherwise. Kept free of React so it can +// be unit-tested; SettingsScreen passes in the state. + +import type { SettingsTabId } from './settings-search'; +import type { MessageParams } from '../i18n/format'; + +type T = (key: string, fallback: string, params?: MessageParams) => string; + +export interface SummaryState { + languageLabel: string; + timeFormat: '12h' | '24h'; + theme: 'light' | 'dark' | 'system'; + /** Display name of the active built-in theme, or null for the default. */ + themeName: string | null; + emailNotificationsEnabled: boolean; + calendarNotificationsEnabled: boolean; + calendarDefaultView: 'month' | 'week' | 'day' | 'agenda'; + calendarFirstDayOfWeek: 0 | 1 | 6; + offlineCacheEnabled: boolean; + offlineCacheDays: number; + offlineCacheMaxMB: number; + appVersion: string; + /** 'none' when no check has run yet. */ + update: 'none' | 'current' | 'available' | 'security'; +} + +const WEEKDAY: Record<0 | 1 | 6, [string, string]> = { + 0: ['calendar.days.sunday', 'Sunday'], + 1: ['calendar.days.monday', 'Monday'], + 6: ['calendar.days.saturday', 'Saturday'], +}; + +const STATIC: Partial> = { + layout: ['settings.summary.layout', 'Quick actions, swipes, list order'], + reading: ['settings.summary.reading', 'Read status, delete, attachments'], + composing: ['settings.summary.composing', 'Signature, send delay, drafts'], + identities: ['settings.summary.identities', 'Addresses you send from'], + vacation: ['settings.summary.vacation', 'Automatic reply while away'], + filters: ['settings.summary.filters', 'Rules for incoming mail'], + templates: ['settings.summary.templates', 'Reusable messages'], + folders: ['settings.summary.folders', 'Create, rename, roles'], + keywords: ['settings.summary.keywords', 'Tag names and colours'], + downloads: ['settings.summary.downloads', 'File names for saved mail'], + security: ['settings.summary.security', 'Password, two-factor, app passwords'], + content_senders: ['settings.summary.content_senders', 'Remote images, trusted senders'], + contacts: ['settings.summary.contacts', 'Address books, sorting'], + files: ['settings.summary.files', 'View, sorting, thumbnails'], + sidebar_apps: ['settings.summary.sidebar_apps', 'Web apps in the side menu'], + updates: ['settings.summary.updates', 'Install new versions'], +}; + +export function settingsSummary(tab: SettingsTabId, s: SummaryState, t: T): string | null { + switch (tab) { + case 'language': + return [ + s.languageLabel, + t(`settings.language_region.time_format.${s.timeFormat}`, s.timeFormat === '12h' ? '12-hour' : '24-hour'), + ].join(' · '); + case 'notifications': + return [ + s.emailNotificationsEnabled + ? t('settings.summary.mail_on', 'Mail on') + : t('settings.summary.mail_off', 'Mail off'), + s.calendarNotificationsEnabled + ? t('settings.summary.events_on', 'events on') + : t('settings.summary.events_off', 'events off'), + ].join(' · '); + case 'appearance': + return [ + t(`settings.appearance.theme.${s.theme}`, s.theme[0].toUpperCase() + s.theme.slice(1)), + s.themeName ?? t('settings.themes.default_name', 'Bulwark'), + ].join(' · '); + case 'themes': + return s.themeName ?? t('settings.themes.default_name', 'Bulwark'); + case 'calendar': { + const [dayKey, dayFallback] = WEEKDAY[s.calendarFirstDayOfWeek] ?? WEEKDAY[1]; + const view = s.calendarDefaultView; + return [ + t(`calendar.views.${view}`, view[0].toUpperCase() + view.slice(1)), + t('settings.summary.week_starts', 'week starts {day}', { day: t(dayKey, dayFallback) }), + ].join(' · '); + } + case 'about_data': + return s.offlineCacheEnabled + ? t('settings.summary.offline_on', 'Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB', { + days: s.offlineCacheDays, mb: s.offlineCacheMaxMB, + }) + : t('settings.summary.offline_off', 'Offline mail off'); + case 'about': { + const version = t('settings.summary.version', 'Version {version}', { version: s.appVersion }); + if (s.update === 'current') return `${version} · ${t('settings.about.up_to_date', 'Up to date')}`; + if (s.update === 'available') return `${version} · ${t('updates.available', 'Update available')}`; + if (s.update === 'security') return `${version} · ${t('updates.banner.security_title', 'Security update available')}`; + return version; + } + default: { + const entry = STATIC[tab]; + return entry ? t(entry[0], entry[1]) : null; + } + } +} diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts index d2993b14..6b4bb552 100644 --- a/src/lib/sha256.ts +++ b/src/lib/sha256.ts @@ -1,8 +1,7 @@ -// Minimal SHA-256 implementation. Used to verify APK update downloads against -// the checksum published in the GitHub release body — the JS runtime has no -// built-in `crypto.subtle.digest` and we don't currently depend on -// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per -// MB on a modern phone, which is fine for the rare update flow. +// Minimal SHA-256 implementation. The fallback for verifying APK update +// downloads when expo-crypto's native digest is unavailable (see +// `hashFile` in install-update.ts); far too slow to be the main path. +// Public-domain algorithm (FIPS 180-4). const K = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, diff --git a/src/lib/sign-out-confirm.ts b/src/lib/sign-out-confirm.ts new file mode 100644 index 00000000..d428cf65 --- /dev/null +++ b/src/lib/sign-out-confirm.ts @@ -0,0 +1,31 @@ +import { Alert } from 'react-native'; + +type Translate = (key: string, fallback: string, params?: Record) => string; + +/** + * Asks before signing out: it drops the account's cached mail and push + * registration on this device, and a stray tap in the account menu or at the + * end of Settings did it straight away. + */ +export function confirmSignOut( + t: Translate, + { account, all = false }: { account?: string; all?: boolean }, + onConfirm: () => void, +): void { + const title = all + ? t('sidebar.sign_out_all_confirm_title', 'Sign out of all accounts?') + : account + ? t('sidebar.sign_out_confirm_title_account', 'Sign out of {account}?', { account }) + : t('sidebar.sign_out_confirm_title', 'Sign out?'); + const message = all + ? t('sidebar.sign_out_all_confirm_message', 'Every account is removed from this device. You can sign in again at any time.') + : t('sidebar.sign_out_confirm_message', 'The account is removed from this device. You can sign in again at any time.'); + Alert.alert(title, message, [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: all ? t('sidebar.sign_out_all', 'Sign out of all accounts') : t('sidebar.sign_out', 'Sign out'), + style: 'destructive', + onPress: onConfirm, + }, + ]); +} diff --git a/src/lib/signature-utils.ts b/src/lib/signature-utils.ts index f162a940..e778037f 100644 --- a/src/lib/signature-utils.ts +++ b/src/lib/signature-utils.ts @@ -90,6 +90,27 @@ export function signatureIdentityFor
' : '

--
'; + const sep = wantsSignatureSeparator(options.separator, signature) ? '

--
' : '

'; if (signature?.htmlSignature?.trim()) { return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`; @@ -208,7 +229,7 @@ export function buildEmbeddedSignatureHtml( identity: SignatureSource, options: { separator: boolean }, ): string { - const startMarker = options.separator + const startMarker = wantsSignatureSeparator(options.separator, identity) ? `

--

` : `

`; const endMarker = `


`; diff --git a/src/lib/template-utils.ts b/src/lib/template-utils.ts index 2fea0181..cf61d392 100644 --- a/src/lib/template-utils.ts +++ b/src/lib/template-utils.ts @@ -4,6 +4,7 @@ import type { EmailTemplate } from '../stores/templates-store'; import { generateUUID } from './uuid'; +import { intlLocale } from './intl-locale'; export const BUILT_IN_PLACEHOLDERS = [ 'recipient_name', @@ -63,13 +64,12 @@ export function getAutoFilledPlaceholders( context: AutoFillContext, ): Record { const now = new Date(); - const locale = context.locale || 'en'; - const intlLocale = locale === 'en' ? 'en-US' : locale; + const intlTag = intlLocale(context.locale); const values: Record = {}; try { - values.date = now.toLocaleDateString(intlLocale, { year: 'numeric', month: 'long', day: 'numeric' }); - values.day_of_week = now.toLocaleDateString(intlLocale, { weekday: 'long' }); + values.date = now.toLocaleDateString(intlTag, { year: 'numeric', month: 'long', day: 'numeric' }); + values.day_of_week = now.toLocaleDateString(intlTag, { weekday: 'long' }); } catch { values.date = now.toDateString(); values.day_of_week = now.toLocaleDateString(undefined, { weekday: 'long' }); diff --git a/src/lib/thread-utils.ts b/src/lib/thread-utils.ts index ad65ced3..84a4529c 100644 --- a/src/lib/thread-utils.ts +++ b/src/lib/thread-utils.ts @@ -1,4 +1,5 @@ import type { Email } from '../api/types'; +import type { KeywordDef } from '../stores/keywords-store'; // Port of the webmail's `lib/thread-utils.ts` (tag id extraction, thread // grouping helpers) trimmed to what the native list needs. @@ -23,6 +24,21 @@ export function getEmailTagIds(keywords: Record | undefined): s return [...tags]; } +/** + * The tags to draw for a message: every tag id on it, with its local + * definition where there is one. A tag no local definition explains (set by + * another client or by the server) is kept, grey and labelled with its raw + * id, so the header shows the same tags as the list row (#79). + */ +export function resolveEmailTags( + keywords: Record | undefined, + defs: readonly KeywordDef[], +): KeywordDef[] { + return getEmailTagIds(keywords).map( + (id) => defs.find((d) => d.id === id) ?? { id, label: id, color: 'gray' }, + ); +} + /** * Every tag anywhere in a thread, deduplicated. A collapsed thread row stands * in for all its messages, so it has to account for all their tags. diff --git a/src/lib/trusted-senders.ts b/src/lib/trusted-senders.ts index 3b5031f0..1853b63f 100644 --- a/src/lib/trusted-senders.ts +++ b/src/lib/trusted-senders.ts @@ -1,3 +1,13 @@ +/** + * Whether "Sync trusted senders to address book" is in effect. Never chosen + * (null) counts as on, so trusted senders land in the dedicated "Trusted + * Senders" address book out of the box; only the user's opt-out (false), or + * a server without contacts, turns it off. + */ +export function isTrustedSendersSyncOn(setting: boolean | null | undefined, hasContacts: boolean): boolean { + return hasContacts && setting !== false; +} + /** * Whether the viewer may load a sender's remote content (images, tracking * pixels) without asking. @@ -13,8 +23,8 @@ export function isSenderContentTrusted( opts: { /** The settings store's local allow-list check. */ isLocallyTrusted: (email: string) => boolean; - /** The "Sync trusted senders to address book" setting (null = never chosen, off). */ - syncEnabled: boolean | null; + /** Whether address book sync is in effect (`isTrustedSendersSyncOn`). */ + syncEnabled: boolean; /** Lowercased addresses filed in the "Trusted Senders" book. */ trustedBookEmails: readonly string[]; }, @@ -22,5 +32,5 @@ export function isSenderContentTrusted( const email = senderEmail?.trim(); if (!email) return false; if (opts.isLocallyTrusted(email)) return true; - return !!opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase()); + return opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase()); } diff --git a/src/lib/use-keyboard-obstruction.ts b/src/lib/use-keyboard-obstruction.ts new file mode 100644 index 00000000..df351537 --- /dev/null +++ b/src/lib/use-keyboard-obstruction.ts @@ -0,0 +1,77 @@ +import React from 'react'; +import { Dimensions, Keyboard, Platform, type KeyboardEvent } from 'react-native'; + +/** + * How much of the screen bottom the keyboard covers, given the keyboard's top + * edge (`screenY`) and its reported height. On Android edge-to-edge the + * reported height leaves out the gesture bar, so the gap from the keyboard's + * top to the screen bottom is the better measure; it is capped by the + * keyboard height plus the bottom inset so a stale screen size (a rotation + * that arrives before the keyboard's own event) can't produce an overlap + * taller than the keyboard. + */ +export function keyboardObstruction( + screenHeight: number, + screenY: number | undefined, + height: number | undefined, + bottomInset: number, +): number { + const fromTop = typeof screenY === 'number' ? screenHeight - screenY : NaN; + const reported = typeof height === 'number' && height > 0 ? height : NaN; + if (Number.isNaN(fromTop) && Number.isNaN(reported)) return 0; + if (Number.isNaN(fromTop)) return Math.max(0, reported); + if (Number.isNaN(reported)) return Math.max(0, fromTop); + return Math.max(0, Math.min(fromTop, reported + bottomInset)); +} + +/** + * Tracks the keyboard's overlap with the bottom of the screen. + * + * Android sends no keyboard event when the window rotates with the keyboard + * open, so the last frame (from the other orientation) is all there is: a + * portrait keyboard kept as the overlap blanked the whole landscape composer. + * A window change with the keyboard open therefore closes the keyboard; the + * text stays, and the next tap opens it with the right size. `onWindowChange` + * lets a screen drop focus that `Keyboard.dismiss` can't reach (a WebView). + */ +export function useKeyboardObstruction(bottomInset: number, onWindowChange?: () => void): number { + const [obstruction, setObstruction] = React.useState(0); + const insetRef = React.useRef(bottomInset); + insetRef.current = bottomInset; + const onWindowChangeRef = React.useRef(onWindowChange); + onWindowChangeRef.current = onWindowChange; + React.useEffect(() => { + const apply = (e?: Pick | null) => { + if (!e?.endCoordinates) { + setObstruction(0); + return; + } + const { screenY, height } = e.endCoordinates; + setObstruction(keyboardObstruction(Dimensions.get('screen').height, screenY, height, insetRef.current)); + }; + const subs = + Platform.OS === 'ios' + ? [ + Keyboard.addListener('keyboardWillChangeFrame', apply), + Keyboard.addListener('keyboardWillHide', () => setObstruction(0)), + ] + : [ + Keyboard.addListener('keyboardDidShow', apply), + Keyboard.addListener('keyboardDidHide', () => setObstruction(0)), + ]; + let lastWindow = Dimensions.get('window'); + const dims = Dimensions.addEventListener('change', ({ window }) => { + if (window.width === lastWindow.width && window.height === lastWindow.height) return; + lastWindow = window; + setObstruction(0); + if (!Keyboard.isVisible()) return; + Keyboard.dismiss(); + onWindowChangeRef.current?.(); + }); + return () => { + for (const s of subs) s.remove(); + dims.remove(); + }; + }, []); + return obstruction; +} diff --git a/src/lib/use-layout.ts b/src/lib/use-layout.ts new file mode 100644 index 00000000..31c1c175 --- /dev/null +++ b/src/lib/use-layout.ts @@ -0,0 +1,113 @@ +import React from 'react'; +import { useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +/** + * Window size classes for the adaptive layouts (docs/ux-audit-2026-10.md, + * "Adaptive layout plan"). They come from the window, not the device, so + * split-screen, freeform windows and foldables get the layout that fits. + */ +export type WidthClass = 'compact' | 'medium' | 'expanded'; + +export const MEDIUM_MIN_WIDTH = 600; +export const EXPANDED_MIN_WIDTH = 840; +/** Below this a window is "short": a phone in landscape. */ +export const SHORT_MAX_HEIGHT = 480; + +export interface Layout { + widthClass: WidthClass; + /** A phone in landscape: compress chrome, keep the single-pane layout. */ + shortHeight: boolean; + /** List and detail side by side (medium or expanded, and not short). */ + isSplit: boolean; + /** A persistent sidebar next to the list (expanded and not short). */ + hasSidebar: boolean; +} + +export function layoutFor(width: number, height: number): Layout { + const widthClass: WidthClass = + width >= EXPANDED_MIN_WIDTH ? 'expanded' : width >= MEDIUM_MIN_WIDTH ? 'medium' : 'compact'; + const shortHeight = height < SHORT_MAX_HEIGHT; + const isSplit = widthClass !== 'compact' && !shortHeight; + return { widthClass, shortHeight, isSplit, hasSidebar: isSplit && widthClass === 'expanded' }; +} + +export function useLayout(): Layout { + const { width, height } = useWindowDimensions(); + return layoutFor(width, height); +} + +/** + * Width of the list pane next to a detail pane: a third of the window, + * between 320 and 420 dp (the webmail's list column is about 400). + */ +export function listPaneWidth(windowWidth: number): number { + return Math.round(Math.max(320, Math.min(420, windowWidth * 0.34))); +} + +/** Width for sheets, dialogs and floating cards that would span a wide window. */ +export const SHEET_MAX_WIDTH = 560; + +/** + * Left and right offsets of a bottom sheet: centred at SHEET_MAX_WIDTH on a + * window 600 dp or wider (a phone in landscape included), else full width + * inside the side safe-area insets (a landscape notch or navigation bar). + */ +export function sheetSideInsets( + width: number, + insetLeft: number, + insetRight: number, +): { left: number; right: number } { + if (width >= MEDIUM_MIN_WIDTH) { + const side = Math.max(Math.round((width - SHEET_MAX_WIDTH) / 2), insetLeft, insetRight); + return { left: side, right: side }; + } + return { left: insetLeft, right: insetRight }; +} + +/** `sheetSideInsets` for the current window. */ +export function useSheetInsets(): { left: number; right: number } { + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { left, right } = sheetSideInsets(width, insets.left, insets.right); + return React.useMemo(() => ({ left, right }), [left, right]); +} + +/** APP.md toast card: 360 wide, 20 from the edges on a large screen. */ +const FLOATING_CARD_WIDTH = 360; +const FLOATING_CARD_MARGIN = 20; +/** Phone: full width with 12 dp margins, above the 64 dp tab bar. */ +const PHONE_CARD_MARGIN = 12; +const TAB_BAR_CLEARANCE = 64; + +/** + * Where toasts, the undo bar and the push prompt float. A phone gets the full + * width above the tab bar; a split layout (tabs in a side rail) gets the + * webmail's desktop placement, bottom right. + */ +export function floatingCardFrame( + width: number, + split: boolean, + insets: { left: number; right: number }, +): { left: number; right: number; bottom: number } { + if (split) { + return { + left: Math.max(FLOATING_CARD_MARGIN, width - FLOATING_CARD_WIDTH - FLOATING_CARD_MARGIN - insets.right), + right: FLOATING_CARD_MARGIN + insets.right, + bottom: FLOATING_CARD_MARGIN, + }; + } + return { + left: PHONE_CARD_MARGIN + insets.left, + right: PHONE_CARD_MARGIN + insets.right, + bottom: TAB_BAR_CLEARANCE, + }; +} + +/** `floatingCardFrame` for the current window; `bottom` excludes the safe-area inset. */ +export function useFloatingCardFrame(): { left: number; right: number; bottom: number } { + const { width, height } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const { left, right, bottom } = floatingCardFrame(width, layoutFor(width, height).isSplit, insets); + return React.useMemo(() => ({ left, right, bottom }), [left, right, bottom]); +} diff --git a/src/lib/zip-preview.ts b/src/lib/zip-preview.ts new file mode 100644 index 00000000..848c0544 --- /dev/null +++ b/src/lib/zip-preview.ts @@ -0,0 +1,90 @@ +// ZIP archives in the file preview: the entries as a list, and one entry's +// bytes when it is opened. Nothing is extracted to disk under the archive's +// paths; an opened entry goes through the same preview as any attachment. + +import type JSZip from 'jszip'; + +export interface ZipEntry { + /** Full path inside the archive, as stored. */ + path: string; + /** Last path segment. */ + name: string; + /** Folder path, '' at the top. */ + folder: string; + /** Uncompressed size, when the archive records it. */ + size?: number; + modified?: Date; +} + +export interface ZipArchive { + entries: ZipEntry[]; + /** True when the listing stopped at {@link MAX_ZIP_ENTRIES}. */ + truncated: boolean; + /** One entry's bytes. */ + read(path: string): Promise; +} + +/** The archive is password-protected; JSZip can't decrypt it. */ +export class ZipEncryptedError extends Error {} + +// Metadata the archives of macOS and Windows carry and nobody wants to open. +const JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|desktop\.ini$)/i; + +/** Most entries listed; a larger archive lists the first ones. */ +export const MAX_ZIP_ENTRIES = 2000; + +// JSZip keeps the directory's sizes here without exposing them. +interface ZipObjectInternals { + _data?: { uncompressedSize?: number }; +} + +/** + * Read an archive's directory: its files (not folders), sorted by folder and + * then name, and a reader for their contents. + */ +export async function openZip(bytes: Uint8Array): Promise { + const { default: Zip } = await import('jszip'); + let zip: JSZip; + try { + zip = await Zip.loadAsync(bytes, { createFolders: false }); + } catch (e) { + if (/encrypt/i.test(e instanceof Error ? e.message : String(e))) throw new ZipEncryptedError('encrypted'); + throw e; + } + const entries: ZipEntry[] = []; + let truncated = false; + for (const file of Object.values(zip.files)) { + if (file.dir || JUNK.test(file.name)) continue; + if (entries.length >= MAX_ZIP_ENTRIES) { + truncated = true; + break; + } + const path = file.name; + const slash = path.lastIndexOf('/'); + const size = (file as unknown as ZipObjectInternals)._data?.uncompressedSize; + entries.push({ + path, + name: slash >= 0 ? path.slice(slash + 1) : path, + folder: slash >= 0 ? path.slice(0, slash) : '', + size: typeof size === 'number' && size >= 0 ? size : undefined, + modified: file.date instanceof Date && !Number.isNaN(file.date.getTime()) ? file.date : undefined, + }); + } + entries.sort((a, b) => a.folder.localeCompare(b.folder) || a.name.localeCompare(b.name)); + return { + entries, + truncated, + read: async (path) => { + const file = zip.file(path); + if (!file) throw new Error(`${path} is not in the archive`); + return file.async('uint8array'); + }, + }; +} + +/** Whether a file is a ZIP the preview lists (not one of the formats built on ZIP). */ +export function isZipArchive(name: string, type?: string | null): boolean { + const mime = (type || '').split(';')[0].trim().toLowerCase(); + if (mime === 'application/zip' || mime === 'application/x-zip-compressed' || mime === 'application/x-zip') return true; + return /\.zip$/i.test(name); +} diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 07a64684..88d2e0d4 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi } from 'vitest'; -import { handleDeepLink, parseDeepLink, shareToDeepLink } from '../linking'; +import { acceptSignInLink, handleDeepLink, parseDeepLink, parseSignInLink, shareToDeepLink } from '../linking'; +import { usePendingSignInLinkStore } from '../pending-sign-in-link'; import { usePendingSettingsTab } from '../pending-settings-tab'; import { usePendingCalendarOpen } from '../pending-calendar-open'; +import { usePendingMailSearch } from '../pending-mail-search'; +import { links } from '../../widgets/clicks'; describe('parseDeepLink', () => { it('parses app-scheme mail links', () => { @@ -75,6 +78,26 @@ describe('parseDeepLink', () => { }); }); + it('parses the widget links: reply, draft, unified views, scheduled and search', () => { + expect(parseDeepLink('bulwarkmobile://mail/message/M1?account=a%40b&jmapAccount=team&action=reply')).toEqual({ + kind: 'message', emailId: 'M1', accountId: 'a@b', jmapAccountId: 'team', action: 'reply', + }); + // What the widgets build is what the app reads, thread included. + const m = { id: 'M/1', threadId: 'T1', accountId: 'a@b', jmapAccountId: 'team' }; + expect(parseDeepLink(links.reply(m))).toEqual({ + kind: 'message', emailId: 'M/1', accountId: 'a@b', jmapAccountId: 'team', threadId: 'T1', action: 'reply', + }); + expect(parseDeepLink(links.message({ id: 'M2', accountId: 'a@b' }))).toEqual({ + kind: 'message', emailId: 'M2', accountId: 'a@b', + }); + expect(parseDeepLink('bulwarkmobile://mail/draft/D1')).toEqual({ kind: 'draft', emailId: 'D1', accountId: undefined }); + expect(parseDeepLink('bulwarkmobile://mail/unified?view=starred')).toEqual({ kind: 'unified', view: 'starred' }); + expect(parseDeepLink('bulwarkmobile://mail/unified?role=drafts&view=bogus')).toEqual({ kind: 'unified', role: 'drafts' }); + expect(parseDeepLink('bulwarkmobile://mail/scheduled')).toEqual({ kind: 'scheduled' }); + expect(parseDeepLink('bulwarkmobile://mail/search?q=from%3Aada')).toEqual({ kind: 'search', query: 'from:ada' }); + expect(parseDeepLink('bulwarkmobile://mail/search')).toEqual({ kind: 'search', query: '' }); + }); + it('rejects unknown links', () => { expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull(); expect(parseDeepLink('garbage')).toBeNull(); @@ -130,6 +153,21 @@ describe('handleDeepLink', () => { expect(usePendingCalendarOpen.getState().consume()).toBeNull(); }); + it('switches to the signed-in account a widget event link names first', async () => { + const navigation = nav(); + const url = links.event({ serverId: 'j', accountId: 'usera@example.org@mail', jmapAccountId: 'd' }); + const link = parseDeepLink(url); + expect(link).toEqual({ kind: 'calendar', eventId: 'j', jmapAccountId: 'd', accountId: 'usera@example.org@mail' }); + const switchAccount = vi.fn(async () => true); + await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null, switchAccount }); + expect(switchAccount).toHaveBeenCalledWith('usera@example.org@mail'); + expect(usePendingCalendarOpen.getState().consume()).toMatchObject({ serverId: 'j', accountId: 'd' }); + // An account that is no longer signed in opens nothing. + expect(await handleDeepLink(link!, { + navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => false, + })).toBe(false); + }); + it('parks the settings tab and opens the Settings tab', async () => { const navigation = nav(); await handleDeepLink( @@ -166,6 +204,51 @@ describe('handleDeepLink', () => { })); }); + it('opens a group-mailbox message in its JMAP account', async () => { + const navigation = nav(); + const resolveThreadId = vi.fn(async () => 'T1'); + await handleDeepLink( + { kind: 'message', emailId: 'M1', jmapAccountId: 'team' }, + { navigation: navigation as never, resolveThreadId }, + ); + expect(resolveThreadId).toHaveBeenCalledWith('M1', 'team'); + expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M1', threadId: 'T1', jmapAccountId: 'team' }); + }); + + it('opens a reply over its message, without a lookup when the link names the thread', async () => { + // Offline, or before the session is back: the lookup would fail. + const resolveThreadId = vi.fn(async () => null); + const navigation = nav(); + const ok = await handleDeepLink( + { kind: 'message', emailId: 'M2', threadId: 'T2', action: 'reply' }, + { navigation: navigation as never, resolveThreadId }, + ); + expect(ok).toBe(true); + expect(resolveThreadId).not.toHaveBeenCalled(); + expect(navigation.navigate).toHaveBeenCalledWith('EmailThread', { emailId: 'M2', threadId: 'T2', action: 'reply' }); + + // Without the thread and without a server, nothing opens (the app says so). + expect(await handleDeepLink( + { kind: 'message', emailId: 'M3', action: 'reply' }, + { navigation: nav() as never, resolveThreadId }, + )).toBe(false); + }); + + it('opens drafts, unified views, scheduled mail and searches', async () => { + const openDraft = vi.fn(async () => true); + const navigation = nav(); + const deps = { navigation: navigation as never, resolveThreadId: async () => 'T', openDraft }; + expect(await handleDeepLink({ kind: 'draft', emailId: 'D1' }, deps)).toBe(true); + expect(openDraft).toHaveBeenCalledWith('D1', undefined); + await handleDeepLink({ kind: 'unified', view: 'starred' }, deps); + expect(navigation.navigate).toHaveBeenCalledWith('UnifiedInbox', { view: 'starred' }); + await handleDeepLink({ kind: 'scheduled' }, deps); + expect(navigation.navigate).toHaveBeenCalledWith('Scheduled'); + await handleDeepLink({ kind: 'search', query: 'from:ada' }, deps); + expect(usePendingMailSearch.getState().consume()).toBe('from:ada'); + expect(navigation.navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Mail' }); + }); + it('refuses when the linked account is not signed in', async () => { const navigation = nav(); const ok = await handleDeepLink( @@ -190,3 +273,61 @@ describe('shareToDeepLink', () => { }); }); }); + +describe('sign-in links', () => { + const code = 'c0ffee'.repeat(10) + 'beef'; + const pair = `bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com/webmail')}&code=${code}`; + + it('are never navigation links', () => { + expect(parseDeepLink(pair)).toBeNull(); + expect(parseDeepLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBeNull(); + // The scheme is not an alias of the app scheme. + expect(parseDeepLink('bulwarkmail://mail/message/M1')).toBeNull(); + }); + + it('parses pair and connect links, and nothing else', () => { + expect(parseSignInLink(pair)).toEqual({ kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code }); + expect(parseSignInLink(pair.replace('://pair?', '://pair/?'))).toEqual({ + kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code, + }); + expect(parseSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')) + .toEqual({ kind: 'connect', webmailUrl: 'https://mail.example.com' }); + // A plain webmail address is a QR convenience, not a link that signs in. + expect(parseSignInLink('https://mail.example.com')).toBeNull(); + expect(parseSignInLink('bulwarkmobile://mail/message/M1')).toBeNull(); + expect(parseSignInLink(`bulwarkmail://pair?server=http%3A%2F%2Fmail.example.com&code=${code}`)).toBeNull(); + expect(parseSignInLink(null)).toBeNull(); + }); + + it('parks a sign-in link from the OS as external, for the login screen to take once', () => { + usePendingSignInLinkStore.getState().clear(); + expect(acceptSignInLink('bulwarkmobile://mail')).toBe(false); + expect(acceptSignInLink('mailto:a@b.co')).toBe(false); + expect(usePendingSignInLinkStore.getState().pending).toBeNull(); + + expect(acceptSignInLink(pair)).toBe(true); + expect(usePendingSignInLinkStore.getState().pending).toMatchObject({ + source: 'external', + payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code }, + }); + // Anyone can fire the link, so it is not run until the user confirms. + expect(usePendingSignInLinkStore.getState().take()).toMatchObject({ + needsConfirmation: true, + payload: { kind: 'pair', webmailUrl: 'https://mail.example.com/webmail', code }, + }); + // Taken means cleared: a second delivery of the same state signs in nothing. + expect(usePendingSignInLinkStore.getState().pending).toBeNull(); + expect(usePendingSignInLinkStore.getState().take()).toBeNull(); + usePendingSignInLinkStore.getState().clear(); + }); + + it('parks connect links as external too', () => { + usePendingSignInLinkStore.getState().clear(); + expect(acceptSignInLink('bulwarkmail://connect?server=https%3A%2F%2Fmail.example.com')).toBe(true); + expect(usePendingSignInLinkStore.getState().take()).toMatchObject({ + needsConfirmation: true, + payload: { kind: 'connect', webmailUrl: 'https://mail.example.com' }, + }); + usePendingSignInLinkStore.getState().clear(); + }); +}); diff --git a/src/navigation/__tests__/pending-sign-in-link.test.ts b/src/navigation/__tests__/pending-sign-in-link.test.ts new file mode 100644 index 00000000..d51b2e9c --- /dev/null +++ b/src/navigation/__tests__/pending-sign-in-link.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { acceptSignInLink, routeParkedSignInLink } from '../linking'; +import { + setPendingSignInLink, + signInLinkNeedsConfirmation, + usePendingSignInLinkStore, +} from '../pending-sign-in-link'; +import type { QrLoginPayload } from '../../lib/oauth'; +import { MAX_ACCOUNTS } from '../../lib/account-utils'; + +// A `bulwarkmail://` link from outside the app (any web page, one tap; any +// installed app, no tap) names a webmail and a code of the sender's choosing. +// It must not be redeemed or opened until the user says Continue; a scanned +// or pasted one is the user's own doing in the app and runs straight away. + +const code = 'c0ffee'.repeat(10) + 'beef'; +const link = (server: string) => + `bulwarkmail://pair?server=${encodeURIComponent(server)}&code=${code}`; +const ATTACKER = link('https://attacker.example'); +const OTHER = link('https://other.example'); + +const store = () => usePendingSignInLinkStore.getState(); + +beforeEach(() => store().clear()); + +describe('sign-in link confirmation', () => { + it('asks for links from outside the app, not for scanned or pasted ones', () => { + expect(signInLinkNeedsConfirmation('external')).toBe(true); + expect(signInLinkNeedsConfirmation('in-app')).toBe(false); + }); + + it('holds an external link until the user continues, then runs it once', () => { + acceptSignInLink(ATTACKER); + const taken = store().take(); + expect(taken).toMatchObject({ needsConfirmation: true, payload: { kind: 'pair', webmailUrl: 'https://attacker.example', code } }); + // Taken for the dialog, not handed out to run. + expect(store().pending).toBeNull(); + expect(store().awaitingConfirmation?.id).toBe(taken!.id); + expect(store().take()).toBeNull(); + + expect(store().confirm(taken!.id)).toEqual({ kind: 'pair', webmailUrl: 'https://attacker.example', code }); + expect(store().awaitingConfirmation).toBeNull(); + // A second Continue (or a stale dialog) runs nothing. + expect(store().confirm(taken!.id)).toBeNull(); + }); + + it('forgets a cancelled link, so nothing redeems it later', () => { + acceptSignInLink(ATTACKER); + const taken = store().take()!; + expect(store().discard(taken.id)).toBe(true); + expect(store().confirm(taken.id)).toBeNull(); + expect(store().take()).toBeNull(); + expect(store().pending).toBeNull(); + expect(store().awaitingConfirmation).toBeNull(); + expect(store().discard(taken.id)).toBe(false); + }); + + it('drops links delivered while the dialog is open instead of swapping them in', () => { + acceptSignInLink(ATTACKER); + const shown = store().take()!; + // A second 'url' event, or getInitialURL delivering again. + expect(acceptSignInLink(OTHER)).toBe(true); + expect(acceptSignInLink(ATTACKER)).toBe(true); + expect(store().pending).toBeNull(); + expect(store().take()).toBeNull(); + // Continue runs the link the dialog named, nothing else. + expect(store().confirm(shown.id)).toMatchObject({ webmailUrl: 'https://attacker.example' }); + expect(store().take()).toBeNull(); + }); + + it('asks again when a link is delivered again after the dialog closed', () => { + acceptSignInLink(ATTACKER); + const first = store().take()!; + store().discard(first.id); + + acceptSignInLink(ATTACKER); + const again = store().take()!; + expect(again.needsConfirmation).toBe(true); + expect(again.id).not.toBe(first.id); + // The cancelled dialog's id cannot confirm the new delivery. + expect(store().confirm(first.id)).toBeNull(); + expect(store().awaitingConfirmation?.id).toBe(again.id); + }); + + it('keeps the newest of two links parked before the login screen takes one', () => { + acceptSignInLink(ATTACKER); + acceptSignInLink(OTHER); + expect(store().take()).toMatchObject({ needsConfirmation: true, payload: { webmailUrl: 'https://other.example' } }); + }); + + it('runs an in-app link without a dialog', () => { + const payload: QrLoginPayload = { kind: 'connect', webmailUrl: 'https://mail.example.com' }; + expect(setPendingSignInLink(payload, 'in-app')).toBe(true); + expect(store().take()).toMatchObject({ needsConfirmation: false, payload }); + expect(store().awaitingConfirmation).toBeNull(); + expect(store().pending).toBeNull(); + }); +}); + +describe('refused sign-in links', () => { + it('parks a pairing link for a plain-http webmail as a refusal, with nothing to run', () => { + expect(acceptSignInLink(link('http://mail.example.com'))).toBe(true); + expect(store().pending).toBeNull(); + expect(store().take()).toBeNull(); + const refusal = store().takeRefusal(); + expect(refusal).toMatchObject({ name: 'PairingError', reason: 'insecure', host: 'mail.example.com' }); + expect(store().takeRefusal()).toBeNull(); + }); + + it('ignores other broken sign-in links, as before', () => { + expect(acceptSignInLink(`bulwarkmail://pair?server=${encodeURIComponent('https://mail.example.com')}`)).toBe(false); + expect(acceptSignInLink('bulwarkmail://unlock?server=https://mail.example.com')).toBe(false); + expect(store().refusal).toBeNull(); + expect(store().pending).toBeNull(); + }); +}); + +describe('routeParkedSignInLink (signed in)', () => { + it('opens Add account while there is room', () => { + acceptSignInLink(ATTACKER); + expect(routeParkedSignInLink(MAX_ACCOUNTS - 1)).toBe('add-account'); + // Still parked for Add account's login screen. + expect(store().pending).not.toBeNull(); + }); + + it('drops the link and reports the limit when the registry is full, so no code is spent', () => { + acceptSignInLink(ATTACKER); + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit'); + expect(store().pending).toBeNull(); + expect(store().take()).toBeNull(); + + acceptSignInLink(link('http://mail.example.com')); + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('account-limit'); + expect(store().refusal).toBeNull(); + }); + + it('does nothing when no link is parked', () => { + expect(routeParkedSignInLink(0)).toBe('none'); + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none'); + }); + + it('leaves a link the dialog is asking about alone', () => { + acceptSignInLink(ATTACKER); + const shown = store().take()!; + expect(routeParkedSignInLink(MAX_ACCOUNTS)).toBe('none'); + expect(store().confirm(shown.id)).not.toBeNull(); + }); +}); diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 14a8bf48..8b786423 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -8,16 +8,37 @@ import { parseMailtoUrl } from '../lib/mailto'; import type { RootStackParamList } from './types'; import { setPendingSettingsTab } from './pending-settings-tab'; import { setPendingCalendarOpen } from './pending-calendar-open'; +import { setPendingMailSearch } from './pending-mail-search'; +import { setPendingSignInLink, usePendingSignInLinkStore } from './pending-sign-in-link'; +import { insecurePairingLinkError, parseQrLoginPayload, type QrLoginPayload } from '../lib/oauth'; +import { MAX_ACCOUNTS } from '../lib/account-utils'; export const APP_SCHEME = 'bulwarkmobile'; +// The webmail's "Link Mobile App" links (`bulwarkmail://pair?server=…&code=…`) +// and server-bootstrap links (`bulwarkmail://connect?server=…`). They sign in +// rather than navigate, so they never become a DeepLink. +export const SIGN_IN_SCHEME = 'bulwarkmail'; + +const UNIFIED_ROLES = ['inbox', 'sent', 'drafts', 'junk', 'archive', 'trash'] as const; +const UNIFIED_VIEWS = ['all', 'unread', 'starred'] as const; +type UnifiedRole = typeof UNIFIED_ROLES[number]; +type UnifiedView = typeof UNIFIED_VIEWS[number]; export type DeepLink = - | { kind: 'message'; emailId: string; accountId?: string } + // `jmapAccountId`: the JMAP account owning a message in a group/shared + // mailbox (`?jmapAccount=`); `threadId` (`?thread=`) saves looking it up; + // `action: 'reply'` opens a reply to it. + | { kind: 'message'; emailId: string; accountId?: string; jmapAccountId?: string; threadId?: string; action?: 'reply' } + | { kind: 'draft'; emailId: string; accountId?: string; jmapAccountId?: string } | { kind: 'thread'; threadId: string; accountId?: string } | { kind: 'folder'; ref: string; accountId?: string } + | { kind: 'unified'; role?: UnifiedRole; view?: UnifiedView } + | { kind: 'scheduled' } + | { kind: 'search'; query: string } // `jmapAccountId`: the JMAP account owning the event (`?account=`), for - // one on a calendar shared with the user. - | { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string } + // one on a calendar shared with the user; `accountId`: the signed-in + // account it belongs to (`?appAccount=`, from widgets). + | { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string; accountId?: string } | { kind: 'contact'; contactId: string } | { kind: 'contacts' } | { kind: 'files' } @@ -61,6 +82,7 @@ function splitUrl(url: string): { segments: string[]; search: URLSearchParams } export function parseDeepLink(url: string): DeepLink | null { if (!url) return null; + if (isSignInSchemeUrl(url)) return null; if (/^mailto:/i.test(url)) { const parsed = parseMailtoUrl(url); if (!parsed) return null; @@ -82,7 +104,32 @@ export function parseDeepLink(url: string): DeepLink | null { switch (area) { case 'mail': { - if (kind === 'message' && value) return { kind: 'message', emailId: decodeSegment(value), accountId }; + const jmapAccountId = search.get('jmapAccount') ?? undefined; + if (kind === 'message' && value) { + const threadId = search.get('thread'); + return { + kind: 'message', + emailId: decodeSegment(value), + accountId, + ...(jmapAccountId ? { jmapAccountId } : {}), + ...(threadId ? { threadId } : {}), + ...(search.get('action') === 'reply' ? { action: 'reply' as const } : {}), + }; + } + if (kind === 'draft' && value) { + return { kind: 'draft', emailId: decodeSegment(value), accountId, ...(jmapAccountId ? { jmapAccountId } : {}) }; + } + if (kind === 'unified') { + const role = search.get('role'); + const view = search.get('view'); + return { + kind: 'unified', + ...(role && (UNIFIED_ROLES as readonly string[]).includes(role) ? { role: role as UnifiedRole } : {}), + ...(view && (UNIFIED_VIEWS as readonly string[]).includes(view) ? { view: view as UnifiedView } : {}), + }; + } + if (kind === 'scheduled') return { kind: 'scheduled' }; + if (kind === 'search') return { kind: 'search', query: search.get('q') ?? '' }; if (kind === 'thread' && value) return { kind: 'thread', threadId: decodeSegment(value), accountId }; if (kind === 'folder' && value) return { kind: 'folder', ref: decodeSegment(value), accountId }; // Legacy `?email=` from the webmail's older service worker. @@ -93,9 +140,13 @@ export function parseDeepLink(url: string): DeepLink | null { case 'calendar': { if (kind === 'event' && value) { const jmapAccountId = search.get('account') ?? undefined; - return jmapAccountId - ? { kind: 'calendar', eventId: decodeSegment(value), jmapAccountId } - : { kind: 'calendar', eventId: decodeSegment(value) }; + const appAccountId = search.get('appAccount') ?? undefined; + return { + kind: 'calendar', + eventId: decodeSegment(value), + ...(jmapAccountId ? { jmapAccountId } : {}), + ...(appAccountId ? { accountId: appAccountId } : {}), + }; } const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s)); return { kind: 'calendar', date }; @@ -128,11 +179,61 @@ export function parseDeepLink(url: string): DeepLink | null { } } +function isSignInSchemeUrl(url: string): boolean { + return url.trim().toLowerCase().startsWith(`${SIGN_IN_SCHEME}:`); +} + +/** A `bulwarkmail://pair|connect?…` link, parsed; null for any other URL. */ +export function parseSignInLink(url: string | null | undefined): QrLoginPayload | null { + if (!url || !isSignInSchemeUrl(url)) return null; + return parseQrLoginPayload(url); +} + +/** + * Park a sign-in link the OS delivered (a tapped link, or another app firing + * the intent) for the login screen, which takes it (the signed-out one, or + * Add account when someone is signed in) and asks the user before running + * it: whoever sent it chose the webmail and the code. A pairing link for a + * plain-http webmail is parked as a refusal instead, for the login screen to + * say why nothing happens. Returns whether `url` was a sign-in link. + */ +export function acceptSignInLink(url: string | null | undefined): boolean { + const payload = parseSignInLink(url); + if (payload) { + setPendingSignInLink(payload, 'external'); + return true; + } + const refusal = url && isSignInSchemeUrl(url) ? insecurePairingLinkError(url) : null; + if (!refusal) return false; + usePendingSignInLinkStore.getState().refuse(refusal); + return true; +} + +/** + * Signed in, where a parked sign-in link goes: Add account, whose login + * screen takes it, or, with no room for another account, nowhere: the link + * is dropped before a code is spent and the caller shows the limit. 'none' + * when nothing is parked (taken meanwhile by an Add account already open). + */ +export function routeParkedSignInLink(accountCount: number): 'add-account' | 'account-limit' | 'none' { + const links = usePendingSignInLinkStore.getState(); + if (!links.pending && !links.refusal) return 'none'; + if (accountCount >= MAX_ACCOUNTS) { + links.dropParked(); + return 'account-limit'; + } + return 'add-account'; +} + export interface DeepLinkNavigator { navigation: NavigationContainerRefWithCurrent; // Resolve a message id to its thread (EmailThread needs both). Returns - // null when the message cannot be loaded. - resolveThreadId: (emailId: string) => Promise; + // null when the message cannot be loaded. `jmapAccountId` names the + // group/shared account holding it, when it is not the user's own. + resolveThreadId: (emailId: string, jmapAccountId?: string) => Promise; + // Open the composer on a server draft. Resolve false when the draft cannot + // be loaded. + openDraft?: (emailId: string, jmapAccountId?: string) => Promise; // Switch to the account a permalink names (`?account=`); resolves false // when that account is not signed in on this device. switchAccount?: (accountId: string) => Promise; @@ -149,11 +250,34 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr switch (link.kind) { case 'message': { - const threadId = await nav.resolveThreadId(link.emailId); + const threadId = link.threadId ?? await nav.resolveThreadId(link.emailId, link.jmapAccountId); if (!threadId) return false; - navigation.navigate('EmailThread', { emailId: link.emailId, threadId }); + // A reply opens over the message, like the reader's own Reply: the + // reader loads it (from its cache when offline) and then opens the + // composer, or shows why it could not. + navigation.navigate('EmailThread', { + emailId: link.emailId, + threadId, + ...(link.jmapAccountId ? { jmapAccountId: link.jmapAccountId } : {}), + ...(link.action === 'reply' ? { action: 'reply' as const } : {}), + }); return true; } + case 'draft': + return nav.openDraft ? nav.openDraft(link.emailId, link.jmapAccountId) : false; + case 'unified': + navigation.navigate('UnifiedInbox', { + ...(link.role ? { role: link.role } : {}), + ...(link.view ? { view: link.view } : {}), + }); + return true; + case 'scheduled': + navigation.navigate('Scheduled'); + return true; + case 'search': + setPendingMailSearch(link.query); + navigation.navigate('MainTabs', { screen: 'Mail' } as never); + return true; case 'thread': // The reader keys on the message; without one, open the list. navigation.navigate('MainTabs', { screen: 'Mail' } as never); diff --git a/src/navigation/pane.tsx b/src/navigation/pane.tsx new file mode 100644 index 00000000..5ff21676 --- /dev/null +++ b/src/navigation/pane.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { NavigationProvider, type NavigationProp, type ParamListBase } from '@react-navigation/native'; + +/** True inside the detail pane of a split view. */ +const PaneContext = React.createContext(false); + +/** A screen in a split view's detail pane has no back button. */ +export function useInPane(): boolean { + return React.useContext(PaneContext); +} + +/** Routes a pane shows itself instead of pushing them on the stack. */ +export type PaneTarget = { name: string; params?: object }; + +/** + * Runs a stack screen inside a split view's detail pane. The screen's own + * `useNavigation()` / `useRoute()` get the pane: `goBack` closes it, the + * routes `onOpen` accepts open in it, everything else goes to the stack. + */ +export function PaneScreen({ + navigation, + name, + params, + onOpen, + onClose, + children, +}: { + navigation: NavigationProp; + name: string; + params: object; + /** Return true when the pane shows this route itself. */ + onOpen: (target: PaneTarget) => boolean; + onClose: () => void; + children: React.ReactNode; +}) { + const route = React.useMemo(() => ({ key: `pane-${name}`, name, params }), [name, params]); + const paneNavigation = React.useMemo(() => { + const open = (method: 'navigate' | 'push' | 'replace') => (...args: unknown[]) => { + const [target, targetParams] = args as [string, object | undefined]; + if (typeof target === 'string' && onOpen({ name: target, params: targetParams })) return; + // `push` and `replace` only exist on a stack; from a tab they become a + // navigate, which reaches the root stack all the same. + const real = navigation as unknown as Record void) | undefined>; + (real[method] ?? real.navigate)!(...args); + }; + return { + ...navigation, + goBack: onClose, + navigate: open('navigate'), + push: open('push'), + replace: open('replace'), + canGoBack: () => true, + } as unknown as NavigationProp; + }, [navigation, onOpen, onClose]); + return ( + + + {children} + + + ); +} diff --git a/src/navigation/pending-mail-search.ts b/src/navigation/pending-mail-search.ts new file mode 100644 index 00000000..1f5677ae --- /dev/null +++ b/src/navigation/pending-mail-search.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +// A `mail/search` deep link (the search widget) arrives before the mail list +// is mounted, or while it shows a folder. The query is parked here and +// consumed by EmailListScreen, which runs it like a submitted search; an +// empty query just opens the search field. +interface PendingMailSearchState { + query: string | null; + set: (query: string | null) => void; + consume: () => string | null; +} + +export const usePendingMailSearch = create((set, get) => ({ + query: null, + set: (query) => set({ query }), + consume: () => { + const query = get().query; + if (query !== null) set({ query: null }); + return query; + }, +})); + +export function setPendingMailSearch(query: string | null): void { + usePendingMailSearch.getState().set(query); +} diff --git a/src/navigation/pending-sign-in-link.ts b/src/navigation/pending-sign-in-link.ts new file mode 100644 index 00000000..c8c5fa53 --- /dev/null +++ b/src/navigation/pending-sign-in-link.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand'; +import type { PairingError, QrLoginPayload } from '../lib/oauth'; + +// A tapped `bulwarkmail://` sign-in link arrives before the login screen is +// up (cold start, or signed in with Add account not open yet). It is parked +// here and taken by LoginScreen, which runs it like a scanned code. Taking +// clears it, so one link signs in once however often it is delivered. +// +// Where a link came from decides whether the user is asked first: +// - `external`: the OS handed it to the app. Any web page (one tap) or any +// installed app (no tap at all) can fire one, with a webmail and a code of +// its own choosing, so the user confirms before it is redeemed or opened. +// - `in-app`: scanned with the app's scanner or pasted into its paste box. +// The user just did that in the app, so it runs straight away. +export type SignInLinkSource = 'external' | 'in-app'; + +export interface PendingSignInLink { + // Tells one delivery from the next, so a dialog can only ever confirm the + // link it named. + id: number; + payload: QrLoginPayload; + source: SignInLinkSource; +} + +export interface TakenSignInLink { + id: number; + payload: QrLoginPayload; + // Show the confirmation and run the link only on Continue (`confirm`). + needsConfirmation: boolean; +} + +export function signInLinkNeedsConfirmation(source: SignInLinkSource): boolean { + return source !== 'in-app'; +} + +interface PendingSignInLinkState { + // Parked, not taken by a login screen yet. + pending: PendingSignInLink | null; + // Taken and shown to the user, waiting for Continue or Cancel. + awaitingConfirmation: PendingSignInLink | null; + // A tapped link that is refused outright (a pairing code for a plain-http + // webmail): nothing to run or confirm, the login screen says why. + refusal: PairingError | null; + /** Park a link. Returns false when it was dropped (a dialog is open). */ + park: (payload: QrLoginPayload, source: SignInLinkSource) => boolean; + /** + * Take the parked link. One that needs confirmation moves to + * `awaitingConfirmation`; run it only with what `confirm` returns. + */ + take: () => TakenSignInLink | null; + /** Continue: the payload to run, or null when that link is gone. */ + confirm: (id: number) => QrLoginPayload | null; + /** + * Cancel: forget the link, so nothing redeems it later. Returns whether it + * was still waiting. + */ + discard: (id: number) => boolean; + refuse: (refusal: PairingError) => void; + takeRefusal: () => PairingError | null; + /** Drop what is parked (not a link a dialog is asking about). */ + dropParked: () => boolean; + clear: () => void; +} + +let nextId = 1; + +export const usePendingSignInLinkStore = create((set, get) => ({ + pending: null, + awaitingConfirmation: null, + refusal: null, + park: (payload, source) => { + // While the user is asked about one link, another is dropped rather + // than queued behind it or swapped in: Continue must run the link the + // dialog names and nothing else, and a link fired while the dialog is + // open must not pop up right after Cancel. + if (get().awaitingConfirmation) return false; + set({ pending: { id: nextId++, payload, source } }); + return true; + }, + take: () => { + const { pending, awaitingConfirmation } = get(); + if (!pending || awaitingConfirmation) return null; + const needsConfirmation = signInLinkNeedsConfirmation(pending.source); + set({ pending: null, awaitingConfirmation: needsConfirmation ? pending : null }); + return { id: pending.id, payload: pending.payload, needsConfirmation }; + }, + confirm: (id) => { + const link = get().awaitingConfirmation; + if (!link || link.id !== id) return null; + set({ awaitingConfirmation: null }); + return link.payload; + }, + discard: (id) => { + if (get().awaitingConfirmation?.id !== id) return false; + set({ awaitingConfirmation: null }); + return true; + }, + refuse: (refusal) => set({ refusal }), + takeRefusal: () => { + const refusal = get().refusal; + if (refusal) set({ refusal: null }); + return refusal; + }, + dropParked: () => { + const { pending, refusal } = get(); + if (!pending && !refusal) return false; + set({ pending: null, refusal: null }); + return true; + }, + clear: () => set({ pending: null, awaitingConfirmation: null, refusal: null }), +})); + +export function setPendingSignInLink(payload: QrLoginPayload, source: SignInLinkSource): boolean { + return usePendingSignInLinkStore.getState().park(payload, source); +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 3ed53acb..098f7c8f 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -67,6 +67,8 @@ export type RootStackParamList = { * viewer pages over the store's current folder. */ emailIds?: string[]; + /** Open the composer on a reply once the message has loaded (widget Reply). */ + action?: 'reply'; }; EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string }; Compose: diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 6e83aaa5..6c8bedf7 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -8,25 +8,25 @@ import { StyleSheet, Pressable, ActivityIndicator, - ScrollView, - RefreshControl, + useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { multiDayCount } from '../lib/calendar-time-grid'; import { + Calendar as CalendarIcon, + ChevronDown, ChevronLeft, ChevronRight, - Plus, - Calendar1, - CalendarDays, - LayoutGrid, - List as ListIcon, + Columns3, + Columns4, + Grid3x3, ListChecks, Menu, -} from 'lucide-react-native'; + Rows2, + Square, +} from '../components/icons'; import { format, - startOfWeek, - endOfWeek, addDays, subDays, addMonths, @@ -36,10 +36,9 @@ import { type Locale, } from 'date-fns'; import { useCalendarLocale } from '../lib/calendar-locale'; -import { displayNow, isDisplayToday } from '../lib/calendar-timezone'; +import { displayNow } from '../lib/calendar-timezone'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; -import { Button } from '../components'; import { useCalendarStore } from '../stores/calendar-store'; import { useSettingsStore } from '../stores/settings-store'; import { MonthView } from '../components/calendar/MonthView'; @@ -47,14 +46,15 @@ import { MonthScrollView } from '../components/calendar/MonthScrollView'; import { WeekView } from '../components/calendar/WeekView'; import { TimeGridScrollView } from '../components/calendar/TimeGridScrollView'; import { AgendaView } from '../components/calendar/AgendaView'; -import { EventCard } from '../components/calendar/EventCard'; import { EventDetailSheet } from '../components/calendar/EventDetailSheet'; import { EventModal } from '../components/calendar/EventModal'; import { RecurrenceScopeDialog, type RecurrenceEditScope, } from '../components/calendar/RecurrenceScopeDialog'; -import { CalendarSidebarDrawer } from '../components/calendar/CalendarSidebarDrawer'; +import { CalendarSidebarDrawer, type DrawerViewOption } from '../components/calendar/CalendarSidebarDrawer'; +import { CalendarFab } from '../components/calendar/CalendarFab'; +import { MonthDropdown } from '../components/calendar/MonthDropdown'; import { TasksSheet } from '../components/calendar/TasksSheet'; import { ICalImportSheet } from '../components/calendar/ICalImportSheet'; import { ICalSubscriptionSheet } from '../components/calendar/ICalSubscriptionSheet'; @@ -71,6 +71,7 @@ import { scrollWindowLoadRange, windowStateForJump, type CalendarFocus, + type ScrollViewMode, type ScrollWindowOptions, type ScrollWindowState, } from '../lib/calendar-scroll-window'; @@ -81,14 +82,12 @@ import { buildEventDayIndex, changedCalendarFields, dayKey, - eventsOnDayFromIndex, getEventStartDate, getPrimaryCalendarId, getTaskDueDate, pickUnusedCalendarColor, sharedCalendarColorKey, - type EventDayIndex, - type TimeFormat, + reopenedEdit, } from '../lib/calendar-utils'; import { buildReplyTo } from '../lib/calendar-invitation'; import { isRecurringSeriesMember } from '../lib/recurrence-overrides'; @@ -97,6 +96,7 @@ import { participantLimitMessage } from '../lib/calendar-participants'; import { maxParticipantsPerEvent } from '../api/calendar'; import { useContactNameResolver } from '../lib/contact-name-resolver'; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar'; +import { moveCalendar } from '../lib/calendar-sort'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { useUserCalendarAddresses } from '../lib/calendar-user-addresses'; @@ -107,7 +107,18 @@ import { shareEventICS } from '../lib/calendar-ics-export'; import * as Clipboard from 'expo-clipboard'; import type { Calendar, CalendarEvent } from '../api/types'; -type ViewMode = 'month' | 'week' | 'day' | 'agenda'; +type ViewMode = 'month' | 'week' | '3day' | 'day' | 'agenda'; + +// The three-day grid scrolls through the same window of days as the day view. +function windowMode(mode: ViewMode): ScrollViewMode { + return mode === '3day' ? 'day' : mode; +} + +// A window without free scrolling: one period, the multi-day grid's days. +function fixedWindowFor(mode: ViewMode, anchor: Date, multiDays: number): ScrollWindowState { + const state = fixedScrollWindowState(windowMode(mode), anchor); + return mode === '3day' ? { ...state, after: multiDays - 1 } : state; +} type PendingAction = | { kind: 'edit'; @@ -131,28 +142,10 @@ type PendingAction = // the next arrow press or edge usually finds them there already. const RANGE_MARGIN_DAYS = 14; -type WeekStart = 0 | 1 | 6; - -// `firstDay`: the scrolled week grid reports the first column in view; the -// title then spans the seven days from there instead of the focused week. -function headerTitle( - viewMode: ViewMode, - currentDate: Date, - weekStartsOn: WeekStart, - locale: Locale, - firstDay?: Date | null, -): string { - if (viewMode === 'month') return format(currentDate, 'MMMM yyyy', { locale }); - if (viewMode === 'day') return format(currentDate, 'EEE, MMM d, yyyy', { locale }); - if (viewMode === 'week') { - const start = firstDay ?? startOfWeek(currentDate, { weekStartsOn }); - const end = firstDay ? addDays(firstDay, 6) : endOfWeek(currentDate, { weekStartsOn }); - if (start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear()) { - return `${format(start, 'MMM d', { locale })} – ${format(end, 'd, yyyy', { locale })}`; - } - return `${format(start, 'MMM d', { locale })} – ${format(end, 'MMM d, yyyy', { locale })}`; - } - return format(currentDate, 'MMMM yyyy', { locale }); +// The month on screen; the year only when it isn't this year. +function headerTitle(date: Date, locale: Locale): string { + const sameYear = date.getFullYear() === displayNow().getFullYear(); + return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale }); } export default function CalendarScreen() { @@ -203,15 +196,19 @@ export default function CalendarScreen() { ); // The sideways-scrolling week and day grids assume a left-to-right strip; // right-to-left layouts keep them paged. + // Wider windows show more days in the multi-day view. + const { width: windowWidth } = useWindowDimensions(); + const multiDays = multiDayCount(windowWidth); const freeScroll = - calendarFreeScroll && ((viewMode !== 'week' && viewMode !== 'day') || !I18nManager.isRTL); + calendarFreeScroll + && ((viewMode !== 'week' && viewMode !== '3day' && viewMode !== 'day') || !I18nManager.isRTL); const focusKey = dayKey(focus.date); const activeWindowState = React.useMemo( () => freeScroll - ? normalizeScrollWindowState(windowState, viewMode, parseDayKey(focusKey)) - : fixedScrollWindowState(viewMode, parseDayKey(focusKey)), - [freeScroll, windowState, viewMode, focusKey], + ? normalizeScrollWindowState(windowState, windowMode(viewMode), parseDayKey(focusKey)) + : fixedWindowFor(viewMode, parseDayKey(focusKey), multiDays), + [freeScroll, windowState, viewMode, focusKey, multiDays], ); React.useEffect(() => { if (freeScroll && activeWindowState !== windowState) setWindowState(activeWindowState); @@ -221,7 +218,7 @@ export default function CalendarScreen() { [activeWindowState, windowOptions], ); // Changes whenever a fresh window starts; the views remount on it. - const windowKey = `${activeWindowState.mode}:${activeWindowState.anchorKey}`; + const windowKey = `${viewMode}:${activeWindowState.anchorKey}`; const loadRange = React.useMemo(() => { const { after, before } = scrollWindowLoadRange(scrollWindow, RANGE_MARGIN_DAYS); return { after: after.toISOString(), before: before.toISOString() }; @@ -236,7 +233,6 @@ export default function CalendarScreen() { const [tasksVisible, setTasksVisible] = React.useState(false); const [importVisible, setImportVisible] = React.useState(false); const [subscriptionsVisible, setSubscriptionsVisible] = React.useState(false); - const [refreshing, setRefreshing] = React.useState(false); const hydrate = useCalendarStore((s) => s.hydrate); const fetchCalendarsAction = useCalendarStore((s) => s.fetchCalendars); @@ -270,6 +266,7 @@ export default function CalendarScreen() { const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const createCalendar = useCalendarStore((s) => s.createCalendar); const updateCalendar = useCalendarStore((s) => s.updateCalendar); + const reorderCalendars = useCalendarStore((s) => s.reorderCalendars); const removeCalendar = useCalendarStore((s) => s.removeCalendar); const clearCalendarEvents = useCalendarStore((s) => s.clearCalendarEvents); const shareCalendar = useCalendarStore((s) => s.shareCalendar); @@ -470,7 +467,7 @@ export default function CalendarScreen() { setSelectedDate(date); setVisibleDate(null); setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); - setWindowState((prev) => windowStateForJump(prev, viewMode, date, windowOptions)); + setWindowState((prev) => windowStateForJump(prev, windowMode(viewMode), date, windowOptions)); }, [viewMode, windowOptions], ); @@ -482,7 +479,7 @@ export default function CalendarScreen() { (side: 'before' | 'after') => { setLoadingEdge(side === 'before' ? 'start' : 'end'); setWindowState((prev) => - growScrollWindow(normalizeScrollWindowState(prev, viewMode, focus.date), side), + growScrollWindow(normalizeScrollWindowState(prev, windowMode(viewMode), focus.date), side), ); }, [viewMode, focus.date], @@ -496,19 +493,21 @@ export default function CalendarScreen() { const base = visibleDate ?? focus.date; jumpTo( viewMode === 'week' ? subWeeks(base, 1) + : viewMode === '3day' ? subDays(base, multiDays) : viewMode === 'day' ? subDays(base, 1) : subMonths(base, 1), ); - }, [viewMode, visibleDate, focus.date, jumpTo]); + }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]); const goNext = React.useCallback(() => { const base = visibleDate ?? focus.date; jumpTo( viewMode === 'week' ? addWeeks(base, 1) + : viewMode === '3day' ? addDays(base, multiDays) : viewMode === 'day' ? addDays(base, 1) : addMonths(base, 1), ); - }, [viewMode, visibleDate, focus.date, jumpTo]); + }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]); const goToday = React.useCallback(() => { jumpTo(displayNow()); @@ -522,16 +521,69 @@ export default function CalendarScreen() { setViewMode(mode); setVisibleDate(null); setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); - setWindowState(freshScrollWindowState(mode, date)); + setWindowState(freshScrollWindowState(windowMode(mode), date)); }, [viewMode, visibleDate, focus.date], ); + // The views, listed at the top of the drawer. + const viewOptions = React.useMemo( + () => [ + { key: 'agenda', label: t('calendar.views.schedule', 'Schedule'), Icon: Rows2 }, + { key: 'day', label: t('calendar.views.day', 'Day'), Icon: Square }, + { + key: '3day', + label: multiDays === 3 + ? t('calendar.views.three_days', '3 days') + : t('calendar.views.n_days', '{count} days', { count: multiDays }), + Icon: Columns3, + }, + { key: 'week', label: t('calendar.views.week', 'Week'), Icon: Columns4 }, + { key: 'month', label: t('calendar.views.month', 'Month'), Icon: Grid3x3 }, + ], + [t, multiDays], + ); + + // The month that drops down under the title; the title follows the month + // swiped to while it is open. + const [monthPanelOpen, setMonthPanelOpen] = React.useState(false); + const [panelMonth, setPanelMonth] = React.useState(null); + const toggleMonthPanel = React.useCallback(() => { + setPanelMonth(null); + setMonthPanelOpen((open) => !open); + }, []); + const handlePanelSelect = React.useCallback( + (date: Date) => { + setMonthPanelOpen(false); + setPanelMonth(null); + jumpTo(date); + }, + [jumpTo], + ); + + // New tasks open the tasks sheet on an empty editor. + const [newTask, setNewTask] = React.useState(false); + const openNewTask = React.useCallback(() => { + setTasksInitialId(null); + setNewTask(true); + setTasksVisible(true); + }, []); + // Picking a day only moves the selection; the view stays where it is. const handleSelectDate = React.useCallback((date: Date) => { setSelectedDate(date); }, []); + // A day in the month grid or a day header in the week grids opens that + // day, as in Google Calendar. + const openDay = React.useCallback((date: Date) => { + setSelectedDate(date); + setViewMode('day'); + setVisibleDate(null); + setFocus((prev) => ({ date, nonce: prev.nonce + 1 })); + setWindowState(freshScrollWindowState('day', date)); + }, []); + const openCreate = React.useCallback((date?: Date) => { setModalEvent(null); setModalDate(date); @@ -588,15 +640,32 @@ export default function CalendarScreen() { [t], ); + // A single event is gone for good once deleted; ask first, as the + // recurrence-scope dialog already does for a series. + const confirmDeleteEvent = React.useCallback((event: CalendarEvent) => { + Alert.alert( + t('calendar.detail.delete_confirm', 'Delete this event?'), + event.title || undefined, + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { + text: t('calendar.recurrence_scope.delete', 'Delete'), + style: 'destructive', + onPress: () => { deleteEvent(event.id).catch(reportError); }, + }, + ], + ); + }, [deleteEvent, reportError, t]); + const handleDeleteFromDetail = React.useCallback((event: CalendarEvent) => { if (isReadOnlyEvent(event)) { setDetailEvent(null); return; } setDetailEvent(null); if (isRecurringSeriesMember(event)) { setPendingAction({ kind: 'delete', event }); } else { - deleteEvent(event.id).catch(reportError); + confirmDeleteEvent(event); } - }, [deleteEvent, isReadOnlyEvent, reportError]); + }, [confirmDeleteEvent, isReadOnlyEvent]); // Send an answer and show it in the open detail sheet. 'occurrence' // answers just that occurrence of a series. @@ -703,6 +772,16 @@ export default function CalendarScreen() { [modalEvent, createEvent, updateEvent], ); + // Cancelling "which occurrences?" after editing a recurring event goes back + // to the editor with the edits still in it; they used to be thrown away. + const cancelScope = React.useCallback(() => { + const pending = pendingAction; + setPendingAction(null); + if (pending?.kind !== 'edit') return; + setModalEvent(reopenedEdit(pending.event, pending.updates)); + setModalVisible(true); + }, [pendingAction]); + const handleDeleteFromModal = React.useCallback( async (event: CalendarEvent) => { setModalVisible(false); @@ -710,19 +789,12 @@ export default function CalendarScreen() { setPendingAction({ kind: 'delete', event }); return; } - await deleteEvent(event.id).catch(reportError); + confirmDeleteEvent(event); }, - [deleteEvent, reportError], + [confirmDeleteEvent], ); - const onRefresh = React.useCallback(async () => { - setRefreshing(true); - try { - await refresh(); - } finally { - setRefreshing(false); - } - }, [refresh]); + const onRefresh = React.useCallback(() => refresh(), [refresh]); // The store flips the checkbox optimistically and reverts it when the // server refuses; say so instead of leaving the user guessing. @@ -740,12 +812,16 @@ export default function CalendarScreen() { const handleDeleteTask = React.useCallback( (id: string) => { - deleteTask(id).catch((err: unknown) => { + const run = () => deleteTask(id).catch((err: unknown) => { Alert.alert( t('calendar.tasks.delete_error', 'Failed to delete task'), err instanceof Error ? err.message : undefined, ); }); + Alert.alert(t('calendar.tasks.delete_confirm', 'Delete this task?'), undefined, [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { text: t('calendar.tasks.delete', 'Delete'), style: 'destructive', onPress: () => { void run(); } }, + ]); }, [deleteTask, t], ); @@ -823,6 +899,14 @@ export default function CalendarScreen() { [setSharedCalendarColor, updateCalendar, reportError], ); + const handleMoveCalendar = React.useCallback( + (cal: Calendar, targetId: string) => { + const orderedIds = moveCalendar(storeCalendars, cal.id, targetId); + if (orderedIds) reorderCalendars(orderedIds).catch(reportError); + }, + [storeCalendars, reorderCalendars, reportError], + ); + const handleClearCalendar = React.useCallback( (cal: Calendar) => { Alert.alert( @@ -865,10 +949,10 @@ export default function CalendarScreen() { [t, removeCalendar, reportError], ); - const isSelectedToday = isDisplayToday(selectedDate); + const titleDate = (monthPanelOpen && panelMonth) || visibleDate || focus.date; return ( - + setSidebarVisible(true)} @@ -879,96 +963,83 @@ export default function CalendarScreen() { > - - {/* Four view buttons leave less room: shrink a long title instead of wrapping it. */} - - {headerTitle( - viewMode, - visibleDate ?? focus.date, - calendarFirstDayOfWeek, - locale, - viewMode === 'week' && freeScroll ? visibleDate : null, - )} + + + {headerTitle(titleDate, locale)} - - {isSelectedToday - ? t('calendar.views.today', 'Today') - : format(selectedDate, 'EEE, MMM d', { locale })} - - + + + + + {/* Without free scrolling the views don't swipe: keep arrows. */} + {!freeScroll && ( + <> + + + + + + + + )} + { + setMonthPanelOpen(false); + goToday(); + }} + hitSlop={4} + accessibilityRole="button" + accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')} + > + + {format(displayNow(), 'd')} + {enableCalendarTasks && ( setTasksVisible(true)} - hitSlop={6} + hitSlop={4} accessibilityRole="button" accessibilityLabel={t('calendar.views.tasks', 'Tasks')} > - + )} - - {(['month', 'week', 'day', 'agenda'] as ViewMode[]).map((mode) => { - const Icon = - mode === 'month' ? LayoutGrid - : mode === 'week' ? CalendarDays - : mode === 'day' ? Calendar1 - : ListIcon; - const active = viewMode === mode; - const label = - mode === 'month' ? t('calendar.views.month', 'Month') - : mode === 'week' ? t('calendar.views.week', 'Week') - : mode === 'day' ? t('calendar.views.day', 'Day') - : t('calendar.views.agenda', 'Agenda'); - return ( - changeViewMode(mode)} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityState={{ selected: active }} - > - - - ); - })} - - openCreate(selectedDate)} - accessibilityRole="button" - accessibilityLabel={t('calendar.events.new_event', 'New event')} - > - - - - - - - - - - - + {monthPanelOpen && ( + + )} {error && ( @@ -993,7 +1064,8 @@ export default function CalendarScreen() { showWeekNumbers={calendarShowWeekNumbers} showTimeInMonthView={calendarShowTimeInMonth} timeFormat={calendarTimeFormat} - onSelectDate={handleSelectDate} + currentUserEmails={currentUserEmails} + onSelectDate={openDay} onLongPressDate={openCreate} /> )} @@ -1007,15 +1079,18 @@ export default function CalendarScreen() { weekStartsOn={calendarFirstDayOfWeek} showWeekNumbers={calendarShowWeekNumbers} showTimeInMonthView={calendarShowTimeInMonth} + fill timeFormat={calendarTimeFormat} - onSelectDate={handleSelectDate} + currentUserEmails={currentUserEmails} + onSelectDate={openDay} onLongPressDate={openCreate} /> )} - {(viewMode === 'day' || (viewMode === 'week' && freeScroll)) && ( + {(viewMode === 'day' || viewMode === '3day' || (viewMode === 'week' && freeScroll)) && ( @@ -1041,7 +1117,8 @@ export default function CalendarScreen() { calendars={calendars} weekStartsOn={calendarFirstDayOfWeek} timeFormat={calendarTimeFormat} - onSelectDate={handleSelectDate} + currentUserEmails={currentUserEmails} + onSelectDate={openDay} onSelectEvent={handleSelectEvent} onCreateAtTime={openCreate} /> @@ -1061,33 +1138,25 @@ export default function CalendarScreen() { eventsByDay={eventsByDay} calendars={calendars} timeFormat={calendarTimeFormat} + weekStartsOn={calendarFirstDayOfWeek} + currentUserEmails={currentUserEmails} onSelectEvent={handleSelectEvent} + onCreateAt={openCreate} /> )} - {viewMode === 'month' && ( - - - - {isSelectedToday - ? t('calendar.events.today_header', 'Today') - : format(selectedDate, 'EEEE, MMMM d', { locale })} - - {loading && } - - + {loading && ( + + )} + openCreate(selectedDate)} + onNewTask={enableCalendarTasks ? openNewTask : undefined} + /> + setPendingAction(null)} + onClose={cancelScope} /> { + setSidebarVisible(false); + setMonthPanelOpen(false); + changeViewMode(key as ViewMode); + }} + onRefresh={() => { + setSidebarVisible(false); + void onRefresh(); + }} calendars={eventCalendars} hiddenCalendarIds={hiddenCalendarIds} onToggle={toggleCalendarVisibility} @@ -1158,6 +1238,7 @@ export default function CalendarScreen() { onShare={(cal) => { setSidebarVisible(false); setShareTarget(cal); }} onClear={handleClearCalendar} onDelete={handleDeleteCalendar} + onMove={handleMoveCalendar} isSubscriptionCalendar={isSubscriptionCalendar} /> @@ -1167,7 +1248,8 @@ export default function CalendarScreen() { calendars={taskSheetCalendars} timeFormat={calendarTimeFormat} initialTaskId={tasksInitialId} - onClose={() => { setTasksVisible(false); setTasksInitialId(null); }} + startNew={newTask} + onClose={() => { setTasksVisible(false); setTasksInitialId(null); setNewTask(false); }} onCreate={createTask} onUpdate={updateTask} onToggle={handleToggleTask} @@ -1207,64 +1289,6 @@ export default function CalendarScreen() { ); } -function DayEventList({ - date, - eventsByDay, - calendars, - timeFormat, - onSelectEvent, - refreshing, - onRefresh, -}: { - date: Date; - eventsByDay: EventDayIndex; - calendars: Calendar[]; - timeFormat?: TimeFormat; - onSelectEvent?: (event: CalendarEvent) => void; - refreshing: boolean; - onRefresh: () => void; -}) { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); - const t = useLocaleStore((s) => s.t); - const dayEvents = React.useMemo( - () => eventsOnDayFromIndex(eventsByDay, date), - [eventsByDay, date], - ); - if (dayEvents.length === 0) { - return ( - - } - > - - {t('calendar.events.no_events', 'No events')} - {t('calendar.events.tap_to_create', 'Tap + to create one')} - - ); - } - return ( - - } - > - {dayEvents.map((event) => ( - - ))} - - ); -} - function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, @@ -1272,57 +1296,38 @@ function makeStyles(c: ThemePalette) { header: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: spacing.md, + paddingHorizontal: spacing.sm, paddingVertical: spacing.sm, gap: spacing.sm, }, headerBtn: { - width: 36, - height: 36, + width: 40, + height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.full, }, - headerLeft: { flex: 1 }, - headerTitle: { ...typography.h3, color: c.text }, - headerSubtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 }, - headerActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, - viewToggle: { - flexDirection: 'row', - backgroundColor: c.surface, - borderRadius: radius.md, - padding: 2, - }, - viewToggleBtn: { - width: 30, - height: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radius.sm, - }, - viewToggleBtnActive: { backgroundColor: c.background }, - fab: { - width: 36, - height: 36, - borderRadius: radius.full, - backgroundColor: c.primary, - alignItems: 'center', - justifyContent: 'center', - }, - - nav: { + titleBtn: { + flex: 1, flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.xs, - gap: spacing.lg, + gap: 2, + minHeight: 40, }, - navBtn: { - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radius.full, + headerTitle: { ...typography.h3, color: c.text, flexShrink: 1 }, + chevronOpen: { transform: [{ rotate: '180deg' }] }, + headerActions: { flexDirection: 'row', alignItems: 'center', gap: 2 }, + // Today's date inside the calendar icon, below its rings. + todayIconText: { + position: 'absolute', + top: 15, + left: 0, + right: 0, + textAlign: 'center', + fontSize: 10, + lineHeight: 12, + fontWeight: '700', + color: c.text, }, errorBanner: { @@ -1334,28 +1339,7 @@ function makeStyles(c: ThemePalette) { content: { flex: 1 }, - dayDetail: { - flex: 1, - borderTopWidth: 1, - borderTopColor: c.border, - }, - dayDetailHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - }, - dayDetailTitle: { ...typography.bodyMedium, color: c.text }, - dayList: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg }, - - emptyState: { - alignItems: 'center', - paddingVertical: spacing.xl, - gap: spacing.xs, - flexGrow: 1, - }, - emptyTitle: { ...typography.bodyMedium, color: c.textSecondary }, - emptySubtitle: { ...typography.caption, color: c.textMuted }, + // Loading shows as a small spinner over the view's top corner. + loadingBadge: { position: 'absolute', top: spacing.xs, right: spacing.md }, }); } diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index b04ea684..9105cee5 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, TextInput, Pressable, ScrollView, - Keyboard, Dimensions, Platform, ActivityIndicator, Alert, Modal, Switch, + Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { usePreventRemove } from '@react-navigation/native'; @@ -12,12 +12,11 @@ import { Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, Users, Tag, Type, Highlighter, -} from 'lucide-react-native'; +} from '../components/icons'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; import * as DocumentPicker from 'expo-document-picker'; import * as FileSystem from 'expo-file-system/legacy'; -import * as Sharing from 'expo-sharing'; import * as Clipboard from 'expo-clipboard'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -29,16 +28,22 @@ import RichTextEditor, { } from '../components/RichTextEditor'; import { useEmailStore } from '../stores/email-store'; import { ownMailboxes } from '../lib/mailbox-tree'; +import { getEmailInitials } from '../lib/avatar-utils'; import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store'; +import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; +import { useLayout } from '../lib/use-layout'; +import { createAfterDismissQueue } from '../lib/after-modal-dismiss'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useHasContacts } from '../lib/capabilities'; +import { isTrustedSendersSyncOn } from '../lib/trusted-senders'; import { useAccountStore } from '../stores/account-store'; import { useSendUndoStore } from '../stores/send-undo-store'; import { toast } from '../stores/toast-store'; import { type EmailTemplate } from '../stores/templates-store'; import { getIdentities } from '../api/identity'; import { - sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail, + sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail, } from '../api/email'; import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { pruneEmailSubmissions } from '../api/submissions'; @@ -61,10 +66,13 @@ import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; +import { + initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody, +} from '../lib/draft-part-blobs'; import { signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature, insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature, - plainTextBodyHasSignature, plainTextBodyWithoutSignature, SIGNATURE_RANGE_MARKER, + plainTextBodyHasSignature, plainTextBodyWithoutSignature, SIGNATURE_RANGE_MARKER, wantsSignatureSeparator, } from '../lib/signature-utils'; import { getAutoFilledPlaceholders, getPlaceholdersFromTemplate, substitutePlaceholders, templateBodyToHtml, @@ -75,6 +83,10 @@ import { import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; import type { EmailAddress, Identity } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import ToggleSwitch from '../components/ToggleSwitch'; +import { AttachmentPreviewModal } from '../components/email/AttachmentPreviewModal'; +import { useFilePreview } from '../components/email/use-file-preview'; type Props = NativeStackScreenProps; @@ -209,7 +221,8 @@ function RecipientChip({ @@ -220,11 +233,7 @@ function RecipientChip({ } function initialsOf(name: string, email: string): string { - const source = name.trim() || email; - const parts = source.split(/[\s@._-]+/).filter(Boolean); - const first = parts[0]?.[0] ?? '?'; - const second = parts.length > 1 ? parts[1][0] : ''; - return `${first}${second}`.toUpperCase(); + return getEmailInitials(name.trim(), email); } function SuggestionList({ @@ -341,7 +350,9 @@ function ToolbarButton({ onPress={onPress} hitSlop={4} disabled={disabled} + accessibilityRole="button" accessibilityLabel={label} + accessibilityState={{ selected: !!active, disabled: !!disabled }} style={[styles.formatBtn, active && styles.formatBtnActive, disabled && styles.formatBtnDisabled]} > {icon} @@ -367,16 +378,26 @@ function OptionsSheet({ }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + // An option may open a system picker, which iOS only shows once this + // modal is gone. + const afterDismiss = React.useRef(createAfterDismissQueue(Platform.OS)).current; return ( - - - {}}> + + + {}}> {!!title && {title}} {options.map((opt) => ( { onClose(); opt.onPress(); }} + onPress={() => { onClose(); afterDismiss.run(opt.onPress); }} > {opt.label} @@ -391,6 +412,7 @@ function OptionsSheet({ } export default function ComposeScreen({ route, navigation }: Props) { + const filePreview = useFilePreview(); const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -398,37 +420,16 @@ export default function ComposeScreen({ route, navigation }: Props) { const timeFormat = useSettingsStore((s) => s.timeFormat); const insets = useSafeAreaInsets(); // Track the visible keyboard obstruction so the format bar stays above it. - // On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is - // measured from the top of the gesture-nav bar rather than from the true - // screen bottom, so we derive obstruction height from `Dimensions.screen` - // instead of trusting `endCoordinates.height` directly. The same trick - // works on iOS for the QuickType / autofill / dictation strip. - const [kbObstruction, setKbObstruction] = React.useState(0); - React.useEffect(() => { - const recompute = (endY: number) => { - const screenH = Dimensions.get('screen').height; - setKbObstruction(Math.max(0, screenH - endY)); - }; - const subs = - Platform.OS === 'ios' - ? [ - Keyboard.addListener('keyboardWillChangeFrame', (e) => { - recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER); - }), - ] - : [ - Keyboard.addListener('keyboardDidShow', (e) => { - recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER); - }), - Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)), - ]; - return () => { - for (const s of subs) s.remove(); - }; - }, []); + const kbObstruction = useKeyboardObstruction(insets.bottom, () => editorRef.current?.blur()); + const { shortHeight, isSplit } = useLayout(); + const typingInShortWindow = shortHeight && kbObstruction > 0; // When the keyboard is up it covers the bottom safe area, so we don't // need to add it on top — pad by whichever is larger. - const bottomPad = Math.max(kbObstruction, insets.bottom); + // On a tablet the composer is a centred panel that stops PANEL_MARGIN + // short of the screen bottom; the keyboard covers that much less of it. + const bottomPad = isSplit + ? Math.max(0, kbObstruction - PANEL_MARGIN) + : Math.max(kbObstruction, insets.bottom); const replyTo = route.params?.replyTo; const draft = route.params?.draft; const mode = route.params?.mode ?? 'compose'; @@ -482,6 +483,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter); const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); + const hasContacts = useHasContacts(); const updateSetting = useSettingsStore((s) => s.updateSetting); const quoteLabels = React.useMemo(() => quoteHeaderLabels(t), [t]); @@ -682,10 +684,36 @@ export default function ComposeScreen({ route, navigation }: Props) { }); const editorRef = React.useRef(null); + // The label column grows with the system font size: at 1.3 "Betreff" + // wrapped into "Betref" / "f" in a fixed 56 dp column. + const { fontScale } = useWindowDimensions(); + const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]); + const subjectRef = React.useRef(null); + const plainBodyRef = React.useRef(null); + const [subjectFocused, setSubjectFocused] = React.useState(false); + + // The composer's ScrollView and its live offset. On Android the editor page + // hands back the caret-reveal pans it undoes (see `pinVisualViewport` in + // editor-html); scrolling the composer by the same amount keeps the caret + // where the pan had put it, without leaving the body drawn shifted up. + const bodyScrollRef = React.useRef(null); + const bodyScrollY = React.useRef(0); + const onEditorViewportShift = React.useCallback((dy: number) => { + const y = Math.max(0, bodyScrollY.current + dy); + bodyScrollY.current = y; + bodyScrollRef.current?.scrollTo({ y, animated: false }); + }, []); const isPickingSuggestion = React.useRef(false); // Track inline-image placeholders that haven't yet been rewritten to cid: // until send time. Maps cid → blobId/type/name/size. const inlineRegistryRef = React.useRef>(new Map()); + // A re-opened draft's attachments and inline images are parts of that + // draft: their blobIds die with each version a save replaces (#849), and an + // upload expires once nothing references it. This maps the blobId each + // entry holds to the matching part of the latest saved version, which + // `buildOutgoing` sends. Kept out of the attachment state so the dirty + // snapshot does not change on every save. + const draftPartBlobsRef = React.useRef(initialDraftPartBlobState()); const getAutocomplete = useContactsStore((s) => s.getAutocomplete); const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients); @@ -845,6 +873,10 @@ export default function ComposeScreen({ route, navigation }: Props) { } } if (cancelled || map.size === 0) return; + // No change message since the editor loaded = the user has not typed + // yet, so the editor still holds the seeded body (normalised by the + // WebView DOM, which is why it can differ from the raw server HTML). + const untouched = !plainTextMode && latestRef.current.bodyHtml === initialBodyHtml; let live = latestRef.current.bodyHtml; try { live = (await editorRef.current?.getHtml()) ?? live; @@ -855,8 +887,16 @@ export default function ComposeScreen({ route, navigation }: Props) { editorRef.current?.setHtml(next); } // The hydrated images are part of the untouched baseline, not an edit. - baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map); - if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map); + if (untouched) { + // Rebase on the body the composer now holds: comparing it with the + // raw server HTML made every re-opened draft with inline images look + // edited, and the autosave replaced (and destroyed) it unasked. + baselineRef.current = withSnapshotBody(baselineRef.current, next); + if (lastSavedRef.current) lastSavedRef.current = withSnapshotBody(lastSavedRef.current, next); + } else { + baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map); + if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map); + } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1085,6 +1125,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); const [savingDraft, setSavingDraft] = React.useState(false); + const [, bumpBaseline] = React.useReducer((n: number) => n + 1, 0); const isDirty = currentSnapshot !== baselineRef.current; const needsSave = currentSnapshot !== (lastSavedRef.current ?? baselineRef.current); @@ -1106,7 +1147,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const placeSignaturePlain = React.useCallback((body: string, identity: Identity): string => { const sig = getPlainTextSignature(identity); if (!sig) return body; - const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; + const sep = wantsSignatureSeparator(signatureSeparatorEnabled, identity) ? '\n\n-- \n' : '\n\n'; if (isReplyLike && signaturePosition === 'above_quote') { const idx = body.indexOf('\n\n'); const head = idx === -1 ? body : body.slice(0, idx); @@ -1143,10 +1184,21 @@ export default function ComposeScreen({ route, navigation }: Props) { const next = placeSignatureHtml(live, sigHtml); setBodyHtml(next); editorRef.current?.setHtml(next); + // The untouched baseline is the editor's own read-back: the page + // normalises the markup, so the string built here never equalled + // what it reports, and an untouched reply counted as edited (it + // autosaved a draft and asked "Save or discard?" on close). + let settled = placeSignatureHtml(initialBodyHtml, sigHtml); + try { + settled = (await editorRef.current?.getHtml()) ?? settled; + } catch { /* keep the built string */ } + if (latestRef.current.bodyHtml === next) setBodyHtml(settled); baselineRef.current = snapshotOf({ to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject, - body: placeSignatureHtml(initialBodyHtml, sigHtml), attachments: initialAttachments, + body: settled, attachments: initialAttachments, }); + // The baseline is a ref: re-render so "edited" is worked out again. + bumpBaseline(); })(); } return; @@ -1162,7 +1214,7 @@ export default function ComposeScreen({ route, navigation }: Props) { stripped = plainTextBodyWithoutSignature(body, prev); } else if (prev) { const old = getPlainTextSignature(prev); - const marker = `${signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'}${old}`; + const marker = `${wantsSignatureSeparator(signatureSeparatorEnabled, prev) ? '\n\n-- \n' : '\n\n'}${old}`; if (old && body.includes(marker)) stripped = body.replace(marker, ''); } return signatureIdentity ? placeSignaturePlain(stripped, signatureIdentity) : stripped; @@ -1229,7 +1281,7 @@ export default function ComposeScreen({ route, navigation }: Props) { .map((cid) => inlineRegistryRef.current.get(cid)) .filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error) .map((e) => ({ - blobId: e.blobId!, + blobId: liveBlobId(draftPartBlobsRef.current, e.blobId!), type: e.type, name: e.name, size: e.size, @@ -1240,7 +1292,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const fileAttachments = attachments .filter((a) => !a.inline && a.blobId && !a.error) .map((a) => ({ - blobId: a.blobId!, + blobId: liveBlobId(draftPartBlobsRef.current, a.blobId!), type: a.type, name: a.name, size: a.size, @@ -1300,8 +1352,14 @@ export default function ComposeScreen({ route, navigation }: Props) { setDraftStatus('saving'); try { const outgoing = buildOutgoing(identity, html, { forDraft: true }); - const id = await createDraft(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined); + const saved = await saveDraftVersion(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined); + const id = saved.id; draftIdRef.current = id; + // The previous version and its part blobs are gone: point every + // attachment at the parts of the version just created. + draftPartBlobsRef.current = rebaseDraftPartBlobs( + draftPartBlobsRef.current, outgoing.attachments ?? [], saved.attachments, + ); lastSavedRef.current = snapshot; lastSaveAtRef.current = Date.now(); setDraftStatus('saved'); @@ -1583,14 +1641,8 @@ export default function ComposeScreen({ route, navigation }: Props) { }, []); const pickPhotoAttachments = async () => { - const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert( - t('email_composer.attach', 'Attach'), - t('email_composer.permission_photos', 'Photo library permission is required to attach images.'), - ); - return; - } + // The system photo picker needs no media permission (and Play rejects + // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], allowsMultipleSelection: true, @@ -1687,14 +1739,8 @@ export default function ComposeScreen({ route, navigation }: Props) { }; const insertInlineImage = async () => { - const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (!perm.granted) { - Alert.alert( - t('email_composer.attach', 'Attach'), - t('email_composer.permission_photos', 'Photo library permission is required to attach images.'), - ); - return; - } + // The system photo picker needs no media permission (and Play rejects + // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsMultipleSelection: true, @@ -1705,22 +1751,14 @@ export default function ComposeScreen({ route, navigation }: Props) { await insertInlineImages(result.assets); }; - // Open a chip: local files straight from their URI, server blobs after a - // download into the cache. + // Open a chip in the file preview: local files from their URI, server + // blobs (forwarded, from a draft) after a download. const previewAttachment = async (entry: AttachmentEntry) => { try { - let uri = entry.uri; - if (!uri && entry.blobId) { - const buf = await jmapClient.fetchBlobArrayBuffer(entry.blobId, entry.name, entry.type); - const dir = FileSystem.cacheDirectory ?? ''; - uri = `${dir}${entry.localId}-${entry.name.replace(/[^\w.-]+/g, '_')}`; - await FileSystem.writeAsStringAsync(uri, bytesToBase64(new Uint8Array(buf)), { - encoding: FileSystem.EncodingType.Base64, - }); - } - if (!uri) return; - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(uri, { mimeType: entry.type, dialogTitle: entry.name }); + if (entry.uri) { + await filePreview.open({ from: 'local', uri: entry.uri, name: entry.name, type: entry.type, size: entry.size }); + } else if (entry.blobId) { + await filePreview.open({ from: 'blob', blobId: entry.blobId, name: entry.name, type: entry.type, size: entry.size }); } } catch (err) { Alert.alert(t('email_composer.attach', 'Attach'), err instanceof Error ? err.message : String(err)); @@ -2147,7 +2185,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const contacts = useContactsStore.getState(); for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) { settings.addTrustedSender(r.email); - if (trustedSendersAddressBook) { + if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) { contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined); } } @@ -2226,7 +2264,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field; return ( - {label} + {label} {list.map((r, i) => ( ))} addTyped(field)} + // Return turns typed text into a chip; on an empty field it + // moves on to the subject. + returnKeyType="next" + onSubmitEditing={() => { + if (inputFor(field).trim()) addTyped(field); + else subjectRef.current?.focus(); + }} blurOnSubmit={false} keyboardType="email-address" autoCapitalize="none" @@ -2281,12 +2328,12 @@ export default function ComposeScreen({ route, navigation }: Props) { /> )} {field === 'to' && !ccVisible && ( - setCcVisible(true)} style={styles.ccToggle}> + setCcVisible(true)} style={styles.ccToggle} hitSlop={10} accessibilityRole="button"> {t('email_composer.cc', 'Cc')} )} {field === 'cc' && !bccVisible && ( - setBccVisible(true)} style={styles.ccToggle}> + setBccVisible(true)} style={styles.ccToggle} hitSlop={10} accessibilityRole="button"> {t('email_composer.bcc', 'Bcc')} )} @@ -2294,9 +2341,9 @@ export default function ComposeScreen({ route, navigation }: Props) { ); }; - return ( - - + const composer = ( + + {t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')} - {!!draftStatusText && ( + {!!draftStatusText && !typingInShortWindow && ( {draftStatusText} )} @@ -2361,12 +2408,15 @@ export default function ComposeScreen({ route, navigation }: Props) { { bodyScrollY.current = e.nativeEvent.contentOffset.y; }} + scrollEventThrottle={16} > - {t('email_composer.from', 'From')} + {t('email_composer.from', 'From')} - {t('email_composer.subject', 'Subject')} + {t('email_composer.subject', 'Subject')} setSubjectFocused(true)} + onBlur={() => setSubjectFocused(false)} + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => { + if (plainTextMode) plainBodyRef.current?.focus(); + else editorRef.current?.focus(); + }} /> @@ -2421,6 +2484,8 @@ export default function ComposeScreen({ route, navigation }: Props) { {plainTextMode ? ( )} + {/* The empty space under the body looks like part of it (on a + tablet, most of the panel): a tap there starts typing in it. */} + { + if (plainTextMode) plainBodyRef.current?.focus(); + else editorRef.current?.focusEnd(); + }} + /> - - setTemplateSheetOpen(true)} - icon={} /> - setRequestReadReceipt((v) => !v)} - icon={} /> - { void togglePlainTextMode(); }} - label={plainTextMode - ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') - : t('email_composer.format_plain_text', 'Switch to plain text')} - icon={} /> + {/* A phone in landscape with the keyboard up has room for a few lines + of text only; the format bar comes back with the keyboard down. */} + {!typingInShortWindow && ( + + setTemplateSheetOpen(true)} + label={t('email_composer.use_template', 'Template')} + icon={} /> + setRequestReadReceipt((v) => !v)} + label={t('email_composer.read_receipt_off', 'Request a read receipt')} + icon={} /> + { void togglePlainTextMode(); }} + label={plainTextMode + ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') + : t('email_composer.format_plain_text', 'Switch to plain text')} + icon={} /> - {!plainTextMode && ( - <> - + {!plainTextMode && ( + <> + - editorRef.current?.exec('bold')} - icon={} /> - editorRef.current?.exec('italic')} - icon={} /> - editorRef.current?.exec('underline')} - icon={} /> - editorRef.current?.exec('strikeThrough')} - icon={} /> - setColorTarget('text')} - label={t('email_composer.toolbar.text_color', 'Text color')} - icon={} /> - setColorTarget('background')} - label={t('email_composer.toolbar.background_color', 'Background color')} - icon={} /> + editorRef.current?.exec('bold')} + label={t('email_composer.toolbar.bold', 'Bold')} + icon={} /> + editorRef.current?.exec('italic')} + label={t('email_composer.toolbar.italic', 'Italic')} + icon={} /> + editorRef.current?.exec('underline')} + label={t('email_composer.toolbar.underline', 'Underline')} + icon={} /> + editorRef.current?.exec('strikeThrough')} + label={t('email_composer.toolbar.strikethrough', 'Strikethrough')} + icon={} /> + setColorTarget('text')} + label={t('email_composer.toolbar.text_color', 'Text color')} + icon={} /> + setColorTarget('background')} + label={t('email_composer.toolbar.background_color', 'Background color')} + icon={} /> - + - editorRef.current?.exec('formatBlock:H1')} - icon={} /> - editorRef.current?.exec('formatBlock:H2')} - icon={} /> + editorRef.current?.exec('formatBlock:H1')} + label={t('email_composer.toolbar.heading_1', 'Heading 1')} + icon={} /> + editorRef.current?.exec('formatBlock:H2')} + label={t('email_composer.toolbar.heading_2', 'Heading 2')} + icon={} /> - + - editorRef.current?.exec('insertUnorderedList')} - icon={} /> - editorRef.current?.exec('insertOrderedList')} - icon={} /> - editorRef.current?.exec('formatBlock:BLOCKQUOTE')} - icon={} /> - setTableMenuOpen(true)} - icon={} /> + editorRef.current?.exec('insertUnorderedList')} + label={t('email_composer.toolbar.bullet_list', 'Bullet list')} + icon={} /> + editorRef.current?.exec('insertOrderedList')} + label={t('email_composer.toolbar.ordered_list', 'Ordered list')} + icon={} /> + editorRef.current?.exec('formatBlock:BLOCKQUOTE')} + label={t('email_composer.toolbar.quote', 'Quote')} + icon={} /> + setTableMenuOpen(true)} + label={t('email_composer.toolbar.table', 'Table')} + icon={
} /> - + - editorRef.current?.exec('justifyLeft')} - icon={} /> - editorRef.current?.exec('justifyCenter')} - icon={} /> - editorRef.current?.exec('justifyRight')} - icon={} /> + editorRef.current?.exec('justifyLeft')} + label={t('email_composer.toolbar.align_left', 'Align left')} + icon={} /> + editorRef.current?.exec('justifyCenter')} + label={t('email_composer.toolbar.align_center', 'Align center')} + icon={} /> + editorRef.current?.exec('justifyRight')} + label={t('email_composer.toolbar.align_right', 'Align right')} + icon={} /> - + - - : } /> - { void insertInlineImage(); }} - icon={} /> - editorRef.current?.exec('removeFormat')} - icon={} /> + + : } /> + { void insertInlineImage(); }} + label={t('email_composer.toolbar.insert_image', 'Insert image')} + icon={} /> + editorRef.current?.exec('removeFormat')} + label={t('email_composer.toolbar.clear_formatting', 'Clear formatting')} + icon={} /> - + - editorRef.current?.exec('undo')} - icon={} /> - editorRef.current?.exec('redo')} - icon={} /> - - )} - + editorRef.current?.exec('undo')} + label={t('email_composer.toolbar.undo', 'Undo')} + icon={} /> + editorRef.current?.exec('redo')} + label={t('email_composer.toolbar.redo', 'Redo')} + icon={} /> + + )} + + )} - {/* Text / background colour palette */} - setColorTarget(null)}> - setColorTarget(null)}> - {}}> + setColorTarget(null)}> + setColorTarget(null)}> + {}}> {colorTarget === 'background' ? t('email_composer.toolbar.background_color', 'Background color') @@ -2643,14 +2743,14 @@ export default function ComposeScreen({ route, navigation }: Props) { /> {/* Schedule send sheet */} - setScheduleSheetOpen(false)} > - setScheduleSheetOpen(false)}> - {}}> + setScheduleSheetOpen(false)}> + {}}> {t('email_composer.schedule_send', 'Schedule send')} @@ -2682,9 +2782,9 @@ export default function ComposeScreen({ route, navigation }: Props) { {customStage !== null && Platform.OS === 'ios' && ( - setCustomStage(null)}> - setCustomStage(null)}> - {}}> + setCustomStage(null)}> + setCustomStage(null)}> + {}}> {/* From options: sub-address tag / From override */} - setFromOptionsOpen(false)}> + setFromOptionsOpen(false)}> {t('email_composer.from', 'From')} @@ -2821,7 +2921,7 @@ export default function ComposeScreen({ route, navigation }: Props) { )} {t('email_composer.from_override.toggle_off', 'Override')} - { setSelectedIdentityId(identity.id); setFromOverride(null); }} /> + + ); + if (!isSplit) return composer; + // Tablet: a centred panel over the dimmed screen it was opened from. + return ( + + {composer} + + ); } +const SELECTION_START = { start: 0, end: 0 }; + +/** Gap between the tablet composer panel and the screen edges. */ +const PANEL_MARGIN = 24; + function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, flex: { flex: 1 }, + panelBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + alignItems: 'center', + paddingHorizontal: PANEL_MARGIN, + }, + panel: { + flex: 1, + width: '100%', + maxWidth: 760, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: c.border, + overflow: 'hidden', + backgroundColor: c.background, + }, scrollContent: { flexGrow: 1 }, + bodyFiller: { flexGrow: 1, minHeight: 48 }, header: { flexDirection: 'row', @@ -2894,6 +3025,7 @@ function makeStyles(c: ThemePalette) { borderBottomColor: c.border, gap: spacing.sm, }, + headerCompact: { paddingVertical: 0 }, headerBtn: { width: componentSizes.buttonLg, height: componentSizes.buttonLg, @@ -2920,7 +3052,6 @@ function makeStyles(c: ThemePalette) { fieldLabel: { ...typography.body, color: c.textMuted, - width: 56, paddingTop: 10, }, fieldContent: { diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index 86c76f26..1cdd38f9 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -1,11 +1,12 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, ScrollView, Linking, Alert, Share, - Animated, Dimensions, Easing, Modal, + Animated, Dimensions, Easing, } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useInPane } from '../navigation/pane'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { @@ -13,7 +14,7 @@ import { Building, Cake, Heart, Globe, Tag, Users, FileText, BookUser, Copy, MoreHorizontal, Calendar as CalendarIcon, UserCircle, Languages, Clock, KeyRound, FolderInput, Plus, -} from 'lucide-react-native'; +} from '../components/icons'; import type { RootStackParamList } from '../navigation/types'; import { openExternalUrl } from '../lib/open-url'; import type { ContactCard } from '../api/types'; @@ -34,6 +35,9 @@ import AddressBookPickerSheet from '../components/contacts/AddressBookPickerShee import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; +import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { useSheetInsets } from '../lib/use-layout'; +import { SafeAreaModal } from '../components/SafeAreaModal'; type Nav = NativeStackNavigationProp; type Route = RouteProp; @@ -132,6 +136,7 @@ export default function ContactDetailScreen() { const navigation = useNavigation