Compare commits

..
32 Commits
Author SHA1 Message Date
Linus Rath 980506fadf feat: log why the iOS notification extension falls back to the placeholder 2026-10-22 10:36:17 +02:00
Linus Rath 3e23944851 feat: let the iOS notification extension reach the active account without push setup 2026-10-22 10:36:17 +02:00
Linus Rath 5c05c87c55 ci: sign the iOS notification extension with its own App Store profile 2026-10-22 10:36:17 +02:00
Linus Rath 87ed125672 feat: turn on push notifications on iOS through the relay's APNs transport 2026-10-22 10:36:17 +02:00
Linus Rath add45b15a8 feat: add an iOS notification extension that shows sender and subject 2026-10-22 10:36:17 +02:00
Linus Rath 369274d331 feat: keep iOS credentials where a notification extension can read them 2026-10-22 10:36:17 +02:00
Linus Rath c39e977de5 ci: catch the ios push banner and the extension's log 2026-10-22 10:36:14 +02:00
Linus Rath 24948cb717 ci: keep the app's js log and pin the sign-out dialog button 2026-10-22 10:30:39 +02:00
Linus Rath 6b434346ff ci: give the ios test legs more time 2026-10-22 10:17:49 +02:00
Linus Rath 1827f8eb34 ci: run the ios perf flows in one maestro process and report every round 2026-10-22 10:17:17 +02:00
Linus Rath 9c549c9d00 ci: keep the notification logs and settings of the ios push test 2026-10-22 10:16:34 +02:00
Linus Rath f00cd6a259 ci: check and retype web sign-in fields in the ios flows 2026-10-22 09:54:21 +02:00
Linus Rath 62931bcc53 ci: grant notification permission before the ios push test 2026-10-22 09:43:20 +02:00
Linus Rath 5622d732e9 ci: steady the ios flows around transitions, pickers and covered fields 2026-10-22 09:32:11 +02:00
Linus Rath db3c6b5ba1 ci: test ios push notifications on the simulator with simctl push 2026-10-22 09:04:27 +02:00
Linus Rath 680d4745ed ci: run each ios perf measurement as its own flow 2026-10-22 08:29:55 +02:00
Linus Rath 8533bd4f99 ci: fix ios feature flow steps for the drawer, search, selection, calendar and contacts 2026-10-22 08:20:49 +02:00
Linus Rath 52975bd715 ci: test the webmail hand-off with a webmail session left in the browser 2026-10-22 04:45:29 +02:00
Linus Rath bf226c3794 ci: fix ios sign-out, scanner and add-account flow steps 2026-10-22 04:44:23 +02:00
Linus Rath ca09708fd1 ci: match ios drawer rows by their joined label and keep failed-step hierarchies 2026-10-22 04:40:42 +02:00
Linus Rath 96d9d54cc9 ci: type the first character alone in ios flows and answer the webmail hand-over prompt 2026-10-22 03:55:43 +02:00
Linus Rath 02bd714afd ci: add an ios performance leg that compares two simulator builds 2026-10-22 03:11:39 +02:00
Linus Rath e57ee3d446 ci: sign the ios simulator build ad hoc so the keychain has its entitlements 2026-10-22 03:05:51 +02:00
Linus Rath 08e2f47187 ci: sign the ios simulator app ad hoc so the keychain works, and fix flow selectors 2026-10-22 02:54:00 +02:00
Linus Rath de414f7ed6 ci: run each ios flow in its own maestro process and add the feature flows 2026-10-22 02:15:35 +02:00
Linus Rath 273051e5f5 ci: run sign-in flows for every method on iphone and ipad simulators 2026-10-22 01:54:03 +02:00
Linus Rath fa68b2eb53 ci: type a test value in the ios warm-up flow and log what the driver does 2026-10-22 01:28:13 +02:00
Linus Rath 3748a64b10 ci: turn off the ios slide-to-type tip that swallows typed text in simulator tests 2026-10-22 01:15:24 +02:00
Linus Rath 2078388e6f ci: log why the maestro ios driver fails to start and allow reusing a built app 2026-10-22 00:49:38 +02:00
Linus Rath ea9bad7cff ci: run maestro flows on the ios simulator 2026-10-22 00:28:41 +02:00
Linus Rath ae0b76442a test: add stable test ids to sign-in fields, tabs and message rows 2026-10-22 00:28:41 +02:00
Linus Rath 341cdb0e61 feat: build a Google Play bundle alongside the GitHub APK
The app now has two product flavors. `github` is the sideloaded APK as
before, with the same package name and its in-app updater. `play` is a
bundle for Google Play as `org.bulwarkmail.mobile`, without the updater
or the installer permission, since Play forbids apps updating themselves.

- `release-android.yml` builds the Play bundle in a second job, checks
  its signer, package name and permissions, and keeps it as an artifact.
  With `PLAY_SERVICE_ACCOUNT_JSON` set it uploads it to internal testing.
- The Play flavor uses the new Firebase project; the GitHub flavor keeps
  the old one until the app re-registers its token when it changes.
- Widget clicks and the device-sync account type follow the package name.
- Photos come from the system picker without asking for media access,
  and the unused microphone permission from expo-camera is removed.
- A manual workflow run without a tag no longer replaces the APK of the
  latest release.
