Compare commits

...
65 Commits
Author SHA1 Message Date
Linus Rath 3424bcf2e9 perf: show the cached inbox at start before the server's session arrives 2026-10-22 22:04:33 +02:00
Linus Rath d13769a9f2 perf: load screens that aren't on the way to the inbox on first visit 2026-10-22 22:04:33 +02:00
Linus Rath 0c8b8179b2 perf: load JS modules on first use instead of at startup 2026-10-22 22:04:33 +02:00
Linus Rath 533f176e88 test: time each module's evaluation at start-up in builds made for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath da72415ef3 test: mark the steps of the start-up restore for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath 0109273037 fix: start typing in the message body when the empty space under it is tapped 2026-10-22 22:04:29 +02:00
Linus Rath 33eb8f99e5 fix: show the undo button on the undo bar in dark mode 2026-10-22 22:04:29 +02:00
Linus Rath 721c2e34a1 fix: sign out of the last account instead of staying signed in 2026-10-22 22:04:28 +02:00
Linus Rath e06d92d88f fix: open the photo, camera and file pickers from the compose attach sheet on ios 2026-10-22 22:04:28 +02:00
Linus Rath 42e89122bd test: mark where the time goes when a message opens 2026-10-22 22:04:28 +02:00
Linus Rath 33fc33910a test: add test ids to the contact form's name and email fields 2026-10-22 22:04:28 +02:00
Linus Rath dde504f825 fix: let screen readers reach each option in sheets and menus 2026-10-22 22:04:28 +02:00
Linus Rath f5f3637d01 fix: keep full-screen modal headers below the status bar on iPhones with a notch 2026-10-22 22:04:28 +02:00
Linus Rath bdade8503d test: mark how long each persisted cache takes to load 2026-10-22 22:04:28 +02:00
Linus Rath 57ca1c8083 test: log timing marks for the performance harness when launched with -perfMarks 2026-10-22 22:04:28 +02:00
Linus Rath 32a41f42d3 ci: report the slowest modules at ios start-up in the perf leg 2026-10-22 22:04:24 +02:00
Linus Rath ad9db5cbff ci: tap undo at once, search for every settings page and scroll message rows clear of the ios tab bar 2026-10-22 21:57:31 +02:00
Linus Rath 99e4bd9614 ci: let ios runs with different legs on one branch run side by side 2026-10-22 20:19:48 +02:00
Linus Rath 239bf618b5 ci: search for ipad settings pages, tap the attachment chip by name and move to the body with return in the ios flows 2026-10-22 20:12:59 +02:00
Linus Rath 58e52421e9 ci: report the ios start-up timeline and cache load times in the perf leg 2026-10-22 18:16:33 +02:00
Linus Rath 96ffeaa0e2 ci: pick a photo in the ios compose flow, settle settings rows before tapping, answer prompts in portrait and tap undo sooner 2026-10-22 17:11:21 +02:00
Linus Rath 81a0723abd ci: report the first ios launch after install apart from the cold starts after it 2026-10-22 16:19:03 +02:00
Linus Rath 6103df86a5 ci: wait longer for the sign-in link prompt after a sign-out on ios 2026-10-22 14:18:30 +02:00
Linus Rath b97a877fce ci: let the first ios push banner go before tapping the next 2026-10-22 14:04:11 +02:00
Linus Rath 663f6f0007 ci: push and tap the ios banner again when the first tap misses 2026-10-22 13:56:52 +02:00
Linus Rath 5477cc039a ci: wait for the message toolbar instead of reply all when opening a message on ios 2026-10-22 13:51:07 +02:00
Linus Rath ab8e8d7e1e ci: answer the notification prompt, retry server continue and file delete, and accept a starred row in the ios flows 2026-10-22 13:50:35 +02:00
Linus Rath 49e57482cc ci: scroll ios settings pages to their check text and steady the calendar reminder and compose body steps 2026-10-22 13:49:11 +02:00
Linus Rath 5f06c3918c ci: tap the ios push banner right after the push 2026-10-22 13:38:54 +02:00
Linus Rath 96471cf13b ci: scroll to the seeded inbox row when checking the ios sign-in 2026-10-22 13:20:23 +02:00
Linus Rath 3575ed9450 ci: tap the ios notification again until the app opens 2026-10-22 13:04:59 +02:00
Linus Rath f181c73a56 ci: tap the ios push notification in notification center, not the fading banner 2026-10-22 12:56:58 +02:00
Linus Rath c85e32c9f4 ci: test the ios push banner and tap without the service extension under simctl 2026-10-22 11:58:19 +02:00
Linus Rath f2b98c6dba ci: erase and retype web sign-in fields instead of selecting their text 2026-10-22 11:52:00 +02:00
Linus Rath 653d5965ed ci: scroll the ipad settings column to sign out and retry simctl pbcopy 2026-10-22 11:43:34 +02:00
Linus Rath 01d15fd24f ci: retry the ios maestro warm-up once when the driver dies 2026-10-22 11:28:15 +02:00
Linus Rath 35e44318b9 ci: check the ipad message pane and scroll to rows before long-press in the ios flows 2026-10-22 11:27:29 +02:00
Linus Rath 3bdafefb65 ci: close the ios photo picker without losing the composer and focus the body first 2026-10-22 11:26:36 +02:00
Linus Rath 982a23cafd ci: fix the ios settings, calendar and swipe flow steps 2026-10-22 11:22:51 +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
131 changed files with 5485 additions and 171 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
+349
View File
@@ -0,0 +1,349 @@
name: iOS simulator tests
# Builds the app for the iOS Simulator (no signing needed) and runs the
# Maestro flows in .maestro/ on iPhone and iPad simulators:
#
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
# - features: the app feature by feature (.maestro/features), on iPhone and
# iPad in light and dark, plus the iPad in landscape (split layout)
#
# Each leg signs in with its own account on the test server (e2e-* accounts
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
#
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
# kept out of them.
on:
workflow_dispatch:
inputs:
app_run_id:
description: "Reuse the simulator app from this earlier run instead of building (run id)"
type: string
required: false
legs:
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
type: string
default: all
flows:
description: "Only these flows (comma list of file names without .yaml); empty runs all"
type: string
required: false
perf_base_run_id:
description: "perf leg: compare against the simulator app of this run (run id)"
type: string
required: false
perf_rounds:
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
type: string
default: "5"
required: false
release:
types: [published]
concurrency:
# Per branch and set of legs: a perf run doesn't cancel a feature run.
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
cancel-in-progress: true
env:
EXPO_NO_TELEMETRY: "1"
MAESTRO_CLI_NO_ANALYTICS: "1"
# The XCTest driver takes minutes to come up on a cold runner.
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
jobs:
plan:
runs-on: ubuntu-latest
outputs:
signin: ${{ steps.plan.outputs.signin }}
features: ${{ steps.plan.outputs.features }}
steps:
- id: plan
env:
LEGS: ${{ inputs.legs || 'all' }}
run: |
node -e '
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
const signin = ["phone", "tablet"]
.map((device) => ({ name: `signin-${device}`, device }))
.filter((l) => want(l.name, "signin"));
const features = [
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
].filter((l) => want(l.name, "features"));
// Push on iOS (.maestro/push): only on request, the app needs the
// notification extension (plugins/ios-push).
if (legs.includes("push")) {
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
}
const fs = require("fs");
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
'
build:
if: inputs.app_run_id == ''
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
env:
# Simulator device tokens belong to the APNs sandbox.
APS_ENVIRONMENT: development
run: npx expo prebuild --platform ios --clean
- name: Build for the simulator
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The bundle times each module's evaluation for the perf leg, and
# Metro writes which id is which module (metro.config.js; a branch
# without that support ignores both).
BULWARK_MODULE_TIMINGS: '1'
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
mkdir -p e2e-build
# Release bundles the JS into the app, so no Metro server is needed.
# arm64 only: the runner is Apple silicon, and a second simulator
# architecture would double the build time.
if ! xcodebuild build \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
PROVISIONING_PROFILE_SPECIFIER= \
> e2e-build/xcodebuild.log 2>&1; then
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
tail -n 80 e2e-build/xcodebuild.log
exit 1
fi
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
# Xcode embeds the simulator entitlements the keychain needs. An
# unsigned build has none, and every expo-secure-store call fails
# with "A required entitlement isn't present".
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
echo "Simulated entitlements:"
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
# Packed as a tar: artifacts drop the executable bit, which would
# leave a reused app unable to launch.
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
else
echo "no module map (branch without module timings)"
fi
gzip -f "$RUNNER_TEMP/simulator-app.tar"
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
- name: Upload the simulator app
uses: actions/upload-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}/simulator-app.tar.gz
if-no-files-found: error
- name: Upload the build log
if: failure()
uses: actions/upload-artifact@v4
with:
name: xcodebuild-log
path: e2e-build/
signin:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
fromJson(needs.plan.outputs.signin).include[0] != null
runs-on: macos-latest
timeout-minutes: 150
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: ${{ matrix.device }}
APPEARANCE: light
ORIENTATION: portrait
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/e2e-ios-leg
with:
suite: signin
app_run_id: ${{ inputs.app_run_id }}
flows: ${{ inputs.flows }}
playwright: "true"
name: ${{ matrix.name }}
features:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
fromJson(needs.plan.outputs.features).include[0] != null
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.features) }}
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: ${{ matrix.device }}
APPEARANCE: ${{ matrix.appearance }}
ORIENTATION: ${{ matrix.orientation }}
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
E2E_ACCOUNT: ${{ matrix.account }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/e2e-ios-leg
with:
suite: ${{ matrix.suite || 'features' }}
app_run_id: ${{ inputs.app_run_id }}
flows: ${{ inputs.flows }}
playwright: "false"
name: ${{ matrix.name }}
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
# them on one simulator so both sides share the runner.
perf:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
contains(inputs.legs, 'perf')
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
actions: read
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: phone
APPEARANCE: light
ORIENTATION: portrait
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_ACCOUNT: e2e-bulk
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Download the app under test (this run)
if: inputs.app_run_id == ''
uses: actions/download-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}/cand
- name: Download the app under test (earlier run)
if: inputs.app_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.app_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/cand
- name: Download the baseline app
if: inputs.perf_base_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.perf_base_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/base
- name: Unpack
run: |
for side in cand base; do
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
mkdir -p "$RUNNER_TEMP/$side/app"
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
done
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
- name: Boot a simulator
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
- name: Install Maestro
run: |
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Sign in once
run: |
mkdir -p "$OUT/screenshots"
cd "$OUT/screenshots"
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
- name: Measure
run: |
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
fi
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-ios-perf
path: e2e-output/
if-no-files-found: ignore
+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
+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
+75
View File
@@ -0,0 +1,75 @@
# Opening messages: plain text, HTML with blocked remote content, a thread,
# and a PDF attachment in the in-app preview.
appId: org.bulwarkmail.mobile
name: Read messages
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
# Plain text
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Grüße aus München
- extendedWaitUntil:
visible: ".*Jürgen.*"
timeout: 10000
- takeScreenshot: read-01-plain
- runFlow: ../subflows/close-message.yaml
# HTML newsletter
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- extendedWaitUntil:
visible: ".*5 new articles.*|Weekly digest"
timeout: 20000
- takeScreenshot: read-02-html
- runFlow:
when:
visible: "Load images"
commands:
- tapOn: "Load images"
- takeScreenshot: read-03-html-images
- runFlow: ../subflows/close-message.yaml
# Attachment and its preview
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Q3 report
- extendedWaitUntil:
visible: ".*q3-report.pdf.*"
timeout: 20000
- takeScreenshot: read-04-attachment
# The message's attachment chip, labelled with the file name alone (on the
# iPad the list row next to it mentions the name in its longer label).
- tapOn: "q3-report.pdf"
- extendedWaitUntil:
visible: "Share"
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: read-05-attachment-preview
- assertVisible: "q3-report.pdf"
- tapOn: "Close"
# Back on the message (the iPad pane has a quick reply instead of Reply All).
- extendedWaitUntil:
notVisible: "Share"
timeout: 10000
- assertVisible: ".*q3-report.pdf.*"
- runFlow: ../subflows/close-message.yaml
# A thread (Projects/Alpha)
- runFlow:
file: ../subflows/go-to-folder.yaml
env:
FOLDER: Alpha
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Project Alpha kickoff
- takeScreenshot: read-06-thread
- runFlow: ../subflows/go-to-inbox.yaml
+101
View File
@@ -0,0 +1,101 @@
# Compose a new message and send it to userb; userb's mailbox (checked over
# JMAP) must get it.
appId: org.bulwarkmail.mobile
name: Compose and send
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- tapOn:
text: "Subject"
index: 1
- inputText: ${output.subject}
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Hello from the iOS simulator test."
- takeScreenshot: compose-01-filled
# The attach sheet offers its options one by one (it used to be a single
# accessibility element, and picking one opened no picker on iOS).
- tapOn: "Attach"
- extendedWaitUntil:
visible: "Photos & Videos"
timeout: 10000
- assertVisible: "Camera"
- assertVisible: "Files"
- takeScreenshot: compose-02-attach-sheet
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
# The system photo picker runs in another process: XCTest sees none of its
# elements, but taps by position reach it. Pick the first photo (the app
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
# landscape lays the picker out differently, so it only closes the sheet.
- runFlow:
when:
true: ${!output.pickPhoto}
commands:
- tapOn: "Cancel"
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- tapOn: "Photos & Videos"
- extendedWaitUntil:
notVisible: "Draft saved|New Message"
timeout: 10000
optional: true
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-03-photo-picker
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- tapOn:
point: "44%,33%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,26%"
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- tapOn:
point: "16%,25%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,13%"
- extendedWaitUntil:
visible: "New Message"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-04-with-photo
- tapOn: "Send"
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: ${output.subject}
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- assertTrue:
condition: ${output.helper.attachments.length >= 1}
label: "${output.subject} carries the picked photo"
@@ -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
+41
View File
@@ -0,0 +1,41 @@
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
# right toggles read.
appId: org.bulwarkmail.mobile
name: Swipe actions and undo
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
- swipe:
from: ".*Grüße aus München.*"
direction: LEFT
duration: 400
# The undo bar shows for 5 s; when it goes, the Compose button slides into
# its place, and a late tap on UNDO opens the composer. So tap at once and
# check the rest afterwards.
- tapOn:
text: "UNDO"
waitToSettleTimeoutMs: 500
- extendedWaitUntil:
visible: ".*Grüße aus München.*"
timeout: 15000
- takeScreenshot: swipe-02-undone
- swipe:
from: ".*Grüße aus München.*"
direction: RIGHT
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: swipe-03-read-toggled
- swipe:
from: ".*Grüße aus München.*"
direction: RIGHT
duration: 400
- waitForAnimationToEnd:
timeout: 3000
+85
View File
@@ -0,0 +1,85 @@
# Message actions with undo: move to a folder, delete to Trash, select and
# star from the list.
appId: org.bulwarkmail.mobile
name: Move, delete, select
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
# Move via More → Move to...
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- tapOn: "more"
- extendedWaitUntil:
visible: "More actions"
timeout: 10000
- takeScreenshot: actions-01-more
- tapOn: "Move to..."
- extendedWaitUntil:
visible: "Move to..."
timeout: 10000
- tapOn:
text: "Projects"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-02-moved
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Delete (to Trash) and undo
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- takeScreenshot: actions-03a-before-delete
- tapOn:
text: "Delete"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-03-deleted
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Select from the list and star, then unstar. On a phone the row can be
# below the fold (earlier runs left mail on top).
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
- longPressOn: ".*Grüße aus München.*"
- extendedWaitUntil:
visible: "Clear selection"
timeout: 10000
- takeScreenshot: actions-04-selected
# Star (or Unstar, when an earlier run left it starred), then toggle back.
- tapOn: "Star|Unstar"
# A bulk action ends selection mode by itself.
- runFlow:
when:
visible: "Clear selection"
commands:
- tapOn: "Clear selection"
- longPressOn: ".*Grüße aus München.*"
- tapOn: "Unstar|Star"
- runFlow:
when:
visible: "Clear selection"
commands:
- tapOn: "Clear selection"
+58
View File
@@ -0,0 +1,58 @@
# Scheduled send: schedule a message for later, find it under Scheduled,
# cancel it.
appId: org.bulwarkmail.mobile
name: Scheduled send
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- tapOn:
text: "Subject"
index: 1
- inputText: ${output.subject}
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Scheduled from the iOS simulator test."
- tapOn: "Schedule send"
- extendedWaitUntil:
visible: "Tomorrow morning.*"
timeout: 10000
- takeScreenshot: scheduled-01-options
- tapOn: "Tomorrow morning.*"
- extendedWaitUntil:
visible: "Scheduled"
timeout: 30000
- takeScreenshot: scheduled-02-confirmed
- tapOn: "OK"
- runFlow:
file: ../subflows/go-to-folder.yaml
env:
FOLDER: Scheduled
- extendedWaitUntil:
visible: ".*${output.subject}.*"
timeout: 30000
- takeScreenshot: scheduled-03-list
- tapOn: ".*${output.subject}.*"
- tapOn: "Cancel send"
- extendedWaitUntil:
visible: "Keep scheduled"
timeout: 10000
- tapOn:
text: "Cancel send"
below: "Keep scheduled|.*will not be delivered.*"
- extendedWaitUntil:
notVisible: ".*${output.subject}.*"
timeout: 30000
- takeScreenshot: scheduled-04-cancelled
- runFlow: ../subflows/go-to-inbox.yaml
+126
View File
@@ -0,0 +1,126 @@
# Calendar: the views, create an event with a reminder, edit it, delete it.
appId: org.bulwarkmail.mobile
name: Calendar
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
- tapOn:
id: tab-calendar
# The first visit asks for notification permission (event reminders).
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow: ../subflows/allow-notifications.yaml
- extendedWaitUntil:
visible: "Go to today"
timeout: 30000
- takeScreenshot: calendar-01-month
- tapOn: "Open menu"
- tapOn: "Schedule"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-02-schedule
- tapOn: "Open menu"
- tapOn: "Day"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-03-day
- tapOn: "Open menu"
- tapOn: "Week"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-04-week
# Create
- tapOn: "New event"
- extendedWaitUntil:
visible: "Save"
timeout: 10000
- tapOn: "Title"
- inputText: ${output.event}
# Close the keyboard first: a tap while it slides away lands on a form that
# is still moving.
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "Add reminder"
direction: DOWN
timeout: 10000
- repeat:
while:
notVisible: "15 minutes before"
times: 2
commands:
- tapOn: "Add reminder"
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "15 minutes before"
direction: DOWN
timeout: 10000
- tapOn: "15 minutes before"
- takeScreenshot: calendar-05-editor
- tapOn: "Save"
# The editor closes (its Save goes; the FAB is labelled "New event" too).
- extendedWaitUntil:
notVisible: "Save"
timeout: 20000
# Find it in the schedule and open it
- tapOn: "Open menu"
- tapOn: "Schedule"
- tapOn: "Go to today"
- scrollUntilVisible:
element: ".*${output.event}.*"
direction: DOWN
timeout: 30000
- tapOn: ".*${output.event}.*"
- extendedWaitUntil:
visible: "Edit"
timeout: 10000
- takeScreenshot: calendar-06-detail
- assertVisible: ".*15 minutes before.*"
# Edit: add a location (an empty field, so no cursor games in the title)
- tapOn: "Edit"
- extendedWaitUntil:
visible: "Edit event"
timeout: 10000
- scrollUntilVisible:
element: "Place or address"
direction: DOWN
timeout: 10000
- tapOn: "Place or address"
- inputText: "Room 42"
- tapOn: "Save"
- extendedWaitUntil:
visible: ".*Room 42.*"
timeout: 20000
- takeScreenshot: calendar-07-edited
# Delete (the detail sheet is still open after saving)
- runFlow:
when:
notVisible: "Delete"
commands:
- tapOn: ".*${output.event}.*"
- extendedWaitUntil:
visible: "Delete"
timeout: 10000
- tapOn: "Delete"
- extendedWaitUntil:
visible: "Delete this event.*"
timeout: 10000
- tapOn:
text: "Delete"
below: "Delete this event.*"
- extendedWaitUntil:
notVisible: ".*${output.event}.*"
timeout: 20000
- takeScreenshot: calendar-08-deleted
- tapOn:
id: tab-mail
+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
+65
View File
@@ -0,0 +1,65 @@
# Files: open a folder, preview a text and a PDF file, create and delete a
# folder.
appId: org.bulwarkmail.mobile
name: Files
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
- tapOn:
id: tab-files
- extendedWaitUntil:
visible: ".*Documents.*"
timeout: 30000
- takeScreenshot: files-01-root
- tapOn: ".*Documents.*"
- extendedWaitUntil:
visible: ".*readme.txt.*"
timeout: 20000
- tapOn: ".*readme.txt.*"
- extendedWaitUntil:
visible: "readme.txt"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: files-02-text-preview
- tapOn: "Close"
- tapOn: ".*q3-report.pdf.*"
- extendedWaitUntil:
visible: "q3-report.pdf"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: files-03-pdf-preview
- tapOn: "Close"
- tapOn: "Back"
- tapOn: "New Folder"
- tapOn: "Folder name"
- inputText: ${output.folder}
- tapOn: "Create"
- extendedWaitUntil:
visible: ".*${output.folder}.*"
timeout: 20000
- takeScreenshot: files-04-folder-created
- longPressOn: ".*${output.folder}.*"
- repeat:
while:
notVisible: "Delete this item.*"
times: 3
commands:
- tapOn: "Delete"
- waitForAnimationToEnd:
timeout: 3000
- extendedWaitUntil:
visible: "Delete this item.*"
timeout: 10000
- tapOn:
text: "Delete"
below: "Delete this item.*"
- extendedWaitUntil:
notVisible: ".*${output.folder}.*"
timeout: 20000
- tapOn:
id: tab-mail
+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, .*", Q: "Language", PROOF: "Date Format"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "Folders", PROOF: ".*Inbox.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content", PROOF: "External Content"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Calendar", PROOF: "Default view"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Contacts", PROOF: "Group by first letter"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "The default light and dark palettes."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
- tapOn:
id: tab-mail
+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.*"
+79
View File
@@ -0,0 +1,79 @@
# Push on iOS: the relay's payload carries only ids and a placeholder text.
# A tap opens the message, with the app running and from a cold start. The
# helper sends the message as userb and pushes the relay's payload with
# simctl, so this needs no APNs key.
#
# simctl push hands the payload to the simulator as a local notification
# request: the notification service extension (plugins/ios-push), which
# puts sender and subject in, never runs that way (the log shows no
# NotificationService process). So the banner shows the placeholder here;
# the extension needs a real APNs push to be tested.
appId: org.bulwarkmail.mobile
name: Push notification on iOS
tags:
- push
---
- runFlow: ../subflows/signed-in.yaml
# Without notification permission iOS drops the push silently, and the
# launch-time grant doesn't reach it on these simulators: let the app ask
# (the Calendar tab does, for event reminders) and allow.
- tapOn:
id: tab-calendar
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn:
text: "Allow"
optional: true
- tapOn:
id: tab-mail
- waitForAnimationToEnd:
timeout: 3000
- pressKey: Home
# 1. The banner shows (and what it says).
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
- extendedWaitUntil:
visible: ".*${output.subject0}.*|.*New message.*"
timeout: 30000
- takeScreenshot: push-01-banner
# Let it go, so the next tap can't hit this one.
- extendedWaitUntil:
notVisible: ".*${output.subject0}.*|.*New message.*"
timeout: 20000
# 2. A tap opens the message, app in the background. The banner shows for a
# few seconds and finding it takes Maestro about as long; taps on the lock
# screen list do nothing on these simulators. So tap where the banner
# appears, right after the push (push-and-tap.yaml).
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject}
- extendedWaitUntil:
visible: "Reply All"
timeout: 60000
- assertVisible: ".*${output.subject}.*"
- takeScreenshot: push-02-opened-warm
# 3. The same from a cold start: the app isn't running.
- stopApp
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject2}
- extendedWaitUntil:
visible: "Reply All"
timeout: 90000
- assertVisible: ".*${output.subject2}.*"
- takeScreenshot: push-04-opened-cold
+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.*"
@@ -0,0 +1,21 @@
# The app asks for notification permission now and then (calendar
# reminders, push). The system alert covers the whole app, so XCTest sees
# nothing else until it is answered: allow it when it is up. Taps on system
# alerts miss in landscape, so answer it in portrait.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- setOrientation: PORTRAIT
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
text: "Allow"
optional: true
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
+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
+41
View File
@@ -0,0 +1,41 @@
# In the composer: type BODY into the editor. The rich editor is a WebView
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
# the screen, which is the body area below Subject (above the keyboard).
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
# to the body (an iPad in landscape shows hardly any body with the keyboard
# up, so there is nothing to tap).
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${FROM_SUBJECT == 'yes'}
commands:
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${BODY}
- runFlow:
when:
true: ${FROM_SUBJECT != 'yes'}
commands:
- runFlow:
when:
visible: "Write your message.*"
commands:
- tapOn: "Write your message.*"
# A tap on the WebView doesn't always take the focus from the Subject field
# (on the iPad the body once went into Subject); a second tap does.
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
- runFlow:
when:
notVisible: "Write your message.*"
commands:
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${BODY}
+20
View File
@@ -0,0 +1,20 @@
# A sign-in link from another app asks first. Continue.
appId: org.bulwarkmail.mobile
---
# simctl openurl acts like a link from another app: iOS asks first.
- extendedWaitUntil:
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
timeout: ${LINK_WAIT || 60000}
- runFlow:
when:
visible: "Open in .*Bulwark Mobile.*"
commands:
- tapOn: "Open"
# Right after a sign-out the app can take a while to get to the link.
- extendedWaitUntil:
visible: "Sign in with a link.|Open the sign-in page of.*"
timeout: 60000
- takeScreenshot: link-confirm-prompt
- tapOn:
text: "Continue"
below: "Sign in with a link."
@@ -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
+26
View File
@@ -0,0 +1,26 @@
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
# one-time prompts on the way.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible:
id: tab-mail
timeout: 90000
- runFlow:
when:
visible: "Not now"
commands:
- tapOn: "Not now"
# Adding an account from Settings returns to Settings.
- tapOn:
id: tab-mail
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
# Rows are one element: sender, subject and preview together. Mail the
# tests sent earlier can push the seeded row below the fold.
- scrollUntilVisible:
element: ${INBOX_TEXT || '.*Your verification code.*'}
direction: DOWN
timeout: 30000
+31
View File
@@ -0,0 +1,31 @@
# From the sign-in choices: enter a server address by hand and continue to the
# confirm step. env HOST: the address as a user types it.
appId: org.bulwarkmail.mobile
---
- tapOn: "Enter server details manually"
- extendedWaitUntil:
visible:
id: login-server-input
timeout: 10000
- runFlow:
file: type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: ${HOST}
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
optional: true
# Once in a while the tap doesn't reach the button (it moves as the keyboard
# goes away): tap again if the form is still there.
- runFlow:
when:
visible:
id: login-server-input
commands:
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
+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
+36
View File
@@ -0,0 +1,36 @@
# From a message list: open the message whose row mentions SUBJECT.
# A row near the bottom can count as visible while the phone's tab bar
# covers it (the tap then opens another tab): on the phone, scroll it to the
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
# screen) doesn't reach; its rows fit on screen.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element: ".*${SUBJECT}.*"
direction: DOWN
centerElement: true
visibilityPercentage: 100
timeout: 30000
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- scrollUntilVisible:
element: ".*${SUBJECT}.*"
direction: DOWN
timeout: 30000
# A tap while the list still moves lands on another row.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: ".*${SUBJECT}.*"
# The message toolbar's "more" button: "Reply All" shows on the phone, but
# the iPad pane has a quick reply box instead for mail to one recipient.
- extendedWaitUntil:
visible: "more"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
+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.*"
+23
View File
@@ -0,0 +1,23 @@
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
# where it appears, at once (it shows for a few seconds, about as long as
# Maestro takes to find it). If the tap came too early or too late, push
# again: up to three tries until the message is open.
appId: org.bulwarkmail.mobile
---
- repeat:
while:
notVisible: "Reply All"
times: 3
commands:
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
- tapOn:
point: "50%,9%"
waitToSettleTimeoutMs: 500
retryTapIfNoChange: false
- extendedWaitUntil:
visible: "Reply All"
timeout: 30000
optional: true
+87
View File
@@ -0,0 +1,87 @@
# Settings list: find the row ROW (regex on "Title, summary"), open it,
# check PROOF shows, screenshot, and go back on a phone.
#
# Rows count as visible while the phone's tab bar or header covers them or
# while they hang half off the iPad's screen, and a tap then misses; the
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
# doesn't reach. So search for the page (Q, a word of its title): the row
# then sits right under the search box. The account card (no Q) is always
# at the top.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${!!Q}
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# Now and then a typed character goes missing: type it again.
- runFlow:
when:
notVisible: ${ROW}
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# A tap while the list still coasts from the swipe only stops it.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: ${ROW}
- waitForAnimationToEnd:
timeout: 5000
# On the phone the page replaces the list: make sure it opened before
# scrolling (else the swipes below scroll the list away).
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- runFlow:
when:
notVisible: "Back"
commands:
- tapOn: ${ROW}
- extendedWaitUntil:
visible: "Back"
timeout: 10000
# PROOF can be further down the page: scroll the page (the right-hand pane
# on the iPad) until it shows.
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 75%,80%
end: 75%,40%
duration: 600
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 50%,80%
end: 50%,40%
duration: 600
- extendedWaitUntil:
visible: ${PROOF}
timeout: 20000
- takeScreenshot: settings-${NAME}
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
+37
View File
@@ -0,0 +1,37 @@
# Signed in: sign out of the active account from the end of Settings.
# On the iPad the Settings list is the left column; scroll that one.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: "Sign out"
times: 12
commands:
- swipe:
start: 30%,85%
end: 30%,30%
duration: 500
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element: "Sign out"
direction: DOWN
timeout: 30000
- tapOn: "Sign out"
- extendedWaitUntil:
visible: "The account is removed from this device.*"
timeout: 10000
# The dialog's button, not the Settings row of the same name behind it.
- tapOn:
text: "Sign out"
rightOf: "Cancel"
below: "The account is removed from this device.*"
- takeScreenshot: sign-out-tapped
+36
View File
@@ -0,0 +1,36 @@
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
# Keeps the app's data between flows; signs in only when needed.
appId: org.bulwarkmail.mobile
---
- launchApp:
permissions:
notifications: allow
photos: allow
camera: allow
# Answer the notification prompt (if the app asks) before turning to
# landscape: rotated, taps on system alerts miss.
- setOrientation: PORTRAIT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
timeout: 120000
- runFlow: allow-notifications.yaml
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox"
timeout: 30000
- runFlow:
when:
visible: "Enter server details manually"
commands:
- runFlow:
file: password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: allow-notifications.yaml
- tapOn:
id: tab-mail
- runFlow: go-to-inbox.yaml
@@ -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}
+27
View File
@@ -0,0 +1,27 @@
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
# pages in the auth session), and check it arrived as typed. Now and then a
# stray character lands in front of it on the CI simulator; then erase the
# field and type again. The caret is still at the end of what was just
# typed, so eraseText (which deletes before the caret) empties it; selecting
# the text with a long press didn't work and appended instead. SECRET=yes
# skips the check (a password field shows dots).
appId: org.bulwarkmail.mobile
---
- tapOn: ${HINT}
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${TEXT}
- runFlow:
when:
true: ${SECRET != 'yes'}
commands:
- repeat:
while:
notVisible: ${TEXT}
times: 2
commands:
- eraseText: 150
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${TEXT}
- assertVisible: ${TEXT}
+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"
+20 -16
View File
@@ -43,20 +43,14 @@ import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78). // Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000; const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types'; import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen'; import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen'; import LoginScreen from './src/screens/LoginScreen';
import MailTab from './src/screens/MailTab'; import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen'; import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen'; import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen'; import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen'; // Screens that aren't on the way to the inbox load on first visit
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen'; // (getComponent below), so their modules stay out of the startup work.
import { useAccountStore } from './src/stores/account-store'; import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store'; import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store'; import { useCalendarStore } from './src/stores/calendar-store';
@@ -94,6 +88,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store'; import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync'; import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens'; import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
import { useColors } from './src/theme/colors'; import { useColors } from './src/theme/colors';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -261,6 +256,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<PushOnboardingPrompt /> <PushOnboardingPrompt />
<AppIconBadge /> <AppIconBadge />
<Tab.Navigator <Tab.Navigator
screenListeners={perfMarksEnabled() ? ({ route }) => ({
tabPress: () => perfStart('tab-switch'),
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
}) : undefined}
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
// Tabs out of view stop re-rendering until they are shown again. // Tabs out of view stop re-rendering until they are shown again.
@@ -297,6 +296,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: {
@@ -317,9 +317,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
</Tab.Screen> </Tab.Screen>
<Tab.Screen <Tab.Screen
name="Calendar" name="Calendar"
component={CalendarScreen} getComponent={() => require('./src/screens/CalendarScreen').default}
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),
@@ -332,9 +333,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/> />
<Tab.Screen <Tab.Screen
name="Contacts" name="Contacts"
component={ContactsTab} getComponent={() => require('./src/screens/ContactsTab').default}
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),
@@ -347,9 +349,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
/> />
<Tab.Screen <Tab.Screen
name="Files" name="Files"
component={FilesScreen} getComponent={() => require('./src/screens/FilesScreen').default}
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 +367,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} />,
}} }}
> >
@@ -863,10 +867,10 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}> <Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} /> <Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} /> <Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} /> <Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
<Stack.Screen <Stack.Screen
name="Compose" name="Compose"
component={ComposeScreen} getComponent={() => require('./src/screens/ComposeScreen').default}
// A tablet shows the composer as a panel over the screen it came // A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop. // from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit options={isSplit
@@ -876,15 +880,15 @@ export default function App() {
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} /> <Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen <Stack.Screen
name="ContactForm" name="ContactForm"
component={ContactFormScreen} getComponent={() => require('./src/screens/ContactFormScreen').default}
options={{ options={{
presentation: 'modal', presentation: 'modal',
animation: 'slide_from_bottom', animation: 'slide_from_bottom',
}} }}
/> />
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} /> <Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} /> <Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} /> <Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
<Stack.Screen <Stack.Screen
name="AddAccount" name="AddAccount"
options={{ options={{
+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))
+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
+4
View File
@@ -6,6 +6,10 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
import { pushBackgroundTask } from './src/lib/push-background-task'; import { pushBackgroundTask } from './src/lib/push-background-task';
import { widgetTaskHandler } from './src/widgets/task-handler'; import { widgetTaskHandler } from './src/widgets/task-handler';
import { refreshWidgetsInBackground } from './src/widgets/sync'; import { refreshWidgetsInBackground } from './src/widgets/sync';
import { perfMark } from './src/lib/perf-marks';
// Every import above has run: the bundle is loaded (performance harness).
perfMark('bundle-loaded');
import { markUiStarted } from './src/widgets/ui-presence'; import { markUiStarted } from './src/widgets/ui-presence';
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started // Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
+41
View File
@@ -1,5 +1,46 @@
const path = require('path');
const fs = require('fs');
const { getDefaultConfig } = require('expo/metro-config'); const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname); const config = getDefaultConfig(__dirname);
// Performance harness (the iOS simulator tests, .github/workflows/e2e-ios.yml):
// with BULWARK_MODULE_TIMINGS set, the bundle times each module's
// evaluation (scripts/perf/module-timings.js), and BULWARK_MODULE_MAP names
// a file that receives the module ids with their source paths, so the
// harness can tell which module is which. Release builds set neither.
if (process.env.BULWARK_MODULE_TIMINGS) {
const basePolyfills = config.serializer.getPolyfills;
config.serializer.getPolyfills = (ctx) => [
...basePolyfills(ctx),
path.join(__dirname, 'scripts/perf/module-timings.js'),
];
const mapFile = process.env.BULWARK_MODULE_MAP;
if (mapFile) {
const baseFactory = config.serializer.createModuleIdFactory;
config.serializer.createModuleIdFactory = () => {
const idOf = baseFactory();
const names = {};
process.on('exit', () => {
try { fs.writeFileSync(mapFile, JSON.stringify(names)); } catch { /* best effort */ }
});
return (modulePath) => {
const id = idOf(modulePath);
names[id] = path.relative(__dirname, modulePath).split(path.sep).join('/');
return id;
};
};
}
}
// Evaluate a module when it is first used instead of all of them before the
// first render. Expo's default leaves this off; React Native's own template
// turns it on. Side-effect-only imports (`import './x'`) still run in place.
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
});
module.exports = config; module.exports = config;
+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');
+439
View File
@@ -0,0 +1,439 @@
#!/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);
}
});
}
// simctl pbcopy times out now and then (seen on the iPad simulator); a
// second or third try goes through.
async function pbcopy(text) {
for (let attempt = 1; ; attempt++) {
try {
await simctl(['pbcopy', UDID], text);
return;
} catch (e) {
if (attempt >= 3) throw e;
log(`pbcopy attempt ${attempt} failed, trying again`);
await new Promise((r) => setTimeout(r, 2000));
}
}
}
async function deliver(link, how) {
if (how === 'openurl') await simctl(['openurl', UDID, link]);
else if (how === 'pbcopy') await pbcopy(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 pbcopy(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}`));
+252
View File
@@ -0,0 +1,252 @@
#!/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). The
// first launch after the install is much slower and swings a lot (iOS
// does its first-launch work for the new binary), so it is reported on
// its own as `first-start-*`; `cold-start-*` are the launches after it,
// 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 }
const moduleRuns = {}; // label -> [{ total, top: { id: ms } }] (module-times marks)
async function coldStart(label, round, first) {
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 modulesPending = waitForMark('module-times', 125_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;
}
const kind = first ? 'first-start' : 'cold-start';
// Builds made with BULWARK_MODULE_TIMINGS log how long each module took
// to evaluate, right after the first inbox (src/lib/perf-marks.ts).
const modules = await Promise.race([modulesPending, sleep(3000).then(() => null)]);
if (modules && !first) {
const top = {};
for (const pair of String(modules.top || '').split(',')) {
const [id, ms] = pair.split(':');
if (id) top[id] = Number(ms);
}
(moduleRuns[label] ||= []).push({ total: modules.total, top });
}
samples.push({ label, round, metric: `${kind}-to-inbox`, ms: mark.t - t0 });
if (bundle) samples.push({ label, round, metric: `${kind}-to-bundle`, ms: bundle.t - t0 });
// The start-up timeline: every mark between launch and the inbox, as time
// since launch, plus how long each persisted cache took to read and parse.
if (!first) {
for (const m of marks) {
if (m.t < t0 || m.t > mark.t || m.n === 'inbox-rendered' || m.n === 'bundle-loaded') continue;
const key = m.store ? `${m.n}:${m.store}` : m.n;
samples.push({ label, round, metric: `start:${key}`, ms: m.t - t0 });
if (m.n === 'hydrate') {
samples.push({ label, round, metric: `hydrate-read:${m.store}`, ms: m.readMs });
samples.push({ label, round, metric: `hydrate-parse:${m.store}`, ms: m.parseMs, kb: m.kb });
}
}
}
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);
// The slowest modules at start-up per app, by median over the launches.
const moduleSections = [];
for (const app of apps) {
const runs = moduleRuns[app.label];
if (!runs?.length) continue;
let names = {};
try { names = JSON.parse(fs.readFileSync(path.join(path.dirname(app.path), 'module-map.json'), 'utf8')); } catch { /* no map */ }
const ids = new Set(runs.flatMap((r) => Object.keys(r.top)));
const rows = [...ids]
.map((id) => ({ id, ms: median(runs.map((r) => r.top[id] ?? 0)) }))
.sort((a, b) => b.ms - a.ms)
.slice(0, 30);
moduleSections.push(
`### Slowest modules at start-up: ${app.label} (own evaluation time, median of ${runs.length} launches; all modules ${median(runs.map((r) => r.total))} ms)`,
'',
'| module | ms |',
'|---|---|',
...rows.map((r) => `| ${names[r.id] ?? `#${r.id}`} | ${r.ms} |`),
'',
);
}
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), '', ...moduleSections].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, i === 0);
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);
})();
+157
View File
@@ -0,0 +1,157 @@
#!/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. When
# it dies on the way (it does, now and then), a second try starts a new one
# instead of losing the whole leg.
for attempt in 1 2; do
WARMUP_START=$(date +%s)
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
--debug-output "$RUNNER_TEMP/maestro-warmup-$attempt" "$ROOT/.maestro/launch.yaml")
STATUS=$?
echo "warm-up $attempt took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
[ $STATUS -eq 0 ] && break
echo "::group::Maestro warm-up log $attempt"
find "$RUNNER_TEMP/maestro-warmup-$attempt" -name 'maestro.log' -exec tail -n 300 {} \;
echo "::endgroup::"
done
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
+30
View File
@@ -0,0 +1,30 @@
// Metro polyfill for the performance harness (metro.config.js adds it only
// when BULWARK_MODULE_TIMINGS is set at bundle time; release builds don't
// have it). It wraps every module factory and adds up how long each module's
// own code ran when it was first evaluated, without the modules it required
// meanwhile. src/lib/perf-marks.ts logs the slowest ones at first inbox.
(function (g) {
var define = g.__d;
if (typeof define !== 'function') return;
var now = typeof g.nativePerformanceNow === 'function'
? g.nativePerformanceNow
: function () { return Date.now(); };
var times = {};
var stack = [];
g.__bulwarkModuleTimes = times;
g.__d = function (factory, moduleId, dependencyMap, verboseName) {
var timed = function () {
var start = now();
stack.push(0);
try {
return factory.apply(this, arguments);
} finally {
var total = now() - start;
var nested = stack.pop();
times[moduleId] = (times[moduleId] || 0) + (total - nested);
if (stack.length > 0) stack[stack.length - 1] += total;
}
};
return define(timed, moduleId, dependencyMap, verboseName);
};
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const secure = new Map<string, string>();
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { JMAPClient, AuthenticationError } from '../jmap-client';
import type { JMAPSession } from '../types';
// Start-up used to wait for the server's session document before the
// account could show. The client now keeps the last one per account and
// starts from it (loadAccountFromCache), then fetches a fresh one.
const ACCOUNT = 'user@mail.example.com';
const CREDS = { serverUrl: 'https://mail.example.com', username: 'user', password: 'pass' };
const SESSION: JMAPSession = {
apiUrl: 'https://mail.example.com/jmap/',
downloadUrl: 'https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://mail.example.com/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/eventsource/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc-1', 'urn:ietf:params:jmap:core': 'acc-1' },
accounts: { 'acc-1': { name: 'user@example.com', isPersonal: true, isReadOnly: false } },
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
state: 'state-1',
};
function mockFetch(responses: Array<{ status: number; json?: unknown }>) {
let i = 0;
const fn = vi.fn(async (url: string) => {
const r = responses[i++] ?? responses[responses.length - 1];
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
statusText: r.status === 200 ? 'OK' : 'Error',
url,
redirected: false,
headers: { get: () => null },
json: async () => r.json,
text: async () => (r.json === undefined ? '' : JSON.stringify(r.json)),
};
});
vi.stubGlobal('fetch', fn);
return fn;
}
async function storeCredentials() {
const SecureStore = await import('expo-secure-store');
await SecureStore.setItemAsync(`jmap_credentials__${ACCOUNT.replace(/[^a-zA-Z0-9._-]/g, '_')}`, JSON.stringify(CREDS));
}
beforeEach(async () => {
secure.clear();
await AsyncStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('saved session', () => {
it('starts from the session of the last connect, without a request', async () => {
await storeCredentials();
mockFetch([{ status: 200, json: SESSION }]);
expect(await new JMAPClient().loadAccount(ACCOUNT)).toBe(true);
await vi.waitFor(async () => expect(await AsyncStorage.getAllKeys()).toHaveLength(1));
const fetch = mockFetch([{ status: 500 }]);
const client = new JMAPClient();
expect(await client.loadAccountFromCache(ACCOUNT)).toBe(true);
expect(fetch).not.toHaveBeenCalled();
expect(client.accountId).toBe('acc-1');
expect(client.currentSession?.apiUrl).toBe(SESSION.apiUrl);
});
it('has nothing to start from before the first connect', async () => {
await storeCredentials();
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('ignores a session saved for another server', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: 'https://old.example.com', session: SESSION }),
);
expect(await new JMAPClient().loadAccountFromCache(ACCOUNT)).toBe(false);
});
it('is dropped with the credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
await new JMAPClient().clearAccountCredentials(ACCOUNT);
expect(await AsyncStorage.getItem(`jmap_session_cache__${ACCOUNT}`)).toBeNull();
});
it('revalidates against the server and reports refused credentials', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 401 }]);
await expect(client.revalidateSession()).rejects.toBeInstanceOf(AuthenticationError);
});
it('drops a revalidation that finishes after a switch to another account', async () => {
await storeCredentials();
await AsyncStorage.setItem(
`jmap_session_cache__${ACCOUNT}`,
JSON.stringify({ serverUrl: CREDS.serverUrl, session: SESSION }),
);
const client = new JMAPClient();
await client.loadAccountFromCache(ACCOUNT);
mockFetch([{ status: 200, json: { ...SESSION, apiUrl: 'https://mail.example.com/jmap2/' } }]);
const pending = client.revalidateSession();
client.reset();
expect(await pending).toBeNull();
expect(client.currentSession).toBeNull();
});
});
+84
View File
@@ -1,4 +1,5 @@
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { import type {
JMAPSession, JMAPSession,
JMAPMethodCall, JMAPMethodCall,
@@ -44,6 +45,20 @@ 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, '_');
} }
// The session document from the account's last connect (URLs, account ids,
// capabilities; no secrets). Start-up uses it to show the account without
// waiting for the network, then fetches a fresh one (revalidateSession).
const SESSION_CACHE_PREFIX = 'jmap_session_cache__';
function sessionCacheKey(accountId: string): string {
return SESSION_CACHE_PREFIX + accountId;
}
interface CachedSession {
serverUrl: string;
session: JMAPSession;
}
export interface StoredCredentials { export interface StoredCredentials {
serverUrl: string; serverUrl: string;
username: string; username: string;
@@ -524,9 +539,74 @@ export class JMAPClient {
); );
} }
this._accountId = this.resolveAccountId(this.session); this._accountId = this.resolveAccountId(this.session);
this.rememberSession(accountId);
return true; return true;
} }
/**
* Start-up without a network round trip: the account's credentials and the
* session document saved at its last connect. Returns false when either is
* missing (or the saved one is for another server); the caller then uses
* loadAccount. A wrong password or revoked token surfaces on the first
* request (401, onAuthFailure) or in revalidateSession.
*/
async loadAccountFromCache(accountId: string): Promise<boolean> {
const [stored, cachedRaw] = await Promise.all([
SecureStore.getItemAsync(credentialsKey(accountId)),
AsyncStorage.getItem(sessionCacheKey(accountId)).catch(() => null),
]);
if (!stored || !cachedRaw) return false;
let creds: StoredCredentials;
let cached: CachedSession;
try {
creds = JSON.parse(stored);
cached = JSON.parse(cachedRaw);
} catch {
return false;
}
if (!cached?.session?.apiUrl || cached.serverUrl !== creds.serverUrl) return false;
let accountIdInSession: string;
try {
accountIdInSession = this.resolveAccountId(cached.session);
} catch {
return false;
}
this.credentials = creds;
this.firstTouchGate.reset();
this.session = cached.session;
this._accountId = accountIdInSession;
return true;
}
/**
* Fetch a fresh session document for the account the client holds now,
* after a start from the saved one. Resolves null when the client moved to
* another account meanwhile (the result is dropped). Throws
* AuthenticationError / NetworkError like loadAccount.
*/
async revalidateSession(): Promise<JMAPSession | null> {
const creds = this.credentials;
if (!creds) return null;
let fresh: JMAPSession;
try {
fresh = this.rewriteSessionUrls(await this.fetchSession(creds.serverUrl), creds.serverUrl);
} catch (err) {
if (this.credentials !== creds) return null;
throw err;
}
if (this.credentials !== creds) return null;
this.session = fresh;
this._accountId = this.resolveAccountId(fresh);
this.rememberSession(generateAccountId(creds.username, creds.serverUrl));
return fresh;
}
private rememberSession(accountId: string): void {
if (!this.session || !this.credentials) return;
const entry: CachedSession = { serverUrl: this.credentials.serverUrl, session: this.session };
void AsyncStorage.setItem(sessionCacheKey(accountId), JSON.stringify(entry)).catch(() => undefined);
}
/** /**
* Snapshot of the live connection so a failed "add account" / switch can put * Snapshot of the live connection so a failed "add account" / switch can put
* the previous account back without a network round-trip. * the previous account back without a network round-trip.
@@ -548,12 +628,14 @@ export class JMAPClient {
async clearAccountCredentials(accountId: string): Promise<void> { async clearAccountCredentials(accountId: string): Promise<void> {
await SecureStore.deleteItemAsync(credentialsKey(accountId)); await SecureStore.deleteItemAsync(credentialsKey(accountId));
await AsyncStorage.removeItem(sessionCacheKey(accountId)).catch(() => undefined);
} }
async clearAllCredentials(accountIds: string[]): Promise<void> { async clearAllCredentials(accountIds: string[]): Promise<void> {
await Promise.all([ await Promise.all([
SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY), SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY),
...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))), ...accountIds.map((id) => SecureStore.deleteItemAsync(credentialsKey(id))),
...accountIds.map((id) => AsyncStorage.removeItem(sessionCacheKey(id)).catch(() => undefined)),
]); ]);
} }
@@ -616,6 +698,7 @@ export class JMAPClient {
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY); await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
if (currentAccountId) { if (currentAccountId) {
await SecureStore.deleteItemAsync(credentialsKey(currentAccountId)); await SecureStore.deleteItemAsync(credentialsKey(currentAccountId));
await AsyncStorage.removeItem(sessionCacheKey(currentAccountId)).catch(() => undefined);
} }
} }
@@ -835,6 +918,7 @@ export class JMAPClient {
const baseUrl = this.credentials.serverUrl; const baseUrl = this.credentials.serverUrl;
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
this._accountId = this.resolveAccountId(this.session); this._accountId = this.resolveAccountId(this.session);
this.rememberSession(generateAccountId(this.credentials.username, baseUrl));
return this.session; return this.session;
} }
+7
View File
@@ -19,6 +19,7 @@ import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors'; import { useColors, useResolvedTheme } from '../theme/colors';
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
import type { RootStackParamList } from '../navigation/types'; import type { RootStackParamList } from '../navigation/types';
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -683,8 +684,13 @@ export default function EmailBodyView({
[prepared.isHtml, prepared.applyInversion, quoteLabels], [prepared.isHtml, prepared.applyInversion, quoteLabels],
); );
// Performance harness: how much of opening a message is the WebView.
React.useEffect(() => { perfLap('open-message', 'body-mounted'); }, []);
const onMessage = (e: WebViewMessageEvent) => { const onMessage = (e: WebViewMessageEvent) => {
const data = e.nativeEvent.data; const data = e.nativeEvent.data;
// The body reported its size: it is laid out (performance harness).
perfEndAfterPaint('open-message');
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number. // Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
if (data.charCodeAt(0) === 123 /* '{' */) { if (data.charCodeAt(0) === 123 /* '{' */) {
try { try {
@@ -788,6 +794,7 @@ export default function EmailBodyView({
injectedJavaScript={injectedJs} injectedJavaScript={injectedJs}
onMessage={onMessage} onMessage={onMessage}
onLoadEnd={() => { onLoadEnd={() => {
perfLap('open-message', 'webview-loaded');
// A (re)load resets the page's zoom state to 1 — drop any host-side // A (re)load resets the page's zoom state to 1 — drop any host-side
// scroll locks so they can't go stale (e.g. cid images arriving // scroll locks so they can't go stale (e.g. cid images arriving
// reload the source mid-zoom). // reload the source mid-zoom).
+3 -3
View File
@@ -5,7 +5,6 @@ import {
TextInput, TextInput,
Pressable, Pressable,
StyleSheet, StyleSheet,
Modal,
Platform, Platform,
KeyboardAvoidingView, KeyboardAvoidingView,
ScrollView, ScrollView,
@@ -19,6 +18,7 @@ import Button from './Button';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth'; import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
import { describeLoginError } from '../lib/login-errors'; import { describeLoginError } from '../lib/login-errors';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface PasteSignInLinkModalProps { interface PasteSignInLinkModalProps {
@@ -81,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
const title = t('login.mobile.paste_title', 'Paste sign-in link'); const title = t('login.mobile.paste_title', 'Paste sign-in link');
return ( return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent> <SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
<View style={styles.header}> <View style={styles.header}>
@@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
</Modal> </SafeAreaModal>
); );
} }
+4 -3
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native'; import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera'; import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
import { X } from './icons'; import { X } from './icons';
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors, useResolvedTheme } from '../theme/colors'; import { useColors, useResolvedTheme } from '../theme/colors';
import Button from './Button'; import Button from './Button';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { SafeAreaModal } from './SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
interface QrScanModalProps { interface QrScanModalProps {
@@ -43,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
); );
return ( return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent> <SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<View style={styles.container}> <View style={styles.container}>
{permission?.granted ? ( {permission?.granted ? (
<CameraView <CameraView
@@ -105,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
) : null} ) : null}
</SafeAreaView> </SafeAreaView>
</View> </View>
</Modal> </SafeAreaModal>
); );
} }
+7
View File
@@ -3,6 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens'; import type { ThemePalette } from '../theme/tokens';
import { perfEndAfterPaint } from '../lib/perf-marks';
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html'; import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand = export type RichTextCommand =
@@ -65,6 +66,8 @@ export interface RichTextEditorHandle {
*/ */
getHtml(timeoutMs?: number): Promise<string>; getHtml(timeoutMs?: number): Promise<string>;
focus(): void; focus(): void;
/** Focus with the caret where new text goes (before signature / quote). */
focusEnd(): void;
/** Drop the caret, which closes the keyboard. */ /** Drop the caret, which closes the keyboard. */
blur(): void; blur(): void;
} }
@@ -185,6 +188,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
pendingSnapshots.current.set(id, { resolve, reject, timer }); pendingSnapshots.current.set(id, { resolve, reject, timer });
call(`window.__rne && window.__rne.getHtml(${id})`); call(`window.__rne && window.__rne.getHtml(${id})`);
}), }),
focusEnd: () => {
call(`window.__rne && window.__rne.focusEnd()`);
},
focus: () => { focus: () => {
call(`window.__rne && window.__rne.focus()`); call(`window.__rne && window.__rne.focus()`);
}, },
@@ -202,6 +208,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
} }
if (!readyRef.current) { if (!readyRef.current) {
readyRef.current = true; readyRef.current = true;
perfEndAfterPaint('compose-open');
const queued = queuedRef.current; const queued = queuedRef.current;
queuedRef.current = []; queuedRef.current = [];
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`); for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import { Modal, type ModalProps } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
/**
* A Modal with its own safe-area provider. A Modal is a separate native
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
* view starts off screen when the slide begins, so on iPhones with a notch or
* Dynamic Island the header (and its close button) ended up under the status
* bar, where taps don't reach it. Use this for full-screen modals with a
* SafeAreaView header.
*/
export function SafeAreaModal({ children, ...props }: ModalProps) {
return (
<Modal {...props}>
<SafeAreaProvider>{children}</SafeAreaProvider>
</Modal>
);
}
+5 -3
View File
@@ -6,6 +6,7 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { undoSnackbarColors } from './undo-snackbar-colors';
import { useEmailStore } from '../stores/email-store'; import { useEmailStore } from '../stores/email-store';
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store'; import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
@@ -158,6 +159,7 @@ export function UndoSnackbar() {
} }
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette) {
const colors = undoSnackbarColors(c);
return StyleSheet.create({ return StyleSheet.create({
wrap: { wrap: {
position: 'absolute', position: 'absolute',
@@ -170,7 +172,7 @@ function makeStyles(c: ThemePalette) {
paddingLeft: spacing.lg, paddingLeft: spacing.lg,
paddingRight: spacing.sm, paddingRight: spacing.sm,
borderRadius: radius.md, borderRadius: radius.md,
backgroundColor: c.text, backgroundColor: colors.bar,
shadowColor: '#000', shadowColor: '#000',
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 12, shadowRadius: 12,
@@ -179,7 +181,7 @@ function makeStyles(c: ThemePalette) {
}, },
label: { label: {
...typography.body, ...typography.body,
color: c.background, color: colors.label,
flex: 1, flex: 1,
}, },
undoBtn: { undoBtn: {
@@ -192,7 +194,7 @@ function makeStyles(c: ThemePalette) {
}, },
undoText: { undoText: {
...typography.bodySemibold, ...typography.bodySemibold,
color: c.primary, color: colors.action,
letterSpacing: 0.5, letterSpacing: 0.5,
}, },
sendNowText: { sendNowText: {
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
// iOS: the Modal is its own window and its content starts off screen, so on
// iPhones with a notch or Dynamic Island the header and its close button sat
// under the status bar, out of reach (attachment preview, QR scanner, paste
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
// which gives the window its own SafeAreaProvider.
const SRC = path.resolve(__dirname, '../..');
function sourceFiles(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
return e.name.endsWith('.tsx') ? [p] : [];
});
}
/** Opening tags of plain <Modal> elements, attributes included. */
function modalTags(source: string): string[] {
const tags: string[] = [];
let at = source.indexOf('<Modal');
while (at >= 0) {
if (/[\s>]/.test(source[at + 6] ?? '')) {
let depth = 0;
let i = at;
for (; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
}
tags.push(source.slice(at, i + 1));
}
at = source.indexOf('<Modal', at + 6);
}
return tags;
}
describe('slide-up modals with a SafeAreaView', () => {
it('use SafeAreaModal so their header clears the status bar', () => {
const offenders = sourceFiles(SRC)
.filter((file) => {
const source = fs.readFileSync(file, 'utf8');
if (!source.includes('SafeAreaView')) return false;
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
})
.map((file) => path.relative(SRC, file));
expect(offenders).toEqual([]);
});
it('finds the tags it is looking for', () => {
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('animationType="slide"');
});
});

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