2026-10-20 12:56:44 +02:00
106 changed files with 4952 additions and 156 deletions
+92
View File
@@ -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
+337
View File
@@ -0,0 +1,337 @@
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:
group: e2e-ios-${{ github.ref }}
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 }}
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 -czf "$RUNNER_TEMP/simulator-app.tar.gz" BulwarkMobile.app
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 3 \
--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
+154 -9
View File
@@ -1,4 +1,4 @@
name: Release Android APK name: Release Android
on: on:
release: release:
@@ -137,7 +137,7 @@ jobs:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: | run: |
chmod +x ./gradlew chmod +x ./gradlew
./gradlew assembleRelease --no-daemon ./gradlew assembleGithubRelease --no-daemon
- name: Put apksigner on the PATH - name: Put apksigner on the PATH
run: | run: |
@@ -188,7 +188,7 @@ jobs:
# Oldest signer first: apksigner uses it for v2, and the rotated key # Oldest signer first: apksigner uses it for v2, and the rotated key
# for v3 from API 28. Without --rotation-min-sdk-version it would # 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. # 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 \ apksigner sign \
--v4-signing-enabled false \ --v4-signing-enabled false \
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \ --ks android/app/debug.keystore --ks-key-alias androiddebugkey \
@@ -210,7 +210,7 @@ jobs:
ROTATED: ${{ steps.rotation.outputs.rotated }} ROTATED: ${{ steps.rotation.outputs.rotated }}
run: | run: |
set -euo pipefail 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" apksigner verify --verbose --print-certs "$APK"
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …", # build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …". # 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
@@ -248,12 +248,12 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
VERSION=$(cat VERSION | tr -d '[:space:]') 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" DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
cp "$SRC" "$DEST" cp "$SRC" "$DEST"
sha256sum "$DEST" > "$DEST.sha256" 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="" MAPPING=""
if [ -f "$MAPPING_SRC" ]; then if [ -f "$MAPPING_SRC" ]; then
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt" MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
@@ -299,12 +299,12 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | 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 if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}" 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 else
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true) TAG="${{ github.event.inputs.attach_to_release_tag }}"
fi fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then if [ -n "$TAG" ]; then
@@ -319,3 +319,148 @@ jobs:
with: with:
tag_name: ${{ steps.target_tag.outputs.tag }} tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.files }} 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"
- 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, and
# releases of an app that was never published must be drafts; switch
# `status` to `completed` after the first production release.
- name: Upload to Google Play (internal testing, draft)
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 }}
track: internal
status: draft
+61 -39
View File
@@ -59,9 +59,6 @@ jobs:
- name: Install JS dependencies - name: Install JS dependencies
run: npm ci run: npm ci
- name: Generate the iOS project
run: npx expo prebuild --platform ios --clean
- name: Check for signing credentials - name: Check for signing credentials
id: creds id: creds
env: env:
@@ -69,14 +66,69 @@ jobs:
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }} TEAM: ${{ secrets.APPLE_TEAM_ID }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: | run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then 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" echo "signed=true" >> "$GITHUB_OUTPUT"
else else
echo "signed=false" >> "$GITHUB_OUTPUT" echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced." echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi 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 - name: Resolve workspace and scheme
id: xcode id: xcode
run: | run: |
@@ -141,38 +193,6 @@ jobs:
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/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 - name: Archive
if: steps.creds.outputs.signed == 'true' if: steps.creds.outputs.signed == 'true'
env: env:
@@ -192,8 +212,7 @@ jobs:
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \ -archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \ CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \ DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \ CODE_SIGN_IDENTITY="Apple Distribution"
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
# Signing failures report a symptom ("no profiles found") rather than the # Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the # setting that caused it, and a re-run costs ~30 minutes. Dump the
@@ -215,8 +234,10 @@ jobs:
id: export id: export
env: env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profile.outputs.name }} PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }} 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: | run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
@@ -230,6 +251,7 @@ jobs:
<key>provisioningProfiles</key> <key>provisioningProfiles</key>
<dict> <dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string> <key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
</dict> </dict>
<key>uploadSymbols</key><true/> <key>uploadSymbols</key><true/>
<key>destination</key><string>export</string> <key>destination</key><string>export</string>
+36
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
# 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
- tapOn: ".*q3-report.pdf.*"
- extendedWaitUntil:
visible: "Share"
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: read-05-attachment-preview
- assertVisible: "q3-report.pdf"
- tapOn: "Close"
- extendedWaitUntil:
visible: "Reply All"
timeout: 10000
- 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
+69
View File
@@ -0,0 +1,69 @@
# 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:
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). The system photo and file pickers run in another
# process that XCTest can't drive, so a picked attachment is covered by the
# forward in 04-incoming-reply-forward instead.
- tapOn: "Attach"
- extendedWaitUntil:
visible: "Photos & Videos"
timeout: 10000
- assertVisible: "Camera"
- assertVisible: "Files"
- takeScreenshot: compose-02-attach-sheet
- tapOn: "Photos & Videos"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-03-photo-picker
# Dismiss the picker sheet.
- swipe:
start: 50%,12%
end: 50%,95%
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
notVisible: "New Message"
commands:
- swipe:
start: 50%,12%
end: 50%,95%
duration: 400
- extendedWaitUntil:
visible: "New Message"
timeout: 10000
- tapOn: "Send"
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: ${output.subject}
@@ -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
+27
View File
@@ -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
+36
View File
@@ -0,0 +1,36 @@
# 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
- swipe:
from: ".*Grüße aus München.*"
direction: LEFT
duration: 400
- extendedWaitUntil:
visible: "Email archived"
timeout: 10000
- takeScreenshot: swipe-01-archived
- assertNotVisible: ".*Grüße aus München.*"
- tapOn: "UNDO"
- 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
+74
View File
@@ -0,0 +1,74 @@
# 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: "Projects"
- extendedWaitUntil:
visible: "Moved to Projects"
timeout: 15000
- takeScreenshot: actions-02-moved
- tapOn: "UNDO"
- 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: "Delete"
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: actions-03b-after-delete
- extendedWaitUntil:
visible: "Email moved to Trash"
timeout: 15000
- takeScreenshot: actions-03-deleted
- tapOn: "UNDO"
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Select from the list and star, then unstar
- longPressOn: ".*Grüße aus München.*"
- extendedWaitUntil:
visible: "Clear selection"
timeout: 10000
- takeScreenshot: actions-04-selected
- tapOn: "Star"
# 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"
+57
View File
@@ -0,0 +1,57 @@
# 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:
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
+115
View File
@@ -0,0 +1,115 @@
# 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:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn:
text: "Allow"
optional: true
- 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}
- scrollUntilVisible:
element: "Add reminder"
direction: DOWN
timeout: 10000
- tapOn: "Add reminder"
- tapOn: "15 minutes before"
- takeScreenshot: calendar-05-editor
- tapOn: "Save"
- extendedWaitUntil:
notVisible: "New event"
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
+70
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
# 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}.*"
- tapOn: "Delete"
- extendedWaitUntil:
visible: "Delete this item.*"
timeout: 10000
- tapOn:
text: "Delete"
below: "Delete this item.*"
- extendedWaitUntil:
notVisible: ".*${output.folder}.*"
timeout: 20000
- tapOn:
id: tab-mail
+38
View File
@@ -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, .*", PROOF: "Date Format"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", PROOF: "Email Notifications"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", PROOF: "Choose your preferred color scheme"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", PROOF: "Tune the email list interactions for mobile."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", PROOF: "Mark as Read"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", PROOF: "Composer"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", PROOF: "Sending Identities"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", PROOF: "Reply automatically"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", PROOF: "Email Filters"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", PROOF: "Email Templates"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", PROOF: ".*Inbox.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", PROOF: "Email Tags"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", PROOF: "Email .*eml.* filename"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", PROOF: "Account Security|Detecting server capabilities.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", PROOF: "External Content"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", PROOF: "Default view"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", PROOF: "Group by first letter"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", PROOF: "Default View"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", PROOF: "Keep Apps Loaded"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", PROOF: "The default light and dark palettes."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", PROOF: "Offline mail.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", PROOF: "This device"}}
- tapOn:
id: tab-mail
+30
View File
@@ -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
+27
View File
@@ -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
+33
View File
@@ -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
+23
View File
@@ -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"
+28
View File
@@ -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
+11
View File
@@ -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.*"
+69
View File
@@ -0,0 +1,69 @@
# Push on iOS: the relay's payload carries only ids; the notification service
# extension (plugins/ios-push) fetches sender and subject before the banner
# shows. A tap opens the message, with the app running and from a cold start.
# The helper sends the message as userb and pushes with simctl, so this needs
# no APNs key.
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
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject)}
# The banner shows for a few seconds: catch it with either text, then check.
- extendedWaitUntil:
visible: ".*${output.subject}.*|.*New message.*"
timeout: 30000
- takeScreenshot: push-01-banner
# Sender and subject come from the extension, not the relay.
- assertVisible: ".*User B.*"
- assertNotVisible: ".*New message.*"
- tapOn: ".*${output.subject}.*"
- extendedWaitUntil:
visible: "Reply All"
timeout: 60000
- assertVisible: ".*${output.subject}.*"
- takeScreenshot: push-02-opened-warm
# Cold start: the app isn't running when the notification is tapped.
- stopApp
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject2)}
- extendedWaitUntil:
visible: ".*${output.subject2}.*"
timeout: 30000
- takeScreenshot: push-03-banner-cold
- tapOn: ".*${output.subject2}.*"
- extendedWaitUntil:
visible: "Reply All"
timeout: 90000
- assertVisible: ".*${output.subject2}.*"
- takeScreenshot: push-04-opened-cold
+8
View File
@@ -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);
+32
View File
@@ -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
+58
View File
@@ -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
+24
View File
@@ -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
+20
View File
@@ -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
+39
View File
@@ -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
+20
View File
@@ -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
+24
View File
@@ -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
+17
View File
@@ -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
+16
View File
@@ -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
+15
View File
@@ -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
+15
View File
@@ -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
+21
View File
@@ -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"
+44
View File
@@ -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"
+32
View File
@@ -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"
+23
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
# Second account by password via "Another account on <host>", 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
+41
View File
@@ -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
+43
View File
@@ -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
+37
View File
@@ -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
+52
View File
@@ -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.*"
+11
View File
@@ -0,0 +1,11 @@
# iOS asks before an app opens a sign-in session in the system browser:
# "“Bulwark Mobile” Wants to Use “<domain>” 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.*"
+18
View File
@@ -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}"
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
# 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).
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Write your message.*"
commands:
- tapOn: "Write your message.*"
- runFlow:
when:
notVisible: "Write your message.*"
commands:
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${BODY}
+19
View File
@@ -0,0 +1,19 @@
# 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"
- extendedWaitUntil:
visible: "Sign in with a link.|Open the sign-in page of.*"
timeout: 20000
- takeScreenshot: link-confirm-prompt
- tapOn:
text: "Continue"
below: "Sign in with a link."
@@ -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.*"
@@ -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
+9
View File
@@ -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: "<name>, <unread>, /, <total>".
- tapOn: ${FOLDER}([ ,].*)?
- extendedWaitUntil:
visible: ${FOLDER}
timeout: 15000
+16
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
# 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.
- assertVisible: ${INBOX_TEXT || '.*Your verification code.*'}
+19
View File
@@ -0,0 +1,19 @@
# 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
+19
View File
@@ -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
+24
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
# From a message list: open the message whose row mentions SUBJECT.
appId: org.bulwarkmail.mobile
---
- scrollUntilVisible:
element: ".*${SUBJECT}.*"
direction: DOWN
timeout: 30000
- tapOn: ".*${SUBJECT}.*"
- extendedWaitUntil:
visible: "Reply All"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
+19
View File
@@ -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
+11
View File
@@ -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
+22
View File
@@ -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.*"
+24
View File
@@ -0,0 +1,24 @@
# Settings list: scroll to the row ROW (regex on "Title, summary"), open it,
# check PROOF shows, screenshot, and go back on a phone. On the iPad the list
# is a left column, so scroll there instead of the middle of the screen.
appId: org.bulwarkmail.mobile
---
- repeat:
while:
notVisible: ${ROW}
times: 10
commands:
- swipe:
start: "${DEVICE == 'tablet' ? '18%,80%' : '50%,80%'}"
end: "${DEVICE == 'tablet' ? '18%,40%' : '50%,40%'}"
duration: 600
- tapOn: ${ROW}
- extendedWaitUntil:
visible: ${PROOF}
timeout: 20000
- takeScreenshot: settings-${NAME}
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
+19
View File
@@ -0,0 +1,19 @@
# Signed in: sign out of the active account from the end of Settings.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- 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
+28
View File
@@ -0,0 +1,28 @@
# 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
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox"
timeout: 120000
- runFlow:
when:
visible: "Enter server details manually"
commands:
- runFlow:
file: password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- tapOn:
id: tab-mail
- runFlow: go-to-inbox.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
+27
View File
@@ -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
+21
View File
@@ -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"
+22
View File
@@ -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}
+25
View File
@@ -0,0 +1,25 @@
# 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 select the
# field's text and type over it. 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:
- runFlow:
when:
notVisible: ${TEXT}
commands:
- longPressOn: ".*${TEXT}.*"
- tapOn:
text: "Select All"
optional: true
- inputText: ${TEXT}
- assertVisible: ${TEXT}
+54
View File
@@ -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}
+41
View File
@@ -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"
+5
View File
@@ -297,6 +297,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Mail" name="Mail"
options={{ options={{
title: tabLabels.mail, title: tabLabels.mail,
tabBarButtonTestID: 'tab-mail',
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />, tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined, tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: { tabBarBadgeStyle: {
@@ -320,6 +321,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={CalendarScreen} component={CalendarScreen}
options={{ options={{
title: tabLabels.calendar, title: tabLabels.calendar,
tabBarButtonTestID: 'tab-calendar',
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />, tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle, tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar), tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
@@ -335,6 +337,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={ContactsTab} component={ContactsTab}
options={{ options={{
title: tabLabels.contacts, title: tabLabels.contacts,
tabBarButtonTestID: 'tab-contacts',
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />, tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle, tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts), tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
@@ -350,6 +353,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={FilesScreen} component={FilesScreen}
options={{ options={{
title: tabLabels.files, title: tabLabels.files,
tabBarButtonTestID: 'tab-files',
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />, tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle, tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files), tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
@@ -364,6 +368,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Settings" name="Settings"
options={{ options={{
title: tabLabels.settings, title: tabLabels.settings,
tabBarButtonTestID: 'tab-settings',
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />, tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}} }}
> >
+34 -8
View File
@@ -90,8 +90,8 @@ react {
/* Variants */ /* Variants */
// The list of variants to that are debuggable. For those we're going to // The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'. // skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. // The distribution flavors (github, play) need their debug variants listed.
// debuggableVariants = ["liteDebug", "prodDebug"] debuggableVariants = ["githubDebug", "playDebug"]
/* Bundling */ /* Bundling */
// A list containing the node command and its flags. Default is just 'node'. // A list containing the node command and its flags. Default is just 'node'.
@@ -154,13 +154,39 @@ android {
versionCode appVersionCode.toInteger() versionCode appVersionCode.toInteger()
versionName appVersionName versionName appVersionName
// Android account type of device sync (#34), derived from the package
// name so a later rename needs no other change. The XML in res/xml and
// the Kotlin in sync/ read it as @string/device_sync_account_type.
resValue "string", "device_sync_account_type", "${applicationId}.account"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
/**
* Where the build is distributed. The two are separate apps on a phone:
*
* - github: the APK on GitHub Releases, sideloaded, updated by the in-app
* updater. Keeps the original package name so existing installs update.
* - play: the Google Play bundle. Play forbids an app updating itself
* outside Play, so this flavor has no installer permission and the JS
* hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities).
*
* See docs/android-release.md.
*/
flavorDimensions = ["distribution"]
productFlavors {
github {
dimension "distribution"
applicationId 'com.anonymous.bulwarkmobile'
buildConfigField "String", "DISTRIBUTION", '"github"'
}
play {
dimension "distribution"
applicationId 'org.bulwarkmail.mobile'
buildConfigField "String", "DISTRIBUTION", '"play"'
}
}
applicationVariants.all { variant ->
// 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 { signingConfigs {
debug { debug {
storeFile file('debug.keystore') storeFile file('debug.keystore')
@@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') {
} }
} }
tasks.configureEach { task -> tasks.configureEach { task ->
if (task.name == 'preReleaseBuild') { if (task.name ==~ /pre\w*ReleaseBuild/) {
task.dependsOn(checkReleaseSigning) task.dependsOn(checkReleaseSigning)
} }
} }
+8 -8
View File
@@ -1,13 +1,13 @@
{ {
"project_info": { "project_info": {
"project_number": "504486613721", "project_number": "752109678859",
"project_id": "bulwark-mobile", "project_id": "bulwark-20c72",
"storage_bucket": "bulwark-mobile.firebasestorage.app" "storage_bucket": "bulwark-20c72.firebasestorage.app"
}, },
"client": [ "client": [
{ {
"client_info": { "client_info": {
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8", "mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
"android_client_info": { "android_client_info": {
"package_name": "com.anonymous.bulwarkmobile" "package_name": "com.anonymous.bulwarkmobile"
} }
@@ -15,7 +15,7 @@
"oauth_client": [], "oauth_client": [],
"api_key": [ "api_key": [
{ {
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" "current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
} }
], ],
"services": { "services": {
@@ -26,15 +26,15 @@
}, },
{ {
"client_info": { "client_info": {
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8", "mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
"android_client_info": { "android_client_info": {
"package_name": "com.bulwark.bulwarkmobile" "package_name": "org.bulwarkmail.mobile"
} }
}, },
"oauth_client": [], "oauth_client": [],
"api_key": [ "api_key": [
{ {
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno" "current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
} }
], ],
"services": { "services": {
@@ -0,0 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The in-app updater hands the downloaded release APK to the system
installer (src/lib/install-update.ts). Only the sideloaded GitHub build
updates itself; the Play build is updated by Play. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
</manifest>
@@ -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"
}
+38 -34
View File
@@ -2,9 +2,13 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <!-- REQUEST_INSTALL_PACKAGES (in-app updater) is declared by the github flavor only. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <!-- Photos are picked with the system photo picker, which needs no media
permission; Play's photo and video policy rejects READ_MEDIA_IMAGES for that. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. --> <!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
@@ -110,224 +114,224 @@
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label"> <receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label"> <receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
</receiver> </receiver>
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label"> <receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label"> <receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
</receiver> </receiver>
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label"> <receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label"> <receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label"> <receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label"> <receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label"> <receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
</receiver> </receiver>
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label"> <receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label"> <receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label"> <receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label"> <receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label"> <receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
</receiver> </receiver>
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label"> <receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label"> <receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label"> <receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label"> <receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label"> <receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label"> <receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label"> <receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label"> <receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
</receiver> </receiver>
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label"> <receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label"> <receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label"> <receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label"> <receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label"> <receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label"> <receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label"> <receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label"> <receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
</receiver> </receiver>
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label"> <receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
</receiver> </receiver>
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label"> <receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/> <action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
</receiver> </receiver>
@@ -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<String, Any> = mapOf(
"distribution" to BuildConfig.DISTRIBUTION,
)
}
@@ -16,6 +16,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkDeviceSyncModule(reactContext)) add(BulwarkDeviceSyncModule(reactContext))
add(BulwarkWidgetsModule(reactContext)) add(BulwarkWidgetsModule(reactContext))
add(BulwarkPdfModule(reactContext)) add(BulwarkPdfModule(reactContext))
add(BulwarkBuildModule(reactContext))
// The bridge registers React Native's own module under this name. // The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext)) add(BulwarkHeadlessJsTaskSupportModule(reactContext))
+12
View File
@@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7);
// fall back to 1 (never uploaded). // fall back to 1 (never uploaded).
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1'; 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 = { module.exports = {
expo: { expo: {
name: 'Bulwark Mobile', name: 'Bulwark Mobile',
@@ -96,12 +103,17 @@ module.exports = {
microphonePermission: false, microphonePermission: false,
}, },
], ],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
], ],
extra: { extra: {
commit: COMMIT, commit: COMMIT,
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL // Settings → About → Source code. A modified build sets SOURCE_CODE_URL
// to where its own source is (AGPL §13); the default is upstream. // to where its own source is (AGPL §13); the default is upstream.
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native', 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',
}, },
}, },
}; };
+41 -3
View File
@@ -3,7 +3,8 @@
The release APK is built by The release APK is built by
[.github/workflows/release-android.yml](../.github/workflows/release-android.yml) [.github/workflows/release-android.yml](../.github/workflows/release-android.yml)
on an Ubuntu runner whenever a GitHub release is published, and attached to that 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`, 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 the debug key that ships with the React Native template. Its password is
@@ -199,6 +200,42 @@ 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 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. 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 to the internal
testing track as a draft. 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 ## Local release builds
CI signs releases, so you normally don't need the release key on your machine. CI signs releases, so you normally don't need the release key on your machine.
@@ -206,7 +243,8 @@ For a local release build, for profiling or checking R8, opt in to the debug key
```bash ```bash
cd android cd android
./gradlew assembleRelease -PallowDebugSignedRelease=true ./gradlew assembleGithubRelease -PallowDebugSignedRelease=true
./gradlew bundlePlayRelease -PallowDebugSignedRelease=true
``` ```
```powershell ```powershell
@@ -252,7 +290,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`). `retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`).
A local build writes its mapping to A local build writes its mapping to
`android/app/build/outputs/mapping/release/mapping.txt`. `android/app/build/outputs/mapping/<flavor>Release/mapping.txt`.
## versionCode ## versionCode
+9
View File
@@ -639,6 +639,12 @@
"up_to_date": "Already up to date" "up_to_date": "Already up to date"
} }
}, },
"push": {
"ios": {
"more_messages": "and {count} more",
"no_subject": "(no subject)"
}
},
"push_prompt": { "push_prompt": {
"body": "Turn on background notifications so new messages reach you while the app is closed.", "body": "Turn on background notifications so new messages reach you while the app is closed.",
"dismiss": "Not now", "dismiss": "Not now",
@@ -1008,6 +1014,8 @@
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.", "confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", "description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
"err_android_only": "Push notifications are only available on Android.", "err_android_only": "Push notifications are only available on Android.",
"err_apns_empty": "iOS returned an empty push token.",
"err_apns_token": "iOS could not issue a push token ({detail}).",
"err_fcm_empty": "Firebase returned an empty device token.", "err_fcm_empty": "Firebase returned an empty device token.",
"err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.", "err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.",
"err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.", "err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.",
@@ -1015,6 +1023,7 @@
"err_no_account": "No account loaded - cannot set up push.", "err_no_account": "No account loaded - cannot set up push.",
"err_permission": "Notification permission was not granted.", "err_permission": "Notification permission was not granted.",
"err_relay_https": "The relay URL must use https://.", "err_relay_https": "The relay URL must use https://.",
"err_relay_no_apns": "The push relay at {url} does not deliver to iPhones and iPads yet.",
"err_relay_rejected": "The push relay rejected the registration: {detail}", "err_relay_rejected": "The push relay rejected the registration: {detail}",
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).", "err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
"err_up_android_only": "UnifiedPush is only available on Android.", "err_up_android_only": "UnifiedPush is only available on Android.",
+161
View File
@@ -0,0 +1,161 @@
import Foundation
/// The little JMAP the extension needs: who sent the new messages and what
/// they are about, plus the inbox's unread count for the badge.
struct PushedEmail {
let id: String
let threadId: String
let sender: String
let subject: String
let preview: String
}
struct JmapFetchResult {
let emails: [PushedEmail]
let inboxUnread: Int?
}
enum JmapError: Error {
case noCredentials
case http(Int)
case badResponse
}
/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials).
struct StoredCredentials {
var json: [String: Any]
var username: String { json["username"] as? String ?? "" }
var password: String { json["password"] as? String ?? "" }
var accessToken: String? { json["accessToken"] as? String }
var refreshToken: String? { json["refreshToken"] as? String }
var tokenEndpoint: String? { json["tokenEndpoint"] as? String }
var clientId: String? { json["clientId"] as? String }
/// Milliseconds since 1970, like Date.now() in the app.
var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue }
}
enum Jmap {
static let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 12
config.timeoutIntervalForResource = 20
return URLSession(configuration: config)
}()
/// The Authorization header for these credentials, refreshing an OAuth
/// access token that is about to expire. A refreshed bundle is written back
/// to the shared keychain item so the app picks it up (and doesn't refresh
/// again with a refresh token the server has already replaced).
static func authorization(credentialsKey: String) async throws -> String {
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
var creds = StoredCredentials(json: json)
if let token = creds.accessToken {
let nowMs = Date().timeIntervalSince1970 * 1000
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
let url = URL(string: endpoint) {
if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) {
creds.json["accessToken"] = fresh.accessToken
if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh }
if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 }
SharedKeychain.update(credentialsKey, json: creds.json)
return "Bearer \(fresh.accessToken)"
}
}
return "Bearer \(token)"
}
let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString()
return "Basic \(basic)"
}
struct FreshTokens {
let accessToken: String
let refreshToken: String?
let expiresIn: Double?
}
static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
var form = URLComponents()
form.queryItems = [
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: refreshToken),
URLQueryItem(name: "client_id", value: clientId),
]
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let access = body["access_token"] as? String else { throw JmapError.badResponse }
return FreshTokens(
accessToken: access,
refreshToken: body["refresh_token"] as? String,
expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue
)
}
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
var request = URLRequest(url: apiUrl)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(authorization, forHTTPHeaderField: "Authorization")
let body: [String: Any] = [
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls": [
["Email/get", [
"accountId": accountId,
"ids": Array(emailIds.prefix(5)),
"properties": ["threadId", "from", "subject", "preview"],
], "0"],
["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"],
["Mailbox/get", [
"accountId": accountId,
"#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"],
"properties": ["unreadEmails"],
], "2"],
],
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
var emails: [PushedEmail] = []
var inboxUnread: Int?
for response in responses where response.count >= 2 {
guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue }
if name == "Email/get", let list = args["list"] as? [[String: Any]] {
// Keep the order the push gave (newest first from the server's filter).
// Stalwart can list an id twice; keep the first.
let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in
guard let id = item["id"] as? String else { return nil }
return (id, item)
}, uniquingKeysWith: { first, _ in first })
for id in emailIds {
guard let item = byId[id] else { continue }
let from = (item["from"] as? [[String: Any]])?.first
let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let sender = name ?? (from?["email"] as? String) ?? ""
emails.append(PushedEmail(
id: id,
threadId: item["threadId"] as? String ?? "",
sender: sender,
subject: item["subject"] as? String ?? "",
preview: item["preview"] as? String ?? ""
))
}
} else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] {
inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue
}
}
return JmapFetchResult(emails: emails, inboxUnread: inboxUnread)
}
}
+110
View File
@@ -0,0 +1,110 @@
import Foundation
import os
import UserNotifications
/// Ids and steps only, never credentials or message content.
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
/// Turns the relay's content-blind push ("New message" plus ids) into a real
/// notification: sender, subject and preview, fetched over JMAP on this
/// device with the account's own credentials. Nothing about the message ever
/// passes through the relay or Apple.
///
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
/// <credentialsKey> the account's stored credentials
/// If anything is missing or the server can't be reached, the relay's
/// placeholder text is shown, so the user still learns there is new mail.
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var placeholder: UNMutableNotificationContent?
private var work: Task<Void, Never>?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
placeholder = content
let push = request.content.userInfo["body"] as? [String: Any]
work = Task { [weak self] in
let enriched = await NotificationService.enrich(content, push: push)
self?.finish(enriched)
}
}
override func serviceExtensionTimeWillExpire() {
work?.cancel()
finish(placeholder)
}
private func finish(_ content: UNNotificationContent?) {
guard let handler = contentHandler, let content else { return }
contentHandler = nil
handler(content)
}
static func sanitize(_ id: String) -> String {
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
}
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
guard let push, let accountId = push["accountId"] as? String, !accountId.isEmpty else {
pushLog.error("push without an account id")
return content
}
pushLog.info("push for account \(accountId, privacy: .public), group \(SharedKeychain.group ?? "none", privacy: .public)")
guard let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"),
let credentialsKey = account["credentialsKey"] as? String,
let apiString = account["apiUrl"] as? String,
let apiUrl = URL(string: apiString) else {
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
return content
}
let emailIds = push["emailIds"] as? [String] ?? []
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
let strings = settings["strings"] as? [String: String] ?? [:]
content.threadIdentifier = accountId
// The app reads userInfo.body when the notification is tapped.
var body = push
do {
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey)
let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization)
pushLog.info("fetched \(result.emails.count, privacy: .public) of \(emailIds.count, privacy: .public) messages")
if let first = result.emails.first {
content.title = first.sender
content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject
content.body = first.preview
if result.emails.count > 1 {
let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1))
content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)"
}
body["emailId"] = first.id
body["threadId"] = first.threadId
}
// Badge: like the app, the active account's inbox unread count. A push
// for another account leaves the badge as it is.
if settings["badge"] as? Bool == true,
settings["activeAccountId"] as? String == accountId,
let unread = result.inboxUnread {
content.badge = NSNumber(value: unread)
}
} catch {
// Keep the placeholder.
pushLog.error("fetch failed: \(String(describing: error), privacy: .public)")
}
var userInfo = content.userInfo
userInfo["body"] = body
content.userInfo = userInfo
return content
}
}
+54
View File
@@ -0,0 +1,54 @@
import Foundation
import Security
/// Items the app shares with this extension through a keychain access group
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
/// expo-secure-store: generic passwords whose account and generic attributes
/// are the UTF-8 key and whose service is "app:no-auth".
enum SharedKeychain {
static let service = "app:no-auth"
static var group: String? {
Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String
}
static func read(_ key: String) -> Data? {
guard let group else { return nil }
let account = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: group,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
pushLog.error("keychain read of \(key, privacy: .public) failed: \(status, privacy: .public)")
return nil
}
return item as? Data
}
static func readJSON(_ key: String) -> [String: Any]? {
guard let data = read(key) else { return nil }
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
}
/// Replaces the value of an existing item (refreshed OAuth tokens). Never
/// creates one: the app owns which items exist.
@discardableResult
static func update(_ key: String, json: [String: Any]) -> Bool {
guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false }
let account = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessGroup as String: group,
]
let attributes: [String: Any] = [kSecValueData as String: data]
return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
}
}
+195
View File
@@ -0,0 +1,195 @@
// Config plugin: push notifications on iOS.
//
// - The app's entitlements: aps-environment, and a keychain group shared with
// the notification service extension (the app's own group stays first, so
// items saved before keep their group).
// - A Notification Service Extension target ("NotificationService",
// org.bulwarkmail.mobile.NotificationService). The relay's push carries
// only ids; the extension fetches sender and subject over JMAP on the device
// before iOS shows the notification. Its sources live next to this file and
// are copied into ios/ at prebuild, like everything else in ios/.
//
// Options (app.config.js):
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
// teamId: Apple team id, for the shared keychain group
// extensionProfile / appProfile: provisioning profile specifiers for the
// Release configuration of the extension and the app (IOS_NSE_PROFILE and
// IOS_APP_PROFILE env win). Set per target because xcodebuild's command
// line applies a specifier to every target. Empty = leave unset.
const fs = require('node:fs');
const path = require('node:path');
const {
withEntitlementsPlist,
withXcodeProject,
withDangerousMod,
createRunOncePlugin,
} = require('@expo/config-plugins');
const TARGET = 'NotificationService';
const DEPLOYMENT_TARGET = '15.1';
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
function sharedGroupSuffix() {
return 'org.bulwarkmail.shared';
}
function withAppEntitlements(config, { apsEnvironment }) {
return withEntitlementsPlist(config, (cfg) => {
const bundleId = cfg.ios?.bundleIdentifier;
cfg.modResults['aps-environment'] = apsEnvironment;
const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []);
groups.add(`$(AppIdentifierPrefix)${bundleId}`);
groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`);
cfg.modResults['keychain-access-groups'] = [...groups];
return cfg;
});
}
function infoPlist({ version, buildNumber }) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Bulwark Mail notifications</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${buildNumber}</string>
<key>BulwarkKeychainGroup</key>
<string>$(DEVELOPMENT_TEAM).${sharedGroupSuffix()}</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
`;
}
function entitlementsPlist() {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)${sharedGroupSuffix()}</string>
</array>
</dict>
</plist>
`;
}
function withExtensionFiles(config) {
return withDangerousMod(config, [
'ios',
async (cfg) => {
const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
fs.mkdirSync(dir, { recursive: true });
for (const file of SOURCE_FILES) {
fs.copyFileSync(path.join(__dirname, file), path.join(dir, file));
}
fs.writeFileSync(
path.join(dir, `${TARGET}-Info.plist`),
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
);
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
return cfg;
},
]);
}
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
if (!appProfile && !extensionProfile) return;
const project = cfg.modResults;
const appName = `"${cfg.modRequest.projectName}"`;
const configs = project.pbxXCBuildConfigurationSection();
for (const key of Object.keys(configs)) {
const entry = configs[key];
if (!entry.buildSettings || entry.name !== 'Release') continue;
const name = entry.buildSettings.PRODUCT_NAME;
const profile = name === `"${TARGET}"` ? extensionProfile
: (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile
: '';
if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`;
}
}
// One mod for creating the target and naming the profiles: separate
// withXcodeProject mods don't run in the order they are registered.
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
return withXcodeProject(config, (cfg) => {
addExtensionTarget(cfg, { teamId });
setReleaseProfiles(cfg, { appProfile, extensionProfile });
return cfg;
});
}
function addExtensionTarget(cfg, { teamId }) {
const project = cfg.modResults;
if (project.pbxTargetByName(TARGET)) return;
// addTarget expects these sections; a fresh Expo project may not have them.
const objects = project.hash.project.objects;
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
const group = project.addPbxGroup(
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
TARGET,
TARGET,
);
// Hang the group off the project's main group.
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
project.addToPbxGroup(group.uuid, mainGroupId);
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
const configs = project.pbxXCBuildConfigurationSection();
for (const key of Object.keys(configs)) {
const entry = configs[key];
if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue;
const s = entry.buildSettings;
s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`;
s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET;
s.TARGETED_DEVICE_FAMILY = '"1,2"';
s.SWIFT_VERSION = '5.0';
s.CLANG_ENABLE_MODULES = 'YES';
s.GENERATE_INFOPLIST_FILE = 'NO';
if (teamId) s.DEVELOPMENT_TEAM = teamId;
}
}
function withIosPush(config, options = {}) {
const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production';
const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || '';
const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
config = withAppEntitlements(config, { apsEnvironment });
config = withExtensionFiles(config);
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
return config;
}
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Boot a fresh simulator for one test leg and install the app.
#
# scripts/e2e/boot-sim.sh <phone|tablet> <light|dark> <app path>
#
# Writes UDID and SIM_NAME to $GITHUB_ENV. Every simulator gets the keyboard
# set up for XCTest typing: a fresh one shows the slide-to-type tip over the
# keyboard the first time a field gets focus, and predictions, autocorrect and
# password AutoFill can swallow or rewrite typed text.
set -euo pipefail
DEVICE=$1
APPEARANCE=$2
APP=$3
case "$DEVICE" in
phone) PATTERN='^iPhone' ;;
tablet) PATTERN='^iPad Pro 11|^iPad Air 11|^iPad \(' ;;
*) echo "unknown device $DEVICE" >&2; exit 1 ;;
esac
# The newest matching device type on the newest iOS runtime the image ships.
RUNTIME=$(xcrun simctl list runtimes available -j | jq -r '[.runtimes[] | select(.platform == "iOS")] | sort_by(.version | split(".") | map(tonumber)) | last | .identifier')
UDID=$(xcrun simctl list devices available -j | jq -r --arg rt "$RUNTIME" --arg re "$PATTERN" '
[.devices[$rt][] | select(.name | test($re))] | last | .udid // empty')
if [ -z "$UDID" ]; then
xcrun simctl list devices available
echo "::error::No $DEVICE simulator on $RUNTIME"
exit 1
fi
NAME=$(xcrun simctl list devices -j | jq -r --arg u "$UDID" '.devices[][] | select(.udid == $u) | .name')
echo "Using $NAME ($UDID) on $RUNTIME"
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b
PREFS=com.apple.keyboard.preferences
xcrun simctl spawn "$UDID" defaults write $PREFS DidShowContinuousPathIntroduction -bool true
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardContinuousPathEnabled -bool false
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardPrediction -bool false
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardAutocorrection -bool false
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardAutocapitalization -bool false
xcrun simctl spawn "$UDID" defaults write $PREFS KeyboardShowPredictionBar -bool false
xcrun simctl spawn "$UDID" defaults write com.apple.WebUI AutoFillPasswords -bool false || true
xcrun simctl ui "$UDID" appearance "$APPEARANCE"
xcrun simctl install "$UDID" "$APP"
# A photo for the compose flow's attachment.
xcrun simctl addmedia "$UDID" "$(dirname "$0")/../../assets/icon.png" || true
{
echo "UDID=$UDID"
echo "SIM_NAME=$NAME"
} >> "${GITHUB_ENV:-/dev/null}"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
// Summarise Maestro's debug output (~/.maestro/tests) without its secrets.
//
// The JSON there records every command with its arguments, so typed
// passwords are in it; it is never uploaded. This prints, per flow, the
// command types and their status (no arguments), and for a failed command
// the element tree Maestro saw (labels, ids and bounds), which is what a
// failing selector needs. run-flows.sh scrubs the secret values from the
// output as well.
import fs from 'node:fs';
import path from 'node:path';
const root = process.argv[2];
const out = [];
function walkFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
return e.isDirectory() ? walkFiles(p) : [p];
});
}
function tree(node, depth, lines) {
if (!node || typeof node !== 'object') return;
const a = node.attributes || {};
const label = [a.accessibilityText, a.text, a.hintText, a['resource-id'] && `#${a['resource-id']}`]
.filter(Boolean)
.filter((v, i, all) => all.indexOf(v) === i)
.join(' | ');
if (label) lines.push(`${' '.repeat(Math.min(depth, 12))}${label} ${a.bounds || ''}${a.focused === 'true' ? ' [focused]' : ''}`);
for (const c of node.children || []) tree(c, depth + 1, lines);
}
function findHierarchy(obj, seen = new Set()) {
if (!obj || typeof obj !== 'object' || seen.has(obj)) return null;
seen.add(obj);
if (obj.attributes && Array.isArray(obj.children)) return obj;
for (const v of Object.values(obj)) {
const h = findHierarchy(v, seen);
if (h) return h;
}
return null;
}
for (const file of walkFiles(root).filter((f) => /commands-.*\.json$/.test(f))) {
let data;
try {
data = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
continue;
}
out.push(`== ${path.basename(file)}`);
for (const entry of Array.isArray(data) ? data : []) {
const command = entry.command || {};
const type = Object.keys(command).find((k) => command[k]) || '?';
const status = entry.metadata?.status || '?';
out.push(` ${status.padEnd(9)} ${type}`);
if (status === 'FAILED') {
const err = entry.metadata?.error?.message;
if (err) out.push(` error: ${String(err).slice(0, 300)}`);
const h = findHierarchy(entry.metadata);
if (h) {
const lines = [];
tree(h, 0, lines);
out.push(...lines.slice(0, 250).map((l) => ` ${l}`));
}
}
}
}
// Maestro 2 keeps the screen hierarchy of each failed step under
// screen-hierarchy/; it holds what the screen showed (like the screenshot).
for (const file of walkFiles(root).filter((f) => f.includes('screen-hierarchy') && f.endsWith('.json'))) {
let data;
try {
data = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
continue;
}
const h = findHierarchy(data);
if (!h) continue;
const parts = file.split(path.sep).join('/').split('/');
out.push(`== ${parts[parts.length - 3]} / ${parts[parts.length - 1]}`);
const lines = [];
tree(h, 0, lines);
out.push(...lines.slice(0, 300).map((l) => ` ${l}`));
}
process.stdout.write(out.join('\n') + '\n');
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env node
// Test helper for the iOS simulator flows (.maestro/). Runs on the CI runner
// next to the simulator; Maestro flows call it from runScript over
// http://127.0.0.1:8787 for things a flow can't do itself:
//
// GET /totp current TOTP code of the e2e-totp-* accounts
// GET /pair?user=&deliver= sign in to the test webmail with Playwright,
// create a fresh "Link mobile app" code and hand
// its bulwarkmail://pair link to the app
// (deliver=openurl: simctl openurl, as a link
// from another app; pbcopy: simctl pbcopy for
// "Paste sign-in link"; none: keep it only)
// GET /redeliver?user=&deliver= hand the last link for that user over again
// GET /pair-later?user=&deliver=&delay= create a code now, deliver it after
// <delay> seconds (expired-code test)
// GET /send?to=&subject=&attach=1&html=1 userb sends a message over JMAP
// GET /find?subject=&as=userb&timeout= wait until a message with that subject
// arrives; answers {found, attachments}
// GET /simpush?to=&subject= userb sends a message to `to`; then the
// relay's APNs payload for it (ids only) is
// pushed to the simulator with simctl push,
// so the app's notification extension runs
// GET /openurl?url= simctl openurl for other links (mailto:, ...)
// GET /clipboard?text= simctl pbcopy of a plain value
//
// Secrets come from the environment and never appear in a response: the
// pairing link is delivered to the simulator directly, not returned. The
// answers are JSON. Logs go to stdout without secrets or links.
//
// Env: E2E_SERVER (JMAP server), E2E_PASSWORD (shared by the e2e accounts),
// E2E_USERB_PASSWORD, E2E_TOTP_SECRET (base32), E2E_WEBMAIL (test webmail base URL), UDID
// (booted simulator), PLAYWRIGHT_DIR (node_modules folder with playwright).
import fs from 'node:fs';
import os from 'node:os';
import http from 'node:http';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
const PORT = Number(process.env.HELPER_PORT || 8787);
const SERVER = (process.env.E2E_SERVER || '').replace(/\/+$/, '');
const WEBMAIL = (process.env.E2E_WEBMAIL || 'https://bulwark.internal.bulwarkmail.org').replace(/\/+$/, '');
const PASSWORD = process.env.E2E_PASSWORD || '';
const TOTP_SECRET = process.env.E2E_TOTP_SECRET || '';
const UDID = process.env.UDID || 'booted';
const DOMAIN = 'example.org';
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
// ── TOTP (RFC 6238, SHA-1, 6 digits, 30 s) ──────────────────────────────────
function base32Decode(s) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
const out = [];
for (const ch of s.replace(/=+$/, '').toUpperCase()) {
const idx = alphabet.indexOf(ch);
if (idx < 0) continue;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(out);
}
function totpAt(secret, time) {
const counter = Math.floor(time / 30);
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const mac = crypto.createHmac('sha1', base32Decode(secret)).update(buf).digest();
const offset = mac[mac.length - 1] & 0xf;
const code = (mac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
return String(code).padStart(6, '0');
}
// A code with at least `minLeft` seconds to live, waiting for the next window
// when the current one is about to close.
async function freshTotp(minLeft = 10) {
if (!TOTP_SECRET) throw new Error('E2E_TOTP_SECRET is not set');
const now = Date.now() / 1000;
const left = 30 - (now % 30);
if (left < minLeft) await sleep((left + 0.5) * 1000);
return totpAt(TOTP_SECRET, Date.now() / 1000);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── simulator ───────────────────────────────────────────────────────────────
function simctl(args, input) {
return new Promise((resolve, reject) => {
const child = execFile('xcrun', ['simctl', ...args], { timeout: 30_000 }, (err, stdout, stderr) => {
if (err) reject(new Error(`simctl ${args[0]} failed: ${stderr || err.message}`));
else resolve(stdout);
});
if (input !== undefined) {
child.stdin.end(input);
}
});
}
async function deliver(link, how) {
if (how === 'openurl') await simctl(['openurl', UDID, link]);
else if (how === 'pbcopy') await simctl(['pbcopy', UDID], link);
else if (how !== 'none') throw new Error(`unknown deliver=${how}`);
}
// ── webmail pairing (Playwright) ────────────────────────────────────────────
let chromium = null;
function loadPlaywright() {
if (chromium) return chromium;
const dir = process.env.PLAYWRIGHT_DIR;
const req = createRequire(dir ? path.join(dir, 'noop.js') : import.meta.url);
({ chromium } = req('playwright'));
return chromium;
}
const lastLinks = new Map();
async function createPairingLink(user) {
const browser = await loadPlaywright().launch();
const started = Date.now();
let page = null;
try {
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1280, height: 900 } });
page = await context.newPage();
await page.goto(`${WEBMAIL}/en/login`, { waitUntil: 'domcontentloaded' });
await page.locator('#username').fill(`${user}@${DOMAIN}`);
await page.locator('#password').fill(PASSWORD);
// Keep the session across page loads (a Basic session without "remember
// me" is signed out by the next navigation).
const remember = page.getByRole('checkbox', { name: /remember/i });
if (await remember.count()) await remember.first().check().catch(() => undefined);
const totpField = page.locator('#totp');
if (await totpField.isVisible().catch(() => false)) await totpField.fill(await freshTotp());
await page.locator('button[type=submit]').first().click();
// A TOTP account: the webmail asks for the code after the password.
const signedIn = page.waitForURL((u) => !/\/login/.test(u.pathname), { timeout: 30_000 }).then(() => 'in');
const askedCode = totpField.waitFor({ state: 'visible', timeout: 30_000 }).then(() => 'totp');
const first = await Promise.race([signedIn, askedCode]);
if (first === 'totp' && /\/login/.test(new URL(page.url()).pathname)) {
await totpField.fill(await freshTotp());
await page.locator('button[type=submit]').first().click();
await page.waitForURL((u) => !/\/login/.test(u.pathname), { timeout: 30_000 });
}
await page.goto(`${WEBMAIL}/en/settings/security`, { waitUntil: 'domcontentloaded' });
const show = page.getByRole('button', { name: /Show QR code/i });
await show.waitFor({ timeout: 30_000 });
await show.click();
// Step-up: the password again (and a code for a TOTP account).
const linkInput = page.locator('input[readonly][value^="bulwarkmail://"]');
// The Security page has other password forms; use the link-device one.
const form = page.locator('form', { hasText: /confirm it's you/i });
const pw = form.locator('input[type=password]').first();
await Promise.race([
linkInput.waitFor({ timeout: 30_000 }),
pw.waitFor({ timeout: 30_000 }),
]);
if (!(await linkInput.count())) {
await pw.fill(PASSWORD);
// For a TOTP account the code field is there from the start.
const codeField = form.locator('input:not([type=password])');
if (await codeField.count()) await codeField.last().fill(await freshTotp());
await form.getByRole('button', { name: /^Continue$/ }).click();
const codeAsked = page.getByText(/code from your authenticator app/i);
await Promise.race([
linkInput.waitFor({ timeout: 30_000 }),
codeAsked.waitFor({ timeout: 30_000 }),
]);
if (!(await linkInput.count())) {
if (!(await pw.inputValue())) await pw.fill(PASSWORD);
await form.locator('input:not([type=password])').last().fill(await freshTotp());
await form.getByRole('button', { name: /^Continue$/ }).click();
await linkInput.waitFor({ timeout: 30_000 });
}
}
const link = await linkInput.inputValue();
if (!link.startsWith('bulwarkmail://pair?')) throw new Error('no pairing link on the page');
log(`pair: code for ${user} after ${Date.now() - started} ms`);
return link;
} catch (err) {
// What the webmail showed, for the run's artifacts (passwords are masked
// on the page; a TOTP code is dead in 30 s).
if (page && process.env.HELPER_SHOTS) {
await page.screenshot({ path: path.join(process.env.HELPER_SHOTS, `pair-${user}-${Date.now()}.png`), fullPage: true }).catch(() => undefined);
}
throw err;
} finally {
await browser.close();
}
}
// ── JMAP as userb (or any e2e account) ─────────────────────────────────────
const sessions = new Map();
async function jmapSession(user) {
if (sessions.has(user)) return sessions.get(user);
const pass = user === 'userb' && process.env.E2E_USERB_PASSWORD ? process.env.E2E_USERB_PASSWORD : PASSWORD;
const auth = 'Basic ' + Buffer.from(`${user}@${DOMAIN}:${pass}`).toString('base64');
const res = await fetch(`${SERVER}/.well-known/jmap`, { headers: { Authorization: auth } });
if (!res.ok) throw new Error(`session for ${user}: HTTP ${res.status}`);
const session = await res.json();
const s = {
auth,
apiUrl: session.apiUrl,
uploadUrl: session.uploadUrl,
accountId: session.primaryAccounts['urn:ietf:params:jmap:mail'],
};
sessions.set(user, s);
return s;
}
async function jmap(user, calls) {
const s = await jmapSession(user);
const res = await fetch(s.apiUrl, {
method: 'POST',
headers: { Authorization: s.auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission'],
methodCalls: calls,
}),
});
if (!res.ok) throw new Error(`JMAP as ${user}: HTTP ${res.status}`);
return (await res.json()).methodResponses;
}
async function upload(user, data, type) {
const s = await jmapSession(user);
const res = await fetch(s.uploadUrl.replace('{accountId}', s.accountId), {
method: 'POST',
headers: { Authorization: s.auth, 'Content-Type': type },
body: data,
});
if (!res.ok) throw new Error(`upload as ${user}: HTTP ${res.status}`);
return (await res.json()).blobId;
}
async function sendMail({ from = 'userb', to, subject, text, html, attach }) {
const s = await jmapSession(from);
const [[, boxes], [, ids]] = await jmap(from, [
['Mailbox/get', { accountId: s.accountId, properties: ['role'] }, '0'],
['Identity/get', { accountId: s.accountId }, '1'],
]);
const drafts = boxes.list.find((m) => m.role === 'drafts')?.id;
const sent = boxes.list.find((m) => m.role === 'sent')?.id;
const identity = ids.list.find((i) => i.email === `${from}@${DOMAIN}`) ?? ids.list[0];
const email = {
mailboxIds: { [drafts]: true },
keywords: { $draft: true },
from: [{ name: identity.name || from, email: `${from}@${DOMAIN}` }],
to: [{ email: to.includes('@') ? to : `${to}@${DOMAIN}` }],
subject,
bodyValues: { t: { value: text || `Test message "${subject}" from the iOS simulator tests.\n` } },
textBody: [{ partId: 't', type: 'text/plain' }],
};
if (html) {
email.bodyValues.h = { value: `<html><body><h1 style="color:#2563eb">${subject}</h1><p>An <b>HTML</b> test message with a <a href="https://bulwarkmail.org">link</a>.</p><table border="1"><tr><td>cell 1</td><td>cell 2</td></tr></table></body></html>` };
email.htmlBody = [{ partId: 'h', type: 'text/html' }];
}
if (attach) {
const pdf = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj ' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj ' +
'4 0 obj<</Length 44>>stream\nBT /F1 18 Tf 20 70 Td (E2E attachment) Tj ET\nendstream endobj ' +
'5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n',
);
const blobId = await upload(from, pdf, 'application/pdf');
email.attachments = [{ blobId, type: 'application/pdf', name: 'e2e-attachment.pdf', disposition: 'attachment' }];
}
const resp = await jmap(from, [
['Email/set', { accountId: s.accountId, create: { m: email } }, '0'],
['EmailSubmission/set', {
accountId: s.accountId,
create: { s: { identityId: identity.id, emailId: '#m' } },
onSuccessUpdateEmail: { '#s': { [`mailboxIds/${drafts}`]: null, [`mailboxIds/${sent}`]: true, 'keywords/$draft': null } },
}, '1'],
]);
const created = resp.find(([name, args]) => name === 'EmailSubmission/set' && args.created?.s);
if (!created) throw new Error(`send failed: ${JSON.stringify(resp).slice(0, 400)}`);
log(`send: ${from} -> ${to} "${subject}"`);
}
async function findMail({ as = 'userb', subject, timeout = 30 }) {
const s = await jmapSession(as);
const deadline = Date.now() + timeout * 1000;
// Stalwart's subject filter finds nothing for "Re: x" or "Fwd: x" (it
// indexes the base subject), so search without the prefixes and compare
// the full subject below.
const base = subject.replace(/^((re|fwd?|aw|wg)\s*:\s*)+/i, '');
for (;;) {
const [[, q], [, got]] = await jmap(as, [
['Email/query', { accountId: s.accountId, filter: { subject: base }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 10 }, '0'],
['Email/get', { accountId: s.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject', 'attachments', 'mailboxIds', 'from'] }, '1'],
]);
const hit = got.list.find((m) => m.subject === subject);
if (hit) {
return {
found: true,
id: hit.id,
accountId: s.accountId,
attachments: (hit.attachments || []).map((a) => a.name),
from: hit.from?.[0]?.email ?? null,
};
}
if (Date.now() > deadline) return { found: false, total: q.ids.length };
await sleep(2000);
}
}
// ── server ──────────────────────────────────────────────────────────────────
const routes = {
async '/health'() {
return { ok: true };
},
async '/totp'(q) {
return { code: await freshTotp(Number(q.get('minLeft') || 10)) };
},
async '/pair'(q) {
const user = q.get('user');
const how = q.get('deliver') || 'openurl';
const link = await createPairingLink(user);
lastLinks.set(user, link);
await deliver(link, how);
return { ok: true };
},
async '/redeliver'(q) {
const link = lastLinks.get(q.get('user'));
if (!link) throw new Error('no link for that user yet');
await deliver(link, q.get('deliver') || 'openurl');
return { ok: true };
},
async '/pair-later'(q) {
const user = q.get('user');
const how = q.get('deliver') || 'openurl';
const delay = Number(q.get('delay') || 130);
const link = await createPairingLink(user);
lastLinks.set(user, link);
setTimeout(() => {
deliver(link, how).then(() => log(`pair-later: delivered after ${delay}s`), (e) => log('pair-later:', e.message));
}, delay * 1000);
return { ok: true, deliverIn: delay };
},
async '/send'(q) {
await sendMail({
from: q.get('from') || 'userb',
to: q.get('to'),
subject: q.get('subject'),
text: q.get('text') || undefined,
html: q.get('html') === '1',
attach: q.get('attach') === '1',
});
return { ok: true };
},
async '/find'(q) {
return findMail({ as: q.get('as') || 'userb', subject: q.get('subject'), timeout: Number(q.get('timeout') || 30) });
},
async '/simpush'(q) {
const to = q.get('to');
const subject = q.get('subject');
await sendMail({ from: 'userb', to, subject, attach: false, html: false });
const found = await findMail({ as: to, subject, timeout: 60 });
if (!found.found) throw new Error('the message did not arrive');
// The same payload the relay sends (relay/src/apns.ts apnsPayload).
const payload = {
aps: {
alert: { title: 'Bulwark Mail', body: 'New message' },
sound: 'default',
'mutable-content': 1,
'thread-id': found.accountId,
},
body: {
kind: 'jmap-email-push',
accountLabel: `${to}@${DOMAIN}`,
accountId: found.accountId,
emailIds: [found.id],
changed: {},
},
};
const file = path.join(os.tmpdir(), `push-${Date.now()}.json`);
fs.writeFileSync(file, JSON.stringify(payload));
await simctl(['push', UDID, 'org.bulwarkmail.mobile', file]);
log(`simpush: ${to} "${subject}"`);
return { ok: true, emailId: found.id };
},
async '/openurl'(q) {
await simctl(['openurl', UDID, q.get('url')]);
return { ok: true };
},
async '/clipboard'(q) {
await simctl(['pbcopy', UDID], q.get('text') || '');
return { ok: true };
},
};
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const route = routes[url.pathname];
res.setHeader('Content-Type', 'application/json');
if (!route) {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'not found' }));
return;
}
try {
const body = await route(url.searchParams);
log(`${url.pathname} ok`);
res.end(JSON.stringify(body));
} catch (err) {
log(`${url.pathname} failed: ${err.message}`);
res.statusCode = 500;
res.end(JSON.stringify({ error: err.message }));
}
}).listen(PORT, '127.0.0.1', () => log(`e2e helper on :${PORT}`));
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
// Performance harness for the iOS simulator (Part 2 of docs/ios-test-2026-10.md).
//
// node scripts/e2e/perf.mjs --app base=<path.app> --app cand=<path.app> \
// --rounds 5 --cold 3 --flows .maestro/perf/1-open-message.yaml,... --out <dir>
//
// The app logs `[perf] {...}` lines when launched with `-perfMarks YES`
// (src/lib/perf-marks.ts); they reach the simulator's system log at info
// level. This script streams that log, and per round and app:
// 1. installs the app over the previous one (data and keychain stay, so it
// stays signed in),
// 2. cold-starts it `--cold` times: terminate, launch, wait for
// `inbox-rendered`; cold start = that mark's time minus the launch time
// taken on this machine (the simulator shares the host clock),
// 3. runs the Maestro interaction flows once each (open messages, switch
// tabs, open compose, scroll), which produce the other marks. Each flow
// is its own run, so one that fails costs only its own metric.
// Apps alternate inside every round, so both sides of a comparison see the
// same runner, the same simulator and the same server load.
//
// Output: <out>/perf.json (every sample) and <out>/perf.md (medians).
import { spawn, execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const args = process.argv.slice(2);
const opt = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : fallback;
};
const apps = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--app') {
const [label, p] = args[i + 1].split('=');
apps.push({ label, path: p });
}
}
const UDID = process.env.UDID;
const BUNDLE = 'org.bulwarkmail.mobile';
const rounds = Number(opt('rounds', 5));
const coldRuns = Number(opt('cold', 3));
const flows = (opt('flows', '') || '').split(',').filter(Boolean);
const out = opt('out', 'perf-output');
const maestroEnv = (opt('env', '') || '').split(',').filter(Boolean);
fs.mkdirSync(out, { recursive: true });
if (!UDID || apps.length === 0) {
console.error('need UDID and at least one --app label=path');
process.exit(2);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const simctl = (...a) => execFileSync('xcrun', ['simctl', ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
// ── log stream ──────────────────────────────────────────────────────────────
const marks = []; // { n, t, ...detail, label }
let currentLabel = null;
const waiters = [];
const stream = spawn('xcrun', [
'simctl', 'spawn', UDID, 'log', 'stream', '--level', 'info', '--style', 'ndjson',
'--predicate', 'subsystem == "com.facebook.react.log" AND eventMessage CONTAINS "[perf]"',
]);
let buf = '';
stream.stdout.on('data', (chunk) => {
buf += chunk;
let nl;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
let ev;
try { ev = JSON.parse(line); } catch { continue; }
const msg = String(ev.eventMessage || '');
const at = msg.indexOf('[perf] ');
if (at < 0) continue;
// The message is the console line, possibly quoted by RCTLog.
const json = msg.slice(at + 7).replace(/^'|'$/g, '');
let mark;
try { mark = JSON.parse(json); } catch { continue; }
mark.label = currentLabel;
marks.push(mark);
for (const w of [...waiters]) {
if (w.name === mark.n) {
waiters.splice(waiters.indexOf(w), 1);
w.resolve(mark);
}
}
}
});
function waitForMark(name, timeoutMs) {
return new Promise((resolve) => {
const w = { name, resolve };
waiters.push(w);
setTimeout(() => {
const i = waiters.indexOf(w);
if (i >= 0) { waiters.splice(i, 1); resolve(null); }
}, timeoutMs);
});
}
// ── steps ───────────────────────────────────────────────────────────────────
const samples = []; // { label, metric, ms, round }
async function coldStart(label, round) {
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
await sleep(1500);
const pending = waitForMark('inbox-rendered', 120_000);
const bundlePending = waitForMark('bundle-loaded', 120_000);
const t0 = Date.now();
simctl('launch', UDID, BUNDLE, '-perfMarks', 'YES');
const [mark, bundle] = await Promise.all([pending, bundlePending]);
if (!mark) {
console.log(` ${label} cold start: no inbox-rendered mark within 120 s`);
return;
}
samples.push({ label, round, metric: 'cold-start-to-inbox', ms: mark.t - t0 });
if (bundle) samples.push({ label, round, metric: 'cold-start-to-bundle', ms: bundle.t - t0 });
console.log(` ${label} cold start ${mark.t - t0} ms (bundle ${bundle ? bundle.t - t0 : '?'} ms, rows ${mark.rows})`);
await sleep(3000);
}
// All interaction flows in one Maestro process: every process start brings
// the XCTest driver up again (30-60 s). A failing flow doesn't stop the
// others; a hung driver is killed after FLOW_CAP_MS.
const FLOW_CAP_MS = 12 * 60 * 1000;
function runFlows(label) {
const before = marks.length;
const env = maestroEnv.flatMap((kv) => ['-e', kv]);
const res = spawn('maestro', ['--device', UDID, 'test', ...env, ...flows], { stdio: 'inherit' });
const cap = setTimeout(() => res.kill('SIGKILL'), FLOW_CAP_MS);
return new Promise((resolve) => {
res.on('exit', (code) => {
clearTimeout(cap);
const got = marks.slice(before).filter((m) => typeof m.ms === 'number');
console.log(` ${label} interactions: exit ${code}, ${got.length} timed marks`);
resolve(got);
});
});
}
function writeReport(final = false) {
const median = (xs) => {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
};
const metrics = [...new Set(samples.map((s) => s.metric))].sort();
const labels = apps.map((a) => a.label);
const lines = [
`| metric | ${labels.map((l) => `${l} median (n)`).join(' | ')} |${labels.length === 2 ? ' change |' : ''}`,
`|---|${labels.map(() => '---').join('|')}|${labels.length === 2 ? '---|' : ''}`,
];
for (const metric of metrics) {
const cells = labels.map((l) => {
const xs = samples.filter((s) => s.label === l && s.metric === metric).map((s) => s.ms);
return xs.length ? { med: median(xs), n: xs.length } : null;
});
let change = '';
if (labels.length === 2 && cells[0] && cells[1]) {
change = ` ${Math.round(((cells[1].med - cells[0].med) / cells[0].med) * 100)}% |`;
} else if (labels.length === 2) change = ' |';
lines.push(`| ${metric} | ${cells.map((c) => (c ? `${c.med} ms (${c.n})` : '-')).join(' | ')} |${change}`);
}
const scroll = labels.map((l) => {
const xs = samples.filter((s) => s.label === l && s.metric === 'list-scroll');
if (!xs.length) return null;
return `${l}: median ${median(xs.map((s) => s.long))} long frames of ${median(xs.map((s) => s.frames))}, worst ${median(xs.map((s) => s.worst))} ms`;
}).filter(Boolean);
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), ''].join('\n');
fs.writeFileSync(path.join(out, 'perf.json'), JSON.stringify({ apps, rounds, coldRuns, samples }, null, 1));
fs.writeFileSync(path.join(out, 'perf.md'), md);
console.log(md);
if (final && process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Performance\n\n${md}\n`);
}
(async () => {
await sleep(2000); // let the log stream attach
for (let round = 1; round <= rounds; round++) {
console.log(`round ${round}/${rounds}`);
// Alternate the order too, so neither app always goes first.
const order = round % 2 ? apps : [...apps].reverse();
for (const app of order) {
currentLabel = app.label;
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
simctl('install', UDID, app.path);
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round);
const got = await runFlows(app.label);
for (const m of got) {
const metric = m.n === 'tab-switch' && m.to ? `tab-switch:${m.to}` : m.n;
samples.push({ label: app.label, round, metric, ms: m.ms, ...(m.long !== undefined ? { long: m.long, frames: m.frames, worst: m.worst } : {}) });
}
}
// A job that runs out of time still leaves the rounds it finished.
writeReport();
}
stream.kill();
writeReport(true);
process.exit(0);
})();
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Run one suite of Maestro flows against the booted simulator ($UDID).
#
# scripts/e2e/run-flows.sh <suite dir under .maestro> [flow names, comma separated]
#
# Needs in the environment: UDID, OUT (output folder), and for the flows the
# E2E_* values (see .github/workflows/e2e-ios.yml). Output: $OUT/report.xml,
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log, $OUT/steps.txt.
#
# Each flow runs in its own Maestro process with a time cap: when the XCTest
# driver dies (it does, now and then), only that flow fails and the next one
# starts a fresh driver.
#
# Secrets: Maestro's debug output holds every typed text, passwords included,
# and artifacts of this public repo are public. Only the warm-up (which types
# a made-up address) prints a debug log; the debug output of the real flows
# stays on the runner, and only PNGs and an argument-free summary of the
# steps (failed-steps.mjs) are copied. Every text file in $OUT is scrubbed of
# the secret values before upload.
set -uo pipefail
SUITE=$1
ONLY=${2:-}
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
FLOW_TIMEOUT=${FLOW_TIMEOUT:-900}
mkdir -p "$OUT/screenshots" "$OUT/junit"
DEBUG_DIR="$RUNNER_TEMP/maestro-debug"
mkdir -p "$DEBUG_DIR"
# perl's alarm stands in for coreutils timeout, which macOS lacks.
capped() { perl -e 'alarm shift; exec @ARGV' "$FLOW_TIMEOUT" "$@"; }
maestro --version
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/run.mp4" &
RECORDER=$!
# Warm-up: the XCTest driver takes minutes to start on a cold runner.
WARMUP_START=$(date +%s)
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--debug-output "$RUNNER_TEMP/maestro-warmup" "$ROOT/.maestro/launch.yaml")
STATUS=$?
echo "warm-up took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
if [ $STATUS -ne 0 ]; then
echo "::group::Maestro warm-up log"
find "$RUNNER_TEMP/maestro-warmup" -name 'maestro.log' -exec tail -n 300 {} \;
echo "::endgroup::"
fi
if [ $STATUS -eq 0 ]; then
ARGS=(
-e "SERVER=$E2E_SERVER"
-e "PASSWORD=$E2E_PASSWORD"
-e "ACCOUNT=${E2E_ACCOUNT:-}"
-e "ACCOUNT2=${E2E_ACCOUNT2:-}"
-e "TOTP_ACCOUNT=${E2E_TOTP_ACCOUNT:-}"
-e "WEBMAIL=${E2E_WEBMAIL:-}"
-e "WEBMAIL_BETA=${E2E_WEBMAIL_BETA:-https://bulwark-beta.internal.bulwarkmail.org}"
-e "HELPER=http://127.0.0.1:${HELPER_PORT:-8787}"
-e "ORIENTATION=${ORIENTATION:-portrait}"
-e "DEVICE=${DEVICE:-phone}"
-e "APPEARANCE=${APPEARANCE:-light}"
)
FLOWS=()
if [ -n "$ONLY" ]; then
IFS=',' read -ra NAMES <<< "$ONLY"
for n in "${NAMES[@]}"; do
f="$ROOT/.maestro/$SUITE/${n%.yaml}.yaml"
if [ -f "$f" ]; then FLOWS+=("$f"); else echo "::warning::no flow $SUITE/$n"; fi
done
else
for f in "$ROOT/.maestro/$SUITE"/*.yaml; do FLOWS+=("$f"); done
fi
FAILED=()
for f in "${FLOWS[@]}"; do
name=$(basename "$f" .yaml)
started=$(date +%s)
echo "::group::$name"
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--format junit --output "$OUT/junit/$name.xml" \
--debug-output "$DEBUG_DIR/$name" \
"${ARGS[@]}" "$f")
rc=$?
echo "::endgroup::"
echo "$name: exit $rc after $(( $(date +%s) - started )) s"
if [ $rc -ne 0 ]; then
FAILED+=("$name")
# What the screen showed when it failed.
xcrun simctl io "$UDID" screenshot "$OUT/screenshots/FAILED-$name.png" >/dev/null 2>&1 || true
fi
done
if [ ${#FAILED[@]} -gt 0 ]; then
echo "::error::Failed flows: ${FAILED[*]}"
STATUS=1
fi
node -e '
const fs = require("fs"), path = require("path");
const dir = process.argv[1];
const cases = fs.readdirSync(dir).filter((f) => f.endsWith(".xml")).sort()
.map((f) => (fs.readFileSync(path.join(dir, f), "utf8").match(/<testcase[\s\S]*?<\/testcase>/g) || []).join("\n"));
fs.writeFileSync(process.argv[2], `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n<testsuite name="${process.argv[3]}">\n${cases.join("\n")}\n</testsuite>\n</testsuites>\n`);
' "$OUT/junit" "$OUT/report.xml" "$SUITE" || true
fi
kill -INT "$RECORDER" 2>/dev/null || true
wait "$RECORDER" 2>/dev/null || true
# Images of failed steps, plus a summary of the steps without arguments.
find "$DEBUG_DIR" "$HOME/.maestro/tests" -name '*.png' -exec cp {} "$OUT/screenshots/" \; 2>/dev/null || true
node "$ROOT/scripts/e2e/failed-steps.mjs" "$DEBUG_DIR" > "$OUT/steps.txt" 2>&1 || true
find "$DEBUG_DIR" -type f | sed "s|$DEBUG_DIR/||" > "$OUT/debug-files.txt" 2>/dev/null || true
# --info: console.log of the JS lands at info level.
xcrun simctl spawn "$UDID" log show --last 90m --style compact --info \
--predicate 'process == "BulwarkMobile" AND (subsystem == "com.facebook.react.log" OR messageType >= 16)' \
> "$OUT/app.log" 2>&1 || true
# Push: what iOS did with the notification (the extension's own log, and
# SpringBoard / usernotificationsd / apsd lines naming the app), plus the
# notification settings the simulator stored for it.
if [ "$SUITE" = push ]; then
xcrun simctl spawn "$UDID" log show --last 90m --style compact --info --predicate \
'process == "NotificationService" OR subsystem == "org.bulwarkmail.mobile.NotificationService" OR ((process == "SpringBoard" OR process CONTAINS "usernotification" OR process == "apsd") AND eventMessage CONTAINS[c] "bulwarkmail")' \
> "$OUT/push.log" 2>&1 || true
SIMDATA="$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data"
{
find "$SIMDATA/Library" \( -path '*BulletinBoard*' -o -path '*UserNotifications*' \) -maxdepth 4 2>/dev/null | head -50
for f in $(find "$SIMDATA/Library" \( -path '*BulletinBoard*' -o -path '*UserNotifications*' \) -name '*.plist' 2>/dev/null | head -20); do
echo "== $f"
plutil -p "$f" 2>/dev/null | grep -i -A12 bulwarkmail | head -60
done
echo "== app extension in the bundle"
ls "$(xcrun simctl get_app_container "$UDID" org.bulwarkmail.mobile app 2>/dev/null)/PlugIns" 2>&1
} > "$OUT/push-settings.txt" 2>&1 || true
fi
# Scrub secrets from every text file that gets uploaded.
find "$OUT" -type f \( -name '*.xml' -o -name '*.log' -o -name '*.json' -o -name '*.txt' \) -print0 |
while IFS= read -r -d '' f; do
node -e '
const fs = require("fs");
const f = process.argv[1];
let s = fs.readFileSync(f, "utf8");
for (const k of ["E2E_PASSWORD", "E2E_USERB_PASSWORD", "E2E_TOTP_SECRET"]) {
const v = process.env[k];
if (v && v.length >= 6) s = s.split(v).join("***");
}
fs.writeFileSync(f, s);
' "$f"
done
echo "screenshots: $(ls "$OUT/screenshots" | wc -l)"
exit $STATUS
@@ -418,6 +418,40 @@ describe('JMAPClient hardening', () => {
expect(client.authHeader).toBe('Bearer new'); expect(client.authHeader).toBe('Bearer new');
}); });
// On iOS the notification extension refreshes OAuth tokens too and writes
// the new bundle back. Refreshing again with the refresh token in memory
// would spend a token the server has already replaced.
it('retries a 401 with a newer stored token instead of refreshing again', async () => {
const SecureStore = await import('expo-secure-store');
const client = new JMAPClient();
mockFetch([{ status: 200, json: SESSION }]);
await client.connectWithOAuth('https://mail.example.com', {
accessToken: 'old',
refreshToken: 'r1',
expiresAt: Date.now() + 3_600_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
});
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({
serverUrl: 'https://mail.example.com',
username: 'user@example.com',
password: '',
accessToken: 'from-extension',
refreshToken: 'r2',
expiresAt: Date.now() + 7_200_000,
tokenEndpoint: 'https://mail.example.com/auth/token',
clientId: 'c',
}));
const fetch = mockFetch([
{ status: 401 },
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
]);
await client.request([['Mailbox/get', {}, '0']]);
expect(mockRefresh).not.toHaveBeenCalled();
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' });
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockReset();
});
it('updatePassword rewrites the stored credential', async () => { it('updatePassword rewrites the stored credential', async () => {
const SecureStore = await import('expo-secure-store'); const SecureStore = await import('expo-secure-store');
const client = await connected(); const client = await connected();
+47 -20
View File
@@ -15,6 +15,7 @@ import {
type OAuthTokenSource, type OAuthTokenSource,
} from '../lib/oauth'; } from '../lib/oauth';
import { FirstTouchGate } from './first-touch-gate'; import { FirstTouchGate } from './first-touch-gate';
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes'; import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
// Refresh OAuth access tokens this many ms before they actually expire so // Refresh OAuth access tokens this many ms before they actually expire so
@@ -40,7 +41,7 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__'; const CREDENTIALS_PREFIX = 'jmap_credentials__';
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/". // SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
function credentialsKey(accountId: string): string { export function credentialsKey(accountId: string): string {
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_'); return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
} }
@@ -260,10 +261,7 @@ export class JMAPClient {
this.firstTouchGate.reset(); this.firstTouchGate.reset();
const accountId = generateAccountId(username, baseUrl); const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync( await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
return this.session; return this.session;
} }
@@ -342,10 +340,7 @@ export class JMAPClient {
this.credentials.username = username; this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl); const accountId = generateAccountId(username, baseUrl);
await SecureStore.setItemAsync( await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
return { session: this.session, username, accountId }; return { session: this.session, username, accountId };
} }
@@ -367,15 +362,36 @@ export class JMAPClient {
this.credentials.username, this.credentials.username,
this.credentials.serverUrl, this.credentials.serverUrl,
); );
await SecureStore.setItemAsync( await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
for (const l of this.tokenRefreshListeners) { for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ } try { l(); } catch { /* ignore */ }
} }
} }
/**
* Take the stored OAuth tokens when they are newer than the ones in memory:
* on iOS the notification extension refreshes them too and writes the new
* bundle back. True when the in-memory tokens changed.
*/
private async adoptStoredTokens(): Promise<boolean> {
const current = this.credentials;
if (!current?.accessToken || !current.refreshToken) return false;
const accountId = generateAccountId(current.username, current.serverUrl);
const stored = await this.getStoredCredentials(accountId).catch(() => null);
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
this.credentials = {
...current,
accessToken: stored.accessToken,
refreshToken: stored.refreshToken ?? current.refreshToken,
expiresAt: stored.expiresAt,
};
for (const l of this.tokenRefreshListeners) {
try { l(); } catch { /* ignore */ }
}
return true;
}
private currentOAuthTokens(): OAuthTokens | null { private currentOAuthTokens(): OAuthTokens | null {
if ( if (
!this.credentials?.accessToken || !this.credentials?.accessToken ||
@@ -414,10 +430,17 @@ export class JMAPClient {
// consumers of `authHeader` (SSE stream, file downloads) can make sure the // consumers of `authHeader` (SSE stream, file downloads) can make sure the
// header they capture is not about to expire. // header they capture is not about to expire.
async ensureFreshToken(): Promise<void> { async ensureFreshToken(): Promise<void> {
const tokens = this.currentOAuthTokens(); let tokens = this.currentOAuthTokens();
if (!tokens) return; if (!tokens) return;
if (tokens.expiresAt == null) return; if (tokens.expiresAt == null) return;
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return; if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
// Another process may have refreshed already (the iOS notification
// extension); use its tokens rather than spend a refresh token the
// server has replaced.
if (await this.adoptStoredTokens()) {
tokens = this.currentOAuthTokens();
if (!tokens || (tokens.expiresAt != null && tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
}
try { try {
const next = await refreshOAuthAccessToken(tokens); const next = await refreshOAuthAccessToken(tokens);
await this.persistRefreshedTokens(next); await this.persistRefreshedTokens(next);
@@ -433,6 +456,10 @@ export class JMAPClient {
// the account instead of evicting it (webmail 1.7.6 "keep the session when // the account instead of evicting it (webmail 1.7.6 "keep the session when
// the auth server is briefly unreachable"). // the auth server is briefly unreachable").
async forceRefreshToken(): Promise<boolean> { async forceRefreshToken(): Promise<boolean> {
if (!this.currentOAuthTokens()) return false;
// A newer token in storage (refreshed by the iOS notification extension)
// is worth a retry before refreshing again.
if (await this.adoptStoredTokens()) return true;
const tokens = this.currentOAuthTokens(); const tokens = this.currentOAuthTokens();
if (!tokens) return false; if (!tokens) return false;
try { try {
@@ -459,10 +486,7 @@ export class JMAPClient {
this.credentials.username, this.credentials.username,
this.credentials.serverUrl, this.credentials.serverUrl,
); );
await SecureStore.setItemAsync( await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
credentialsKey(accountId),
JSON.stringify(this.credentials),
);
} }
// Legacy single-slot restore - kept for backward-compat tests. New code // Legacy single-slot restore - kept for backward-compat tests. New code
@@ -490,6 +514,9 @@ export class JMAPClient {
async loadAccount(accountId: string): Promise<boolean> { async loadAccount(accountId: string): Promise<boolean> {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId)); const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false; if (!stored) return false;
// iOS: credentials saved before the notification extension existed move
// to the keychain group it can read (a no-op elsewhere and afterwards).
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
let creds: StoredCredentials; let creds: StoredCredentials;
try { try {
@@ -566,7 +593,7 @@ export class JMAPClient {
const creds: StoredCredentials = JSON.parse(stored); const creds: StoredCredentials = JSON.parse(stored);
// Re-save under the per-account key before dropping the legacy entry // Re-save under the per-account key before dropping the legacy entry
const accountId = generateAccountId(creds.username, creds.serverUrl); const accountId = generateAccountId(creds.username, creds.serverUrl);
await SecureStore.setItemAsync(credentialsKey(accountId), stored); await writeSharedSecret(credentialsKey(accountId), stored);
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY); await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
return creds; return creds;
} catch { } catch {
@@ -1159,7 +1186,7 @@ export class JMAPClient {
} }
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> { async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds)); await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
} }
// ── Blob Download ───────────────────────────────────── // ── Blob Download ─────────────────────────────────────
+36
View File
@@ -1,21 +1,57 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Platform } from 'react-native';
import { useEmailStore } from '../stores/email-store'; import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useLocaleStore } from '../stores/locale-store';
import { useAuthStore } from '../stores/auth-store';
import { appBadgeCount, setAppBadge } from '../lib/app-badge'; import { appBadgeCount, setAppBadge } from '../lib/app-badge';
import { jmapClient } from '../api/jmap-client';
import { writeIosPushSettings } from '../lib/ios-push-shared';
import { shareAccountWithExtension } from '../lib/push-notifications';
/** /**
* Keeps the app icon badge on the active account's inbox unread count (see * Keeps the app icon badge on the active account's inbox unread count (see
* `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last * `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last
* account signed out, so the badge is cleared then. Renders nothing. * account signed out, so the badge is cleared then. Renders nothing.
*
* On iOS it also tells the notification extension (plugins/ios-push) which
* account the badge follows and the few texts it writes itself, so a push
* that arrives while the app is closed updates the badge the same way.
*/ */
export function AppIconBadge() { export function AppIconBadge() {
const enabled = useSettingsStore((s) => s.appIconUnreadBadge); const enabled = useSettingsStore((s) => s.appIconUnreadBadge);
const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled)); const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// The JMAP session comes up after the account is known (offline start).
const haveSession = useAuthStore((s) => s.session != null);
const t = useLocaleStore((s) => s.t);
useEffect(() => { useEffect(() => {
void setAppBadge(count); void setAppBadge(count);
}, [count]); }, [count]);
useEffect(() => {
if (Platform.OS !== 'ios') return;
let jmapAccountId: string | null = null;
try {
jmapAccountId = jmapClient.accountId;
} catch {
jmapAccountId = null;
}
// The extension learns how to reach the active account as soon as it is
// signed in, so a push for it shows sender and subject even before (or
// without) this device's own push setup, e.g. after a reinstall.
if (activeAccountId) void shareAccountWithExtension(activeAccountId, jmapAccountId);
void writeIosPushSettings({
badge: enabled,
activeAccountId: jmapAccountId,
strings: {
noSubject: t('push.ios.no_subject', '(no subject)'),
more: t('push.ios.more_messages', 'and {count} more'),
},
}).catch(() => undefined);
}, [enabled, activeAccountId, haveSession, t]);
useEffect(() => () => { void setAppBadge(0); }, []); useEffect(() => () => { void setAppBadge(0); }, []);
return null; return null;
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { iosTapPayload } from '../push-notifications';
// A tapped iOS mail notification: the relay's ids arrive as the
// notification's data (APNs `body`), the notification extension adds the
// message it showed, and the app opens that message under the account whose
// push setup recorded the JMAP account id.
describe('iosTapPayload', () => {
beforeEach(async () => {
await AsyncStorage.clear();
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' }));
});
it('opens the message the extension showed, in the right account', async () => {
const payload = await iosTapPayload({
kind: 'jmap-email-push',
accountId: 'c',
emailIds: ['e1', 'e2'],
emailId: 'e2',
threadId: 't2',
});
expect(payload).toEqual({ emailId: 'e2', threadId: 't2', accountId: 'ada@mail.example.org', jmapAccountId: 'c' });
});
it('falls back to the first pushed id when the extension could not fetch', async () => {
const payload = await iosTapPayload({ kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] });
expect(payload?.emailId).toBe('e1');
expect(payload?.threadId).toBe('');
});
it('leaves calendar reminders and other local notifications alone', async () => {
expect(await iosTapPayload({ eventId: 'ev1', calendarId: 'cal' })).toBeNull();
expect(await iosTapPayload(null)).toBeNull();
});
it('does nothing for a push without a message id', async () => {
expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull();
});
});
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
async function load(os: string, nativeModules: Record<string, unknown>) {
vi.resetModules();
vi.doMock('react-native', () => ({ Platform: { OS: os }, NativeModules: nativeModules }));
return import('../platform-capabilities');
}
afterEach(() => {
vi.doUnmock('react-native');
});
describe('supportsSideloadUpdates', () => {
it('is on for the GitHub build', async () => {
const caps = await load('android', { BulwarkBuild: { distribution: 'github' } });
expect(caps.androidDistribution).toBe('github');
expect(caps.supportsSideloadUpdates).toBe(true);
});
it('is off for the Play build, which Play updates', async () => {
const caps = await load('android', { BulwarkBuild: { distribution: 'play' } });
expect(caps.androidDistribution).toBe('play');
expect(caps.supportsSideloadUpdates).toBe(false);
});
it('treats a build without the module as the GitHub build', async () => {
const caps = await load('android', {});
expect(caps.supportsSideloadUpdates).toBe(true);
});
it('is off on iOS', async () => {
const caps = await load('ios', {});
expect(caps.supportsSideloadUpdates).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
// What the app shares with the iOS notification service extension
// (plugins/ios-push): account credentials, where to reach each pushed account,
// and the badge counts. They live in a keychain access group both targets
// belong to; nothing else of the app is visible to the extension.
//
// Readable after the first unlock: a push usually arrives while the phone is
// locked, and a notification that has to wait for the user to unlock would
// show only "New message".
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
type Extra = { iosSharedKeychainGroup?: unknown; iosAppKeychainGroup?: unknown; apnsEnvironment?: unknown };
// Loaded on first use, on iOS only: the module pulls in native code that
// Android's headless tasks and the unit tests can do without.
function extra(): Extra {
const { default: Constants } = require('expo-constants') as typeof import('expo-constants');
return (Constants.expoConfig?.extra ?? {}) as Extra;
}
/** The APNs host this build's device tokens belong to. */
export function apnsEnvironment(): 'production' | 'sandbox' {
return extra().apnsEnvironment === 'sandbox' ? 'sandbox' : 'production';
}
/** The access group shared with the extension, or null off iOS. */
export function sharedKeychainGroup(): string | null {
if (Platform.OS !== 'ios') return null;
const group = extra().iosSharedKeychainGroup;
return typeof group === 'string' && group ? group : null;
}
/** Options for an item the extension must read. Undefined off iOS. */
export function sharedStoreOptions(): SecureStore.SecureStoreOptions | undefined {
const group = sharedKeychainGroup();
if (!group) return undefined;
return { accessGroup: group, keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK };
}
/** Options naming the app's own access group, where items used to live. */
export function appStoreOptions(): SecureStore.SecureStoreOptions | undefined {
if (Platform.OS !== 'ios') return undefined;
const group = extra().iosAppKeychainGroup;
return typeof group === 'string' && group ? { accessGroup: group } : undefined;
}
// SecureStore keys: letters, digits, ".", "-", "_" only. The extension
// builds the same key from the push's account id (NotificationService.swift).
function keyPart(id: string): string {
return id.replace(/[^a-zA-Z0-9._-]/g, '_');
}
export function pushAccountKey(jmapAccountId: string): string {
return `bulwark_push_account__${keyPart(jmapAccountId)}`;
}
export const PUSH_SETTINGS_KEY = 'bulwark_push_settings';
export interface IosPushAccount {
/** SecureStore key of the account's credentials. */
credentialsKey: string;
/** The JMAP API URL as the app uses it (rewritten to the server's host). */
apiUrl: string;
}
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.setItemAsync(pushAccountKey(jmapAccountId), JSON.stringify(account), options);
}
export async function removeIosPushAccount(jmapAccountId: string): Promise<void> {
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.deleteItemAsync(pushAccountKey(jmapAccountId), options).catch(() => undefined);
}
export interface IosPushSettings {
/** Show the unread count on the app icon. */
badge: boolean;
/** JMAP id of the active account: the badge is its inbox unread count. */
activeAccountId: string | null;
/** The few texts the extension writes itself, in the app's language. */
strings: { noSubject: string; more: string };
}
export async function writeIosPushSettings(settings: IosPushSettings): Promise<void> {
const options = sharedStoreOptions();
if (!options) return;
await SecureStore.setItemAsync(PUSH_SETTINGS_KEY, JSON.stringify(settings), options);
}
/**
* Store a credentials item where the extension can read it. Written to the
* shared group first, then removed from the app's own group, so a crash in
* between leaves two copies rather than none. Off iOS a plain write.
*/
export async function writeSharedSecret(key: string, value: string): Promise<void> {
const shared = sharedStoreOptions();
if (!shared) {
await SecureStore.setItemAsync(key, value);
return;
}
await SecureStore.setItemAsync(key, value, shared);
const own = appStoreOptions();
if (own) await SecureStore.deleteItemAsync(key, own).catch(() => undefined);
}
/** Move an item saved before the shared group existed. True if it moved. */
export async function moveToSharedGroup(key: string): Promise<boolean> {
const own = appStoreOptions();
if (!own || !sharedStoreOptions()) return false;
const value = await SecureStore.getItemAsync(key, own).catch(() => null);
if (value === null) return false;
await writeSharedSecret(key, value);
return true;
}
+16 -7
View File
@@ -1,19 +1,28 @@
// Single source of truth for "this feature only exists on some platforms", so // 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. // 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 * In-app sideload updates: download the release APK from GitHub and hand it to
* the system package installer (see `lib/install-update`). * the system package installer (see `lib/install-update`).
* *
* Android only. App Store Review guideline 2.5.2 forbids an iOS app from * Only the GitHub build on Android. App Store Review guideline 2.5.2 forbids an
* downloading and installing executable code, and there is no iOS equivalent of * iOS app from downloading and installing executable code, and Google Play
* the installer intent anyway - iOS builds are updated through TestFlight or * forbids an app updating itself outside Play; the Play build also lacks the
* the App Store. The whole updater surface (banner, settings pane, launch-time * installer permission. iOS builds are updated through TestFlight or the App
* check) is hidden when this is false. * 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 * Device sync: address books and calendars in Android's Contacts and Calendar

Some files were not shown because too many files have changed in this diff Show More