mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9857fe1711 | ||
|
|
b4331a9bf9 | ||
|
|
c08e9d6fa8 | ||
|
|
470e7fc3ba | ||
|
|
b3bd19cc8c | ||
|
|
95cfa809ef | ||
|
|
3d3f4aec5a | ||
|
|
8303ff769f | ||
|
|
7078dfe806 | ||
|
|
f62849d1fe | ||
|
|
4bb846b726 | ||
|
|
2618680b21 | ||
|
|
533f176e88 | ||
|
|
da72415ef3 | ||
|
|
0109273037 | ||
|
|
33eb8f99e5 | ||
|
|
721c2e34a1 | ||
|
|
e06d92d88f | ||
|
|
42e89122bd | ||
|
|
33fc33910a | ||
|
|
dde504f825 | ||
|
|
f5f3637d01 | ||
|
|
bdade8503d | ||
|
|
57ca1c8083 | ||
|
|
32a41f42d3 | ||
|
|
ad9db5cbff | ||
|
|
99e4bd9614 | ||
|
|
239bf618b5 | ||
|
|
58e52421e9 | ||
|
|
96ffeaa0e2 | ||
|
|
81a0723abd | ||
|
|
6103df86a5 | ||
|
|
b97a877fce | ||
|
|
663f6f0007 | ||
|
|
5477cc039a | ||
|
|
ab8e8d7e1e | ||
|
|
49e57482cc | ||
|
|
5f06c3918c | ||
|
|
96471cf13b | ||
|
|
3575ed9450 | ||
|
|
f181c73a56 | ||
|
|
c85e32c9f4 | ||
|
|
f2b98c6dba | ||
|
|
653d5965ed | ||
|
|
01d15fd24f | ||
|
|
35e44318b9 | ||
|
|
3bdafefb65 | ||
|
|
982a23cafd | ||
|
|
c39e977de5 | ||
|
|
24948cb717 | ||
|
|
6b434346ff | ||
|
|
1827f8eb34 | ||
|
|
9c549c9d00 | ||
|
|
f00cd6a259 | ||
|
|
62931bcc53 | ||
|
|
5622d732e9 | ||
|
|
db3c6b5ba1 | ||
|
|
680d4745ed | ||
|
|
8533bd4f99 | ||
|
|
52975bd715 | ||
|
|
bf226c3794 | ||
|
|
ca09708fd1 | ||
|
|
96d9d54cc9 | ||
|
|
02bd714afd | ||
|
|
e57ee3d446 | ||
|
|
08e2f47187 | ||
|
|
de414f7ed6 | ||
|
|
273051e5f5 | ||
|
|
fa68b2eb53 | ||
|
|
3748a64b10 | ||
|
|
2078388e6f | ||
|
|
ea9bad7cff | ||
|
|
ae0b76442a | ||
|
|
341cdb0e61 | ||
|
|
1c9bbcc383 | ||
|
|
c7eda4735c | ||
|
|
5f5ffdaddb | ||
|
|
083ec958a0 | ||
|
|
2b486cb1ef | ||
|
|
86400b124a | ||
|
|
323797b4ee | ||
|
|
9166b0fb3c | ||
|
|
1f253e9488 | ||
|
|
d22fb8d5d0 | ||
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d | ||
|
|
107eb807e3 | ||
|
|
e731357b89 | ||
|
|
9cf1d3e849 | ||
|
|
d3dcc4e830 | ||
|
|
e0d2b190d3 | ||
|
|
b048eaca03 | ||
|
|
6f515e15f7 | ||
|
|
0a176ab51c | ||
|
|
75299244cf | ||
|
|
6aaa479875 | ||
|
|
124c0258ad | ||
|
|
ccffd6b10d | ||
|
|
0d96d8d044 | ||
|
|
95c31631fb | ||
|
|
cf7275c9c3 | ||
|
|
e452f72a8a | ||
|
|
776447dd41 | ||
|
|
52eb18dc33 | ||
|
|
33dc3cd0dc | ||
|
|
72b4f4abac | ||
|
|
de1b955bfd | ||
|
|
c8434b0610 | ||
|
|
e19229a5c6 | ||
|
|
d7cee7ca78 | ||
|
|
b59d00f0b7 | ||
|
|
975ed08c82 | ||
|
|
76170648f5 | ||
|
|
509583e729 | ||
|
|
7b93265b4f | ||
|
|
b5c4d62bde | ||
|
|
c6cb44db61 | ||
|
|
0db4bf00aa | ||
|
|
e94b782f8e | ||
|
|
ae1ef5ba5e | ||
|
|
f891bf3abb | ||
|
|
af55e0acef | ||
|
|
1ebc5c66f7 | ||
|
|
0b3710265b | ||
|
|
56e1593646 | ||
|
|
e7857ef806 | ||
|
|
b3290c3202 | ||
|
|
f231d1c5c6 | ||
|
|
984c359bb4 | ||
|
|
50ab7145c4 | ||
|
|
8f68d24bdb | ||
|
|
f22e851c82 | ||
|
|
d4982f6932 | ||
|
|
275d674577 | ||
|
|
0460da51e5 | ||
|
|
90170ea0e7 | ||
|
|
cbc41c963b | ||
|
|
63eefd7700 | ||
|
|
d97571cf0f | ||
|
|
8403843e76 | ||
|
|
756ceaa436 | ||
|
|
99de74de93 | ||
|
|
46d30169b2 | ||
|
|
512a28c494 | ||
|
|
29c172f2af | ||
|
|
9e463d3a66 | ||
|
|
ab712f11ba | ||
|
|
053fde8d56 | ||
|
|
250c0a9c26 | ||
|
|
d62805f805 | ||
|
|
b9e6ba417d | ||
|
|
0087e4c8ff | ||
|
|
2470e99dc8 | ||
|
|
8ab82cdeb7 | ||
|
|
64a62b6306 | ||
|
|
f4ff07da7a | ||
|
|
2288d28e46 | ||
|
|
295e292e47 | ||
|
|
802474d3f2 | ||
|
|
bfa02f1177 | ||
|
|
4b29cdfd3c | ||
|
|
2a47913d70 | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f | ||
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 | ||
|
|
61690603a2 | ||
|
|
765ff26aa5 | ||
|
|
b832ac0c49 | ||
|
|
f53cee7ec9 | ||
|
|
21d469f7e8 | ||
|
|
c1e0cb36c9 | ||
|
|
d8879617ed | ||
|
|
7b62755aee | ||
|
|
96e1cc1c3f | ||
|
|
ec137e931b | ||
|
|
cf78b07939 | ||
|
|
5237d9004d | ||
|
|
be15af5d4c | ||
|
|
90fe43882a | ||
|
|
465eeffd1e | ||
|
|
200404483c | ||
|
|
28f6787279 | ||
|
|
89f0524ad4 | ||
|
|
172110f7b0 | ||
|
|
aa612e6f0e | ||
|
|
76180b332d | ||
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Release Android APK
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --no-daemon
|
||||
./gradlew assembleGithubRelease --no-daemon
|
||||
|
||||
- name: Put apksigner on the PATH
|
||||
run: |
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
||||
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
||||
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||
apksigner sign \
|
||||
--v4-signing-enabled false \
|
||||
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||
apksigner verify --verbose --print-certs "$APK"
|
||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||
@@ -248,12 +248,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
SRC=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
sha256sum "$DEST" > "$DEST.sha256"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt
|
||||
MAPPING=""
|
||||
if [ -f "$MAPPING_SRC" ]; then
|
||||
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||
@@ -299,12 +299,12 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# A manual run with no tag only builds: it must not replace the APK
|
||||
# of the latest release with one from another commit.
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||
else
|
||||
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||
fi
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$TAG" ]; then
|
||||
@@ -319,3 +319,150 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||
files: ${{ steps.artifact.outputs.files }}
|
||||
|
||||
# The Google Play bundle (docs/android-release.md, "Google Play"). It is
|
||||
# signed with the release key, which Play knows as the upload key; Play
|
||||
# re-signs what it delivers with its own app signing key. A separate job so
|
||||
# the GitHub APK isn't held up by the three-ABI native build.
|
||||
play-bundle:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
steps:
|
||||
- name: Require the release signing secrets
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=""
|
||||
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
|
||||
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
|
||||
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
|
||||
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Decode and check the upload key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||
CERT="$RUNNER_TEMP/release-cert.der"
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||
keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||
-storepass:env KEYSTORE_PASSWORD -file "$CERT"
|
||||
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
|
||||
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then
|
||||
echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key."
|
||||
exit 1
|
||||
fi
|
||||
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
|
||||
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Play splits a bundle per ABI, so the extra architectures cost a phone
|
||||
# nothing and let 32-bit ARM phones and x86_64 Chromebooks install it.
|
||||
- name: Build the Play bundle
|
||||
working-directory: android
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
|
||||
|
||||
# Play rejects a bundle signed with another key than the registered
|
||||
# upload key, and its policies reject a self-updater and the media
|
||||
# permission. Catch all three here instead of in the Play Console.
|
||||
- name: Check the bundle
|
||||
env:
|
||||
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab
|
||||
SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')
|
||||
echo "Bundle signer SHA-256: $SIGNER"
|
||||
if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'."
|
||||
exit 1
|
||||
fi
|
||||
MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n')
|
||||
if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then
|
||||
echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile."
|
||||
exit 1
|
||||
fi
|
||||
for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do
|
||||
if grep -q "android.permission.$permission" <<< "$MANIFEST"; then
|
||||
echo "::error title=Permission not allowed on Play::The Play bundle declares $permission."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Name the bundle and R8 mapping with version
|
||||
id: artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab"
|
||||
MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||
cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB"
|
||||
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.aab }}
|
||||
path: |
|
||||
${{ steps.artifact.outputs.aab }}
|
||||
${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console).
|
||||
# Every release goes out to the internal and closed (beta) testers right
|
||||
# away; closed testing may wait for Google's review first.
|
||||
- name: Upload to Google Play (internal and closed testing)
|
||||
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: org.bulwarkmail.mobile
|
||||
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||
releaseName: ${{ steps.artifact.outputs.version }}
|
||||
tracks: internal,beta
|
||||
status: completed
|
||||
|
||||
@@ -25,6 +25,9 @@ jobs:
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.export.outputs.version }}
|
||||
uploaded: ${{ steps.upload.outputs.done }}
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
@@ -56,9 +59,6 @@ jobs:
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
@@ -66,14 +66,69 @@ jobs:
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
if [ -z "$NSE_PROFILE" ]; then
|
||||
# The notification service extension (plugins/ios-push) is its
|
||||
# own App ID and needs its own App Store profile.
|
||||
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
|
||||
exit 1
|
||||
fi
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Install the provisioning profiles
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profiles
|
||||
env:
|
||||
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
|
||||
# One App Store profile per target: the app and its notification
|
||||
# service extension (plugins/ios-push).
|
||||
for side in app nse; do
|
||||
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
|
||||
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
|
||||
echo "$DATA" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
|
||||
# ID from the profile keeps ExportOptions honest if app.config.js
|
||||
# changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
done
|
||||
|
||||
- name: Generate the iOS project
|
||||
env:
|
||||
# plugins/ios-push names each target's profile: the command line
|
||||
# can't, it would give every target the same one.
|
||||
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
|
||||
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
@@ -138,38 +193,6 @@ jobs:
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Install the provisioning profile
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
@@ -189,8 +212,7 @@ jobs:
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
CODE_SIGN_IDENTITY="Apple Distribution"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
@@ -212,8 +234,10 @@ jobs:
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
|
||||
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
|
||||
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
|
||||
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -227,6 +251,7 @@ jobs:
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
@@ -254,6 +279,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
id: upload
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
@@ -271,4 +297,35 @@ jobs:
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "done=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
|
||||
# Puts the upload on the public TestFlight link (README): waits for App Store
|
||||
# Connect to process it, adds it to the external group and submits it for
|
||||
# beta review. On Linux, because most of this job is waiting.
|
||||
public-testflight:
|
||||
needs: build
|
||||
if: needs.build.outputs.uploaded == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Add the build to the public group and submit it for beta review
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.*"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.*"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.*"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import {
|
||||
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
||||
} from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
|
||||
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
@@ -38,15 +39,18 @@ import {
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||
|
||||
// Pause between a mute change and the push-filter update it sends (#78).
|
||||
const MUTES_RESYNC_DELAY_MS = 1_000;
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
import ComposeScreen from './src/screens/ComposeScreen';
|
||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||
import LoginScreen from './src/screens/LoginScreen';
|
||||
import EmailListScreen from './src/screens/EmailListScreen';
|
||||
import MailTab from './src/screens/MailTab';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsScreen from './src/screens/ContactsScreen';
|
||||
import ContactsTab from './src/screens/ContactsTab';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
@@ -57,12 +61,12 @@ import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useLayout } from './src/lib/use-layout';
|
||||
import { toast } from './src/stores/toast-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
@@ -90,6 +94,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { useOutboxStore } from './src/stores/outbox-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
@@ -119,9 +124,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
emailIds: [payload.emailId],
|
||||
subject: payload.subject,
|
||||
// A group mailbox's message lives under another JMAP account (#839).
|
||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,21 +248,43 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
settings: t('sidebar.settings', 'Settings'),
|
||||
};
|
||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
||||
// Side by side layouts move the tabs into a rail on the left, like the
|
||||
// webmail's navigation rail.
|
||||
const { isSplit } = useLayout();
|
||||
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
|
||||
// with the tabs in a side rail the whole tab area has to.
|
||||
const bottomInset = useSafeAreaInsets().bottom;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<PushOnboardingPrompt />
|
||||
<AppIconBadge />
|
||||
<Tab.Navigator
|
||||
screenListeners={perfMarksEnabled() ? ({ route }) => ({
|
||||
tabPress: () => perfStart('tab-switch'),
|
||||
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
|
||||
}) : undefined}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Tabs out of view stop re-rendering until they are shown again.
|
||||
freezeOnBlur: true,
|
||||
tabBarPosition: isSplit ? 'left' : 'bottom',
|
||||
tabBarVariant: isSplit ? 'material' : 'uikit',
|
||||
// A narrow rail (icon over label), not a wide sidebar with labels
|
||||
// beside the icons.
|
||||
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
|
||||
tabBarActiveTintColor: c.text,
|
||||
tabBarInactiveTintColor: c.textSecondary,
|
||||
tabBarStyle: {
|
||||
tabBarStyle: isSplit ? {
|
||||
backgroundColor: c.secondary,
|
||||
borderRightColor: c.border,
|
||||
borderRightWidth: 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
shadowColor: 'transparent',
|
||||
} : {
|
||||
backgroundColor: c.background,
|
||||
borderTopColor: c.border,
|
||||
borderTopWidth: 1,
|
||||
@@ -273,6 +302,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Mail"
|
||||
options={{
|
||||
title: tabLabels.mail,
|
||||
tabBarButtonTestID: 'tab-mail',
|
||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
@@ -289,36 +319,14 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<EmailListScreen
|
||||
onComposePress={() => navigation.navigate('Compose')}
|
||||
onEmailPress={(email) => {
|
||||
// The list holds the open folder's mail: name its account so the
|
||||
// viewer never has to guess it from whatever folder is open.
|
||||
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
|
||||
const target = {
|
||||
jmapAccountId: mailboxAccountId(all, currentMailboxId),
|
||||
// A row of an "All folders" list or a tag view names its own
|
||||
// account instead, and pages over that account's rows.
|
||||
...viewerParamsForRow(email),
|
||||
};
|
||||
// Start on the body now: mounting the viewer takes a while.
|
||||
prefetchMessage(email, target.jmapAccountId);
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
...target,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{() => <MailTab navigation={navigation} />}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
title: tabLabels.calendar,
|
||||
tabBarButtonTestID: 'tab-calendar',
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
||||
@@ -331,9 +339,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
component={ContactsTab}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarButtonTestID: 'tab-contacts',
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
||||
@@ -349,6 +358,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
title: tabLabels.files,
|
||||
tabBarButtonTestID: 'tab-files',
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
||||
@@ -363,6 +373,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Settings"
|
||||
options={{
|
||||
title: tabLabels.settings,
|
||||
tabBarButtonTestID: 'tab-settings',
|
||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||
}}
|
||||
>
|
||||
@@ -400,6 +411,7 @@ export default function App() {
|
||||
// cached mail list instead of the "Restoring session" spinner; the real
|
||||
// JMAP session comes up in the background.
|
||||
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
||||
const { isSplit } = useLayout();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasRestoredSession) {
|
||||
@@ -685,6 +697,41 @@ export default function App() {
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
// Muting a folder or account changes the server-side push filter (#78),
|
||||
// which the setup above would only send at the next launch. Send it now,
|
||||
// after a short pause so a few taps in a row make one update. Compared by
|
||||
// content, since hydration hands back an equal but new object; the value
|
||||
// seen first after hydration is the baseline, not a change.
|
||||
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
||||
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
||||
const mutesKey = JSON.stringify(notificationMutes);
|
||||
const lastMutesKey = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!settingsHydrated) return;
|
||||
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
|
||||
lastMutesKey.current = mutesKey;
|
||||
return;
|
||||
}
|
||||
lastMutesKey.current = mutesKey;
|
||||
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl || cancelled) return;
|
||||
try {
|
||||
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
|
||||
} catch (error) {
|
||||
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
}, MUTES_RESYNC_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// Only a change of the mutes should send an update; the rest is read live.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mutesKey, settingsHydrated]);
|
||||
|
||||
// Live updates (SSE with polling fallback), re-armed on every account
|
||||
// switch and every re-established session — the singleton `client` object
|
||||
// never changes identity, so it cannot be the dependency on its own.
|
||||
@@ -830,10 +877,11 @@ export default function App() {
|
||||
<Stack.Screen
|
||||
name="Compose"
|
||||
component={ComposeScreen}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
// A tablet shows the composer as a panel over the screen it came
|
||||
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||
options={isSplit
|
||||
? { presentation: 'transparentModal', animation: 'fade' }
|
||||
: { presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||
<Stack.Screen
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## Try it
|
||||
|
||||
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
|
||||
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
@@ -30,7 +35,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
|
||||
|
||||
## Run locally
|
||||
|
||||
|
||||
@@ -90,8 +90,8 @@ react {
|
||||
/* Variants */
|
||||
// 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'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
// The distribution flavors (github, play) need their debug variants listed.
|
||||
debuggableVariants = ["githubDebug", "playDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
@@ -154,13 +154,39 @@ android {
|
||||
versionCode appVersionCode.toInteger()
|
||||
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'}\""
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
@@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') {
|
||||
}
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name == 'preReleaseBuild') {
|
||||
if (task.name ==~ /pre\w*ReleaseBuild/) {
|
||||
task.dependsOn(checkReleaseSigning)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "504486613721",
|
||||
"project_id": "bulwark-mobile",
|
||||
"storage_bucket": "bulwark-mobile.firebasestorage.app"
|
||||
"project_number": "752109678859",
|
||||
"project_id": "bulwark-20c72",
|
||||
"storage_bucket": "bulwark-20c72.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
|
||||
"mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.anonymous.bulwarkmobile"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
@@ -26,15 +26,15 @@
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
|
||||
"mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.bulwark.bulwarkmobile"
|
||||
"package_name": "org.bulwarkmail.mobile"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug builds load the bundle from Metro over http, which the main config
|
||||
forbids (a network security config replaces the manifest's
|
||||
usesCleartextTraffic). Same trust anchors as the release config.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<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_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"/>
|
||||
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
@@ -21,11 +25,11 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -76,6 +80,7 @@
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
@@ -109,224 +114,224 @@
|
||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||
</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,
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
|
||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
thread(name = "bulwark-notification") {
|
||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
preview: String?,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
groupTitle: String?,
|
||||
markReadLabel: String,
|
||||
deleteLabel: String,
|
||||
replyLabel: String,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-notification://$notificationId")
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val bigText = buildString {
|
||||
if (!subject.isNullOrBlank()) append(subject)
|
||||
if (!preview.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(preview)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
// body is the subject with the "(No subject)" fallback; an empty
|
||||
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(if (bigText.isNotBlank()) bigText else body)
|
||||
.setBigContentTitle(title)
|
||||
.setSummaryText(groupTitle)
|
||||
)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
if (emailId != null && accountId != null) {
|
||||
// Action 1: Mark as read
|
||||
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_MARK_READ
|
||||
data = Uri.parse("bulwark-action-read://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "markRead")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val markReadPending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":read").hashCode(),
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
|
||||
)
|
||||
|
||||
// Action 2: Delete
|
||||
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_DELETE
|
||||
data = Uri.parse("bulwark-action-delete://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "delete")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val deletePending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":delete").hashCode(),
|
||||
deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
||||
.setAuthenticationRequired(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Action 3: Reply
|
||||
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-reply://$notificationId")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
putExtra(EXTRA_ACTION, "reply")
|
||||
}
|
||||
val replyPending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
(notificationId + ":reply").hashCode(),
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
|
||||
)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
if (children.size < 2) {
|
||||
manager.cancel(groupKey, groupKey.hashCode())
|
||||
return
|
||||
}
|
||||
val count = children.size
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-group-summary://$groupKey")
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
|
||||
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
|
||||
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
|
||||
@@ -15,6 +15,8 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
add(BulwarkBuildModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationId != null) {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||
* its own so they zoom, asking for each page as it scrolls into view.
|
||||
*
|
||||
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||
* every call runs on one background thread, and the open document is kept
|
||||
* between calls for the file the preview shows.
|
||||
*/
|
||||
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||
private var openPath: String? = null
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var renderer: PdfRenderer? = null
|
||||
|
||||
override fun getName(): String = "BulwarkPdf"
|
||||
|
||||
override fun invalidate() {
|
||||
worker.execute { closeDocument() }
|
||||
worker.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||
@ReactMethod
|
||||
fun open(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
val pages = Arguments.createArray()
|
||||
for (i in 0 until doc.pageCount) {
|
||||
doc.openPage(i).use { page ->
|
||||
pages.pushMap(Arguments.createMap().apply {
|
||||
putInt("width", page.width)
|
||||
putInt("height", page.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||
} catch (e: SecurityException) {
|
||||
// Password-protected; PdfRenderer can't open those.
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||
} catch (e: Throwable) {
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_OPEN", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||
@ReactMethod
|
||||
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||
doc.openPage(index).use { page ->
|
||||
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
// Pages are transparent where nothing is drawn.
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
val out = fileOf(outUri)
|
||||
// The preview's folder is gone once it closed; a page
|
||||
// still queued then must not bring it back.
|
||||
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
|
||||
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
promise.resolve(outUri)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_PDF_RENDER", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the document the preview showed. */
|
||||
@ReactMethod
|
||||
fun close(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(fileUri: String): PdfRenderer {
|
||||
val path = fileOf(fileUri).absolutePath
|
||||
renderer?.let { if (openPath == path) return it }
|
||||
closeDocument()
|
||||
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
try {
|
||||
val doc = PdfRenderer(fd)
|
||||
descriptor = fd
|
||||
renderer = doc
|
||||
openPath = path
|
||||
return doc
|
||||
} catch (e: Throwable) {
|
||||
fd.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDocument() {
|
||||
try { renderer?.close() } catch (_: Throwable) {}
|
||||
try { descriptor?.close() } catch (_: Throwable) {}
|
||||
renderer = null
|
||||
descriptor = null
|
||||
openPath = null
|
||||
}
|
||||
|
||||
private fun fileOf(uri: String): File {
|
||||
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||
private const val MAX_PAGE_PX = 2480
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ object NotificationTapStore {
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
|
||||
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
@@ -51,6 +52,7 @@ object NotificationTapStore {
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_ACTION)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ object NotificationTapStore {
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
val action: String? = null,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
@@ -67,6 +70,7 @@ object NotificationTapStore {
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
if (action != null) putString("action", action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +79,5 @@ object NotificationTapStore {
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Trust anchors for every connection the app makes (fetch, the client
|
||||
certificate module, the sync adapters). Android 7+ trusts only the system
|
||||
CAs unless the app opts in; a self-hosted server behind a private CA
|
||||
installed under Settings > Security > Encryption & credentials would fail
|
||||
with "Network request failed" otherwise (#3). Cleartext stays off, as it is
|
||||
for an app targeting API 28+ without this file; the debug source set
|
||||
overrides this for Metro.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
+16
-1
@@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7);
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
// Apple team: prefixes the keychain groups the app shares with its
|
||||
// notification service extension (plugins/ios-push). Not a secret.
|
||||
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39';
|
||||
// Which APNs host the device tokens belong to: TestFlight and App Store builds
|
||||
// use production, simulators and development builds the sandbox.
|
||||
const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -31,7 +38,7 @@ module.exports = {
|
||||
// Android's manifest is maintained by hand and lists the same schemes.
|
||||
scheme: ['bulwarkmobile', 'bulwarkmail', 'mailto'],
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
orientation: 'default',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
@@ -96,9 +103,17 @@ module.exports = {
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
|
||||
// to where its own source is (AGPL §13); the default is upstream.
|
||||
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
|
||||
// src/lib/ios-push-shared.ts
|
||||
iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`,
|
||||
iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`,
|
||||
apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [presetExpo],
|
||||
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
|
||||
};
|
||||
};
|
||||
|
||||
+43
-3
@@ -3,7 +3,8 @@
|
||||
The release APK is built by
|
||||
[.github/workflows/release-android.yml](../.github/workflows/release-android.yml)
|
||||
on an Ubuntu runner whenever a GitHub release is published, and attached to that
|
||||
release. Installed apps pick it up through the in-app updater.
|
||||
release. Installed apps pick it up through the in-app updater. The same workflow
|
||||
builds the Google Play bundle; see [Google Play](#google-play).
|
||||
|
||||
Every APK up to and including 0.1.62 was signed with `android/app/debug.keystore`,
|
||||
the debug key that ships with the React Native template. Its password is
|
||||
@@ -199,6 +200,44 @@ Once you delete it, every APK is signed with the release key alone:
|
||||
the update. Those users have to uninstall, reinstall and sign in again, and
|
||||
they lose local data. Afterwards their devices trust only the release key.
|
||||
|
||||
## Google Play
|
||||
|
||||
The app has two product flavors (`android/app/build.gradle`). They install
|
||||
side by side as separate apps:
|
||||
|
||||
| Flavor | Package | Output | Updated by | Firebase project |
|
||||
|---|---|---|---|---|
|
||||
| `github` | `com.anonymous.bulwarkmobile` | APK on GitHub Releases | in-app updater | `bulwark-mobile` (`src/github/google-services.json`) |
|
||||
| `play` | `org.bulwarkmail.mobile` | AAB for Google Play | Google Play | `bulwark-20c72` (`google-services.json`) |
|
||||
|
||||
Play forbids an app updating itself outside Play, so the `play` flavor has no
|
||||
`REQUEST_INSTALL_PACKAGES` (only `src/github/AndroidManifest.xml` declares it)
|
||||
and `BuildConfig.DISTRIBUTION` is `"play"`, which the `BulwarkBuild` native
|
||||
module hands to JS. `supportsSideloadUpdates` in `src/lib/platform-capabilities.ts`
|
||||
is false there, which hides the updater. Neither flavor declares
|
||||
`READ_MEDIA_IMAGES`: photos come from the system photo picker, and Play's photo
|
||||
and video policy rejects the permission for that use. `RECORD_AUDIO`, which
|
||||
expo-camera adds, is removed as well: QR scanning never records audio.
|
||||
|
||||
Play App Signing holds the key that signs what Play delivers (generated by
|
||||
Google, certificate SHA-256 `7e:e1:fd:35:…:91:fa:e3`). The workflow signs the
|
||||
bundle with our release key, which Play registered as the upload key with the
|
||||
first upload. The two flavors therefore have different signers too.
|
||||
|
||||
The `play-bundle` job builds `bundlePlayRelease` for arm64-v8a, armeabi-v7a and
|
||||
x86_64 (Play splits the bundle per ABI), checks the signer, the package name and
|
||||
the permissions, and keeps the AAB and its R8 mapping as a workflow artifact.
|
||||
With the `PLAY_SERVICE_ACCOUNT_JSON` secret set (vault: "Google Play – service
|
||||
account bulwark-google-play-api") it also uploads the bundle and rolls it out
|
||||
on the internal and closed (beta) testing tracks, so testers get every release
|
||||
without a step in the Play Console; closed testing may wait for Google's
|
||||
review. Play only accepts API uploads once a first bundle has been uploaded by
|
||||
hand in the Play Console.
|
||||
|
||||
The `github` flavor stays on the old Firebase project until the app registers
|
||||
its new FCM token with the relay when the token changes; moving it before that
|
||||
would silence push for every updated install.
|
||||
|
||||
## Local release builds
|
||||
|
||||
CI signs releases, so you normally don't need the release key on your machine.
|
||||
@@ -206,7 +245,8 @@ For a local release build, for profiling or checking R8, opt in to the debug key
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease -PallowDebugSignedRelease=true
|
||||
./gradlew assembleGithubRelease -PallowDebugSignedRelease=true
|
||||
./gradlew bundlePlayRelease -PallowDebugSignedRelease=true
|
||||
```
|
||||
|
||||
```powershell
|
||||
@@ -252,7 +292,7 @@ retrace bulwark-mobile-0.1.63-abc1234-mapping.txt stacktrace.txt
|
||||
|
||||
`retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`).
|
||||
A local build writes its mapping to
|
||||
`android/app/build/outputs/mapping/release/mapping.txt`.
|
||||
`android/app/build/outputs/mapping/<flavor>Release/mapping.txt`.
|
||||
|
||||
## versionCode
|
||||
|
||||
|
||||
@@ -142,6 +142,20 @@ Internal testers (up to 100, must be on your App Store Connect team) get it
|
||||
immediately. External testers (up to 10,000) need a Beta App Review, which takes
|
||||
about a day the first time and is usually waved through afterwards.
|
||||
|
||||
The workflow's `public-testflight` job does the external part on its own. It
|
||||
waits for processing, adds the build to the `external` group (the one behind
|
||||
the public link in the README), sets "What to Test" to the GitHub release and
|
||||
submits it for Beta App Review (`scripts/testflight-public.mjs`). To do this by
|
||||
hand for a build, for example after a failed run:
|
||||
|
||||
```bash
|
||||
ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=path/to/AuthKey_….p8 node scripts/testflight-public.mjs 0.1.72 18 # marketing version, build number
|
||||
```
|
||||
|
||||
If App Store Connect answers `REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED` (altool
|
||||
reports it as "Cannot determine the Apple ID from Bundle ID"), the Account
|
||||
Holder has to accept an updated agreement under Business in App Store Connect.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
|
||||
@@ -175,7 +175,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
@@ -110,7 +110,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
|
||||
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
|
||||
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
|
||||
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview); since 0.1.80/0.1.81 also audio, video, SVG/ICO, HTML, Markdown, PDFs on Android (native `PdfRenderer`) and ZIP listings, through the one preview the reader, list, Files and compose share (`src/components/email/use-file-preview.ts`)
|
||||
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
|
||||
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
|
||||
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
# Native UI and UX audit, 2026-10
|
||||
|
||||
Scope: every screen, sheet, dialog, banner and gesture of the Android app, on a phone and a tablet, in
|
||||
portrait and landscape. Started from `e19885d` (0.1.75).
|
||||
|
||||
## Method
|
||||
|
||||
- Devices (emulators):
|
||||
- phone `Bulwark_Pixel7_API35`, 411x914 dp;
|
||||
- tablet `Pixel_Tablet_API_34`, 1280x800 dp.
|
||||
|
||||
The tablet's natural orientation is landscape, so `user_rotation 1` is tablet portrait.
|
||||
- Cells used below:
|
||||
|
||||
| Cell | Device and orientation |
|
||||
|---|---|
|
||||
| PP | phone portrait |
|
||||
| PL | phone landscape |
|
||||
| TL | tablet landscape |
|
||||
| TP | tablet portrait |
|
||||
|
||||
"all" means all four.
|
||||
- Build: release APK from `C:\rn`, with the portrait lock removed for the audit only.
|
||||
- Data: Stalwart 0.16 on VM 106, seeded with:
|
||||
- 520+ inbox mails, a 16-message thread, HTML-only newsletters, a wide table, all attachment types
|
||||
and an inline cid image;
|
||||
- RTL mail (Arabic, Hebrew), a read-receipt request, very long subjects and names;
|
||||
- nested folders;
|
||||
- recurring, all-day, multi-day, overlapping, declined and cancelled events, and tasks;
|
||||
- 65 contacts with and without photos, plus two groups;
|
||||
- files and folders, and an active vacation reply.
|
||||
|
||||
The phone ran as `usera@`, the tablet as a separate `uxaudit@` account with the same seed.
|
||||
- Every area was captured in all four cells in light English (tablet) and dark English (phone).
|
||||
Then a second pass ran with German and font scale 1.3, on PP and TL, with the tablet in dark.
|
||||
- Four read-only code passes (mail list, thread and compose, calendar, contacts/files/settings/login)
|
||||
gave the `file:line` references. Device findings were checked against them.
|
||||
- Screenshots are in the session scratchpad (`shots/q_<screen>.png`, a 4-cell strip: PP, PL, TL, TP;
|
||||
`shots/d_<screen>.png` for the German/1.3 pass). They are not committed.
|
||||
|
||||
## Inventory
|
||||
|
||||
### Navigation
|
||||
|
||||
- Root native stack (`App.tsx`):
|
||||
- MainTabs
|
||||
- EmailThread
|
||||
- EmailSource
|
||||
- Compose (modal)
|
||||
- ContactDetail
|
||||
- ContactForm (modal)
|
||||
- GroupDetail
|
||||
- Scheduled
|
||||
- UnifiedInbox
|
||||
- AddAccount (modal)
|
||||
- Bottom tabs: Mail, Calendar, Contacts, Files, Settings. A tab the server lacks is dimmed and does
|
||||
nothing.
|
||||
- Above the tabs:
|
||||
- UpdateBanner
|
||||
- OfflineCacheBanner
|
||||
- PushOnboardingPrompt (floating card)
|
||||
- AppIconBadge (no UI)
|
||||
- On the focused stack screen: ToastHost and UndoSnackbar (none on Compose).
|
||||
- Entry points:
|
||||
- deep links: `bulwarkmobile://`, webmail permalinks, `mailto:`, and sign-in links
|
||||
(`bulwarkmail://pair`, `connect`);
|
||||
- Android share intents into Compose;
|
||||
- notification taps (mail push, calendar reminders);
|
||||
- widgets (inbox, agenda), which deep-link.
|
||||
|
||||
### Login (`LoginScreen` + `screens/login/*`)
|
||||
|
||||
- Steps: choose → email → (discovery) confirm or server → confirm → OAuth hand-off in the browser, or
|
||||
password (+ TOTP when the server asks) → signing in.
|
||||
- Side paths:
|
||||
- QR scan modal (camera permission, blocked → open settings);
|
||||
- paste-link modal;
|
||||
- external sign-in link confirm alert;
|
||||
- refused-link notice;
|
||||
- Add account mode with a close button.
|
||||
|
||||
### Mail
|
||||
|
||||
- **Mail list** (`EmailListScreen`):
|
||||
- Header: menu, folder title, import, logo.
|
||||
- Search row: select-all, search with recent searches and people suggestions, sort toggle,
|
||||
filter.
|
||||
- Filter chips.
|
||||
- Banners: empty folder, offline, failed outbox, unreachable accounts.
|
||||
- States: loading, error + retry, no mailboxes, empty / no results.
|
||||
- Gestures: pull to refresh, endless scroll, long-press to select, swipe actions (instant or
|
||||
reveal).
|
||||
- Selection header: star, read, tag, move, spam, archive, delete.
|
||||
- Compose FAB.
|
||||
- Sheets: filter modal (with date pickers), MoveSheet, TagSheet, attachment preview.
|
||||
- **Sidebar drawer** (`SidebarDrawer`):
|
||||
- Account switcher and account menu: add, set default, sign out, sign out of all.
|
||||
- Unified Inbox, Scheduled.
|
||||
- Unified Mailbox section, folders tree with unread counts and long-press folder sheet, shared
|
||||
accounts, tags.
|
||||
- Nested ActionSheet and NamePrompt.
|
||||
- **Unified inbox** (`UnifiedInboxScreen`): its own header, search, selection, swipe, account dots,
|
||||
pull to refresh, load more.
|
||||
- **Scheduled** (`ScheduledScreen`): list, per-item actions (send now, reschedule, edit, cancel),
|
||||
reschedule presets, custom date-time picker.
|
||||
|
||||
### Thread view (`EmailThreadScreen` + `components/email/*`)
|
||||
|
||||
- Layout:
|
||||
- Horizontal pager over the list.
|
||||
- Toolbar: back, delete, archive (≥400 dp), unread (≥340 dp), star, more.
|
||||
- Subject with star.
|
||||
- Single message, or conversation cards (collapsed rows, expanded cards with Reply / Reply all /
|
||||
Forward pills).
|
||||
- Message header: sender and recipients open the address sheet; "Show details".
|
||||
- Attachments: preview, open, save and share; "Download all".
|
||||
- Banners: blocked images, truncated body, read receipt, unsubscribe, calendar invitation (RSVP,
|
||||
add to calendar).
|
||||
- Quick reply box.
|
||||
- Bottom bar: Prev, three configurable actions, Next.
|
||||
- More sheet: archive, read, move, tag, spam, view mode, sender, forward as attachment, source,
|
||||
export.
|
||||
- Email source screen.
|
||||
|
||||
### Compose (`ComposeScreen`)
|
||||
|
||||
- Header: close, title + draft status, attach, schedule, send.
|
||||
- Fields:
|
||||
- From (IdentitySheet, From options);
|
||||
- To / Cc / Bcc with chips, autocomplete and a chip menu;
|
||||
- Subject.
|
||||
- Attachments: sheet with photos, camera, files; chips with progress.
|
||||
- Body: rich editor WebView or plain text.
|
||||
- Format bar: templates, read receipt, plain/rich, B/I/U/S, colours, H1/H2, lists, quote, table,
|
||||
alignment, link, image, clear, undo/redo.
|
||||
- Modals: link, colour palette, table, schedule sheet, date-time pickers, template placeholders,
|
||||
TemplateSheet.
|
||||
- Flows:
|
||||
- draft autosave;
|
||||
- close guard (cancel / discard / save draft);
|
||||
- empty-subject and forgotten-attachment checks;
|
||||
- undo send through UndoSnackbar;
|
||||
- scheduled-send confirmation.
|
||||
|
||||
### Calendar (`CalendarScreen` + `components/calendar/*`)
|
||||
|
||||
- Views: month (free scroll or paged), week (free scroll or paged), 3 days, day, schedule (agenda).
|
||||
- Header: menu, month title with month dropdown, prev/next (paged only), today, tasks.
|
||||
- FAB: event, or a menu with Event / Task.
|
||||
- Sheets and dialogs:
|
||||
- EventDetailSheet: RSVP, edit, duplicate, .ics, link, delete.
|
||||
- EventModal (editor): RecurrenceEditor, ParticipantInput, pickers.
|
||||
- RecurrenceScopeDialog.
|
||||
- CalendarSidebarDrawer: views, refresh, calendars with a long-press panel, new calendar,
|
||||
subscriptions, import.
|
||||
- TasksSheet, ICalImportSheet, ICalSubscriptionSheet, CalendarEditSheet, CalendarShareSheet.
|
||||
- Gestures:
|
||||
- tap a slot for a draft, then tap the draft to create;
|
||||
- long-press a slot to create;
|
||||
- tap a month cell to open the day;
|
||||
- free scroll with snapping.
|
||||
- There is no drag-to-move or drag-to-resize, so there is nothing to break mid-drag.
|
||||
|
||||
### Contacts
|
||||
|
||||
- `ContactsScreen`:
|
||||
- Header: drawer, count, search, import, add (long-press: contact or group).
|
||||
- Address book / group chips.
|
||||
- Long-press to select.
|
||||
- Selection header: select all, export, move, tag, delete.
|
||||
- Pull to refresh.
|
||||
- `ContactsSidebarDrawer`: books, shared books, groups, tags with rename.
|
||||
- `ContactDetailScreen`:
|
||||
- hero and quick actions (email, call, SMS, share);
|
||||
- sections: email, phone, address, work, personal, online, keys, categories, related, members,
|
||||
notes, address book;
|
||||
- recent mail and events;
|
||||
- More sheet (duplicate, add to group, move, export, delete).
|
||||
- `ContactFormScreen`: person, organisation or group, with many collapsible sections and a date
|
||||
picker.
|
||||
- `GroupDetailScreen`: members, add or remove.
|
||||
- Sheets: AddressBookPicker, TagAssign, ContactPicker, ContactImport.
|
||||
|
||||
### Files (`FilesScreen`)
|
||||
|
||||
- Header: back, search, new folder, upload, grid/list.
|
||||
- Breadcrumb, sort chip, upload progress.
|
||||
- List or 3-column grid; long-press to select (download zip, delete).
|
||||
- Per-item ⋮ sheet: preview, open with, download, share, move, duplicate, rename, delete.
|
||||
- Folder picker, sort sheet, prompt modal, share sheet, preview modal.
|
||||
|
||||
### Settings (`SettingsScreen` + `components/settings/*`)
|
||||
|
||||
- One screen that swaps between a searchable section list and a page.
|
||||
- Pages:
|
||||
- account, language, notifications
|
||||
- appearance, layout
|
||||
- reading, composing, identities, vacation, filters (rule modal, sieve editor), templates,
|
||||
folders, tags, downloads
|
||||
- security, encryption (disabled), content & senders
|
||||
- calendar and contacts (with device sync), files, sidebar apps
|
||||
- about & data, themes, plugins, updates
|
||||
- Sign out.
|
||||
|
||||
### Other
|
||||
|
||||
- PushOnboardingPrompt, OfflineBanner, OfflineCacheBanner, UpdateBanner.
|
||||
- Toasts: swipe to dismiss, action button.
|
||||
- UndoSnackbar: undo, send now.
|
||||
- Android widgets (out of scope for fixes): inbox and agenda.
|
||||
|
||||
## Findings
|
||||
|
||||
Severity: **P1** broken or blocking, **P2** clearly wrong or clumsy, **P3** polish. Each finding
|
||||
lists the cells where it occurs, the screenshot, the code location and the intended fix. A tick
|
||||
carries the commit that fixed it.
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
- [x] **U-001 · P1 · PL TL TP · The app is locked to portrait and has no large-screen layout.** *(fixed in bfa02f1)*
|
||||
- Where: `app.config.js:34`, `AndroidManifest.xml:28`.
|
||||
- Android 16 ignores the lock on screens 600 dp and wider, so tablets and foldables rotate anyway.
|
||||
- Fix: remove the lock in both places. Keep `configChanges` (and add `smallestScreenSize` for
|
||||
split-screen and foldables). Then do phase 2.
|
||||
- [x] **U-002 · P2 · iPad, landscape · No RN `<Modal>` sets `supportedOrientations`.** *(fixed in bfa02f1)*
|
||||
- Where: 56 modals in 40 files.
|
||||
- iOS presents these portrait-only and forces a rotation.
|
||||
- Fix: pass all orientations everywhere.
|
||||
- [x] **U-003 · P2 · TL TP · Every screen is the phone layout stretched to 1280 dp.** *(fixed in 802474d, 64a62b6, 8ab82cd, 2470e99, 0087e4c, 250c0a9)*
|
||||
- Affected: mail rows, thread body, compose, contacts, files, settings, login.
|
||||
- Screenshots: `q_mail_list`, `q_thread_receipt`, `q_contacts`, `q_settings`, `t_login_confirm`.
|
||||
- Fix: the adaptive layout plan below.
|
||||
- [x] **U-004 · P2 · TL TP · Sheets and floating cards span the full width.** *(fixed in f4ff07d)*
|
||||
- Affected: bottom sheets, toasts, the undo bar and the push prompt.
|
||||
- Screenshots: `q_event_detail`, `q_mail_list` (push prompt).
|
||||
- Fix: on medium and expanded widths, cap them at about 560 dp and centre them.
|
||||
- [x] **U-005 · P2 · PL · Left and right safe-area insets are ignored.** *(fixed in bfa02f1, f4ff07d, e19229a)* *(partly fixed in bfa02f1 (compose), f4ff07d (sheets))*
|
||||
- Most screens use `SafeAreaView edges={['top']}` and position sheets `left: 0, right: 0`.
|
||||
- A side navigation bar or a camera cutout covers the content.
|
||||
- Fix: add the side edges to screen roots, and inset sheets and the FABs.
|
||||
- [x] **U-006 · P2 · all · The Files header pads a fixed 60 px instead of the status-bar inset.** *(fixed in cbc41c9)*
|
||||
- Where: `FilesScreen.tsx:1438`.
|
||||
- It is too tall in landscape and wrong on any other status bar height.
|
||||
- [x] **U-007 · P2 · all · Uppercase section labels get clipped on Android.** *(fixed in 9e463d3)*
|
||||
- Example: "WORK" shows as "WOR", "ADDRESS BOOK" as "ADDRESS" (contact detail, English).
|
||||
- Cause: Android measures a shrink-wrapped, letter-spaced label a few pixels short, so its
|
||||
last word wraps onto a second line that the one-line height hides. (Upper-casing the string
|
||||
instead of `textTransform` made no difference.)
|
||||
- Where: `ContactDetailScreen.tsx:976`, `ContactActivity.tsx:283`.
|
||||
- Fix: let the label fill its row (`flex: 1`).
|
||||
- [x] **U-008 · P2 · all · After a cold start in German, the tab labels and the folder title stay
|
||||
English.** *(fixed in 512a28c)* The search placeholder is German. Screenshot `t_layo`.
|
||||
- Fix: re-render the tab labels and the title when the locale store hydrates.
|
||||
- [x] **U-009 · P2 · TL TP (font 1.3) · Tab labels truncate with plenty of room.** *(fixed in 802474d)*
|
||||
- Example: "E-…", "Kalen…", "Einstellu…" in 256 dp-wide tabs (`q_de_mail`).
|
||||
- The label sits beside the icon with a fixed font size and no width.
|
||||
- Fix: let the label size itself; on wide screens use the side rail (phase 2).
|
||||
- [x] **U-010 · P2 · all (font 1.3) · The mail search field clips the top of its text.** *(fixed in 7b93265)*
|
||||
- Where: fixed height in `EmailListScreen.tsx:1885-1912`.
|
||||
- Fix: use `minHeight` and vertical padding.
|
||||
- [x] **U-011 · P2 · dark · The dark palette doesn't match the webmail's standard theme.** *(fixed in b3290c3)*
|
||||
- The app uses a blue primary and zinc greys. The webmail uses a white `#fafafa` primary with
|
||||
`#171717` text, a `#0a0a0a` ground, neutral greys, `#1c1c1c` popover and a `#262626` muted
|
||||
surface.
|
||||
- Where: `src/theme/tokens.ts` `DARK_COLORS`. APP.md lists this as open.
|
||||
- Fix: port the webmail dark tokens.
|
||||
- [x] **U-012 · P2 · light · The mail header logo is invisible on the light theme.** *(fixed in 99de74d)*
|
||||
- It always loads `Bulwark Logo White.png` (`EmailListScreen.tsx:1105`). Screenshot `q_mail_list`
|
||||
TL.
|
||||
- Fix: pick the logo by the resolved theme.
|
||||
- [x] **U-013 · P2 · light · TagSheet reads the static dark palette.** *(fixed in 29c172f)*
|
||||
- Where: `TagSheet.tsx:7,101,124`. Tag colours are wrong on light.
|
||||
- Fix: use `c.tags`.
|
||||
- [x] **U-014 · P2 · all · `Button` has no `accessibilityRole` and no disabled or busy state.** *(fixed in f891bf3)*
|
||||
- Where: `Button.tsx:35`. This affects every dialog and prompt button.
|
||||
- [x] **U-015 · P2 · all · Many icon buttons are under 48 dp.** *(fixed in c8434b0,776447d)* *(partly fixed in c8434b0 (thread back, login header, Files header and row menu, account icons, switches, drawer chevron and folder icon, search clear, recipient chip, quick reply))*
|
||||
- Mail search clear 36, recent-search remove 28, filter chip remove 32, attachment chips about 22,
|
||||
drawer chevron 32.
|
||||
- Viewer: card pills about 36, collapse handle 18, quick-reply Send 28, subject star 34.
|
||||
- Compose: recipient chip X 28, Send 36, Cc/Bcc toggles 28.
|
||||
- Files: header 44, row ⋮ 26.
|
||||
- Settings: account icon buttons 28, toggle 24 tall.
|
||||
- Login: header 32, show-password 20.
|
||||
- Sheet close buttons are 28 everywhere.
|
||||
- Lists with `file:line` are in the code passes; the fix commits name the places.
|
||||
- Fix: a shared 48 dp target (box plus `hitSlop`), applied screen by screen.
|
||||
- [ ] **U-016 · P3 · all · Sheet and dialog scrims are a literal `rgba(0,0,0,0.5)` in about 20
|
||||
files.** *(deferred: all scrims are already the same black at 0.5 on both themes; a token is a refactor with no visible change)* Fix: an `overlay` token.
|
||||
- [ ] **U-017 · P3 · all · Some confirmations use the system alert, others the app's `Dialog`.** *(deferred: switching every confirmation to Dialog changes a lot of flows for a style difference only; see product questions)*
|
||||
Examples: compose discard, event delete, folder empty. This is a style mismatch.
|
||||
- [ ] **U-018 · P3 · all · Changing the system font size while the app is open recreates the activity
|
||||
and resets navigation to the mail list.** *(deferred: handling fontScale in the app (configChanges) was tried and left a mix of old and new text sizes until a restart, worse than Android's default recreate; needs RN to re-measure on a font scale change)* Text measured before the change can stay clipped until
|
||||
the next restart ("Layo" for "Layout").
|
||||
|
||||
### Mail list, drawer, unified inbox, scheduled
|
||||
|
||||
- [x] **U-020 · P2 · all · Back with an active search exits the app instead of clearing the
|
||||
search.** *(fixed in 46d3016)*
|
||||
- Repro: search "Offsite", press Enter, then Back.
|
||||
- Where: `EmailListScreen.tsx` has no back handler for search or selection.
|
||||
- Fix: `useBackWhileFocused` that clears the selection, then the search.
|
||||
- [x] **U-021 · P2 · PL · Phone landscape shows about 1.5 rows.** *(fixed in b5c4d62)*
|
||||
- The 56 dp header, the 56 dp search row and the 64 dp tab bar take most of the 411 dp.
|
||||
- The compose FAB covers the second row. Screenshot `q_mail_list` PL.
|
||||
- Fix: in compact height, merge the title and search into one row and drop the tab labels.
|
||||
- [x] **U-022 · P2 · PL (font 1.3) · Rows cut the sender to three letters and clip the time.** *(no change needed: after a restart at 1.3 the rows keep the full time and name; the clipping was stale measurement from changing the font size live, see U-018)*
|
||||
- Example: "Us…", "17:4" (`q_de_mail` PL).
|
||||
- Where: the row header in `EmailListScreen.tsx` around 158-250.
|
||||
- Fix: let the time keep its width (`flexShrink: 0`) and let the name shrink.
|
||||
- [x] **U-023 · P2 · PP · The undo bar sits on top of the compose FAB for 5 s after every archive or
|
||||
delete.** *(fixed in 50ab714)*
|
||||
- Where: `UndoSnackbar.tsx:161`, `EmailListScreen.tsx:2262`.
|
||||
- Fix: lift the FAB while the bar shows.
|
||||
- [x] **U-024 · P2 · all · Swipe and bulk actions on the list fail silently.** *(fixed in 0db4bf0)*
|
||||
- Archive, spam, read, star, move and delete are called as `void ...` with no catch, so a server
|
||||
rejection becomes an unhandled promise. The selection also stays.
|
||||
- Where: `EmailListScreen.tsx:604-651, 715-799`; `handleImport` `:492`.
|
||||
- Fix: report failures through `toast.error` as the viewer does.
|
||||
- [x] **U-025 · P2 · all · Sign out and "Sign out of all accounts" run with no confirmation.** *(fixed in 8403843)*
|
||||
- Where: drawer `SidebarDrawer.tsx:930,943`, settings `SettingsScreen.tsx:497`.
|
||||
- Fix: a destructive `Dialog`.
|
||||
- [x] **U-026 · P2 · all · The drawer's folder-settings icon only closes the drawer.** *(fixed in d97571c)*
|
||||
- Where: `SidebarDrawer.tsx:1048`, `navigate('MainTabs')`.
|
||||
- Fix: open Settings → Folders.
|
||||
- [x] **U-027 · P2 · all · The filter modal runs a query per keystroke and has no keyboard
|
||||
handling.** *(fixed in c6cb44d)*
|
||||
- Where: `EmailListScreen.tsx:1507-1550`.
|
||||
- Fix: debounce like the main search; add next/search return keys.
|
||||
- [x] **U-028 · P2 · PP PL · The unified inbox and Scheduled lists end under the gesture bar.** *(fixed in 984c359)*
|
||||
- Where: `UnifiedInboxScreen.tsx:384`, `ScheduledScreen.tsx:278` (top edge only, fixed padding).
|
||||
- [x] **U-029 · P2 · all · Unified inbox rows ignore the font-size, density and preview settings.** *(fixed in 52eb18d)*
|
||||
- Where: `UnifiedInboxScreen.tsx:576`.
|
||||
- Its load and open failures are silent (`:103-112`, `:179-206`).
|
||||
- [x] **U-030 · P3 · all · The menu button keeps a grey focus box after the drawer closes.** *(no change needed: did not reproduce after the drawer and rail changes; the button shows no focus box after closing the drawer by tap or Back)*
|
||||
Screenshot `q_search_results`.
|
||||
- [x] **U-031 · P3 · PP · The selection header holds up to 7 buttons; its count has no
|
||||
`numberOfLines`.** *(fixed in 0d96d8d)* Where: `EmailListScreen.tsx:982-1066`.
|
||||
- [x] **U-032 · P3 · all · Folders under a hidden empty parent show flattened at the top level.** *(no change needed: the tree only lifts a folder whose parent the server does not return; usera has no Clients folder, and on uxaudit Q4 nests under Clients / ACME / 2026)*
|
||||
- Example: "Q4 contracts" without Clients / ACME / 2026 (`q_drawer` PP).
|
||||
- This happens with "hide empty folders" on. To check before changing anything.
|
||||
|
||||
- [x] **U-033 · P2 · TL TP · With the tabs in the side rail, the compose FAB and list ends sit under
|
||||
the tablet taskbar.** *(fixed in b9e6ba4)* Found after the mail split view landed (802474d): a bottom tab bar used to
|
||||
keep the screens clear of the bottom inset, a side rail doesn't.
|
||||
- Fix: pad the tab area by the bottom inset when the rail is shown.
|
||||
### Thread view
|
||||
|
||||
- [x] **U-040 · P1 · PL TL TP · After a rotation the pager is misaligned.** *(fixed in bfa02f1)*
|
||||
- Half of the neighbouring page shows and the open message is cut off at the right edge.
|
||||
- It happens when rotating on the thread, and when rotating on Compose opened from it
|
||||
(`q_thread_long` PL, `q_compose_close` TL).
|
||||
- Where: `EmailThreadScreen.tsx:265-280`; the pager keeps its old pixel offset.
|
||||
- Fix: re-scroll to the active index after a width change, without animation.
|
||||
- [x] **U-041 · P2 · all · A long thread opens at the oldest message.** *(fixed in 7617064)*
|
||||
- The newest (opened) message is expanded but off screen on a phone (`q_thread_long` PP).
|
||||
- Fix: scroll the opened message into view once the cards are laid out.
|
||||
- [x] **U-042 · P1 · all · Quick reply "More options" throws away the typed text.** *(fixed in 053fde8)*
|
||||
- Where: `EmailThreadScreen.tsx:1185`.
|
||||
- Fix: pass the text as `prefillBody` to the reply composer.
|
||||
- [x] **U-043 · P2 · PL · Phone landscape leaves almost no room for the body.** *(fixed in 975ed08)*
|
||||
- Toolbar with labels, subject, sender block, attachments, banner and the labelled bottom bar
|
||||
fill the 411 dp (`q_thread_receipt` PL).
|
||||
- Fix: icon-only toolbar and bottom bar in compact height.
|
||||
- [x] **U-044 · P2 · PP (German, 1.3) · The toolbar overflows and pushes More off screen.** *(fixed in 975ed08)*
|
||||
- "Stern hinzufügen" is long (`d_de_thread`).
|
||||
- Fix: drop the toolbar labels when they don't fit; keep the a11y labels.
|
||||
- [ ] **U-045 · P3 · German · Prev/Next read "Zurück"/"Weiter".** *(deferred: the strings come from the vendored webmail catalog (locales/de/common.json); the fix belongs in the webmail's German translation)*
|
||||
- "Zurück" reads as Back. "Allen antworten" wraps in the bottom bar.
|
||||
- Fix: "Vorherige"/"Nächste" (locale), and a single line with shrinking text.
|
||||
- [x] **U-046 · P2 · all · The tag sheet doesn't scroll.** *(fixed in b59d00f)*
|
||||
- With many tags, or in landscape, rows can't be reached.
|
||||
- Where: `EmailThreadScreen.tsx:1472-1486`.
|
||||
- [x] **U-047 · P2 · TL TP · Plain-text and fluid HTML bodies run 150+ characters per line.** *(fixed in e94b782)*
|
||||
- Fix: a readable max width for the body on wide panes.
|
||||
- [x] **U-048 · P3 · all · The toolbar star always says "Star", even when the message is starred.** *(fixed in d7cee7c)*
|
||||
- Where: `EmailThreadScreen.tsx:746`.
|
||||
- [x] **U-049 · P3 · PL TL · Attachment image preview is sized from the window width, so it is
|
||||
taller than the screen in landscape.** *(fixed in 2470e99)* Where: `AttachmentPreviewModal.tsx:112`.
|
||||
|
||||
### Compose
|
||||
|
||||
- [x] **U-060 · P1 · PL · With the keyboard open in landscape, the whole compose body is blank.** *(fixed in bfa02f1)*
|
||||
- Only the header stays, so you can't see what you type (`tri.png`).
|
||||
- Where: the keyboard padding is computed from `Dimensions.get('screen').height`
|
||||
(`ComposeScreen.tsx:400-425`), which doesn't follow the rotation.
|
||||
- Fix: compute the overlap from the window, against the keyboard frame.
|
||||
- [x] **U-061 · P2 · all · An untouched reply autosaves a draft and asks "Save or discard?" on
|
||||
close.** *(fixed in e7857ef)*
|
||||
- The signature insertion counts as an edit (`d_de_compose`). Opening and closing a reply should
|
||||
leave nothing behind.
|
||||
- Where: `ComposeScreen.tsx:1116-1184`, `:1331-1444`.
|
||||
- [x] **U-062 · P2 · all · Recipient and subject fields have no return-key flow.** *(fixed in 56e1593)*
|
||||
- Fix: To/Cc/Bcc use `next`; an empty submit moves on; Subject `next` focuses the body.
|
||||
- Where: `ComposeScreen.tsx:2228-2395`.
|
||||
- [x] **U-063 · P2 · all (German, 1.3) · Field labels wrap.** *(fixed in 509583e)* "Betreff" shows as "Betref/f": the
|
||||
label column is a fixed width.
|
||||
- [x] **U-064 · P3 · PP · The subject field shows the end of a long subject.** *(fixed in 56e1593)*
|
||||
- Example: "…ning 2027: venue…". The cursor was put at the end on mount.
|
||||
- [x] **U-065 · P2 · all · Format-bar buttons have no accessibility labels.** *(fixed in af55e0a)*
|
||||
- Where: `ComposeScreen.tsx:2441-2513`; the toolbar button also has no role or state.
|
||||
- [x] **U-066 · P2 · PL · Text inputs use Android's full-screen landscape keyboard.** *(fixed in bfa02f1)*
|
||||
- The autocomplete and the composer are hidden behind it.
|
||||
- Fix: `disableFullscreenUI` on recipients, subject, plain body and quick reply.
|
||||
- [x] **U-067 · P2 · TL TP · Compose fills the tablet screen.** *(fixed in 0087e4c)* Fix: a centred panel (phase 2).
|
||||
|
||||
- [x] **U-068 · P2 · all · The signature shows two "--" lines.** *(fixed in 90170ea)* The seeded text signature starts
|
||||
with the usual "-- " delimiter and the composer adds its own separator above it (screenshot
|
||||
`t_compose_panel`).
|
||||
- Fix: don't add a separator to a signature that already starts with one.
|
||||
- [ ] **U-069 · P2 · PL · Recipient suggestions can't show in phone landscape.** *(deferred: with the keyboard up a phone in landscape has about 100 dp left; suggestions need a different design (a full-screen picker), which is a feature)* With the keyboard
|
||||
up, about 50 dp are left under the To field, so the suggestion list is hidden behind the
|
||||
keyboard. Typing a full address still works.
|
||||
### Calendar
|
||||
|
||||
- [x] **U-080 · P1 · PL · Month view shows two week rows and cuts off the rest.** *(fixed in bfa02f1)*
|
||||
- Rows keep a 72 dp minimum and the list is sized to six of them (`q_calendar_default` PL).
|
||||
- Where: `MonthScrollView.tsx:127-128,250`, `MonthView.tsx:427-438`.
|
||||
- Fix: size the list to the real height and let rows shrink.
|
||||
- [x] **U-081 · P1 · TL TP · Rotating on month or week jumps back a month, and week view goes
|
||||
blank.** *(fixed in bfa02f1)*
|
||||
- After a rotation, month view showed September instead of October. Week view showed no day
|
||||
headers and no events until Today was tapped (`q_calendar_default` TP, `q_cal_week` TL).
|
||||
- Where: `MonthScrollView` samples the visible month at a fixed 40% of a stale height;
|
||||
`TimeGridScrollView` keeps the old `scrollX` after its list remounts.
|
||||
- Fix: keep the focused date across the remount and reset the header offset.
|
||||
- [x] **U-082 · P1 · PL · The event sheet's action row (Edit, Duplicate, .ics, Delete) is below the
|
||||
screen.** *(fixed in bfa02f1)*
|
||||
- Screenshot `p_event_land`.
|
||||
- Where: `EventDetailSheet.tsx:609,643` (body `maxHeight: 400`, no `flexShrink`).
|
||||
- [x] **U-083 · P1 · all · Saving an event fails silently.** *(fixed in ab712f1)*
|
||||
- `handleSave` has `try/finally` without catch: the spinner stops, the editor stays, nothing is
|
||||
said.
|
||||
- Where: `EventModal.tsx:301-368`.
|
||||
- [x] **U-084 · P2 · all · Several deletes have no confirmation or undo.** *(fixed in 0460da5)*
|
||||
- Deleting a single event (`CalendarScreen.tsx:632`, `EventModal.tsx:371`).
|
||||
- Deleting a task (`TasksSheet.tsx:427`).
|
||||
- Unsubscribing from a feed, which also deletes its calendar (`ICalSubscriptionSheet.tsx:278`).
|
||||
- [x] **U-085 · P2 · all · Cancelling the recurrence-scope dialog after editing a recurring event
|
||||
throws the edits away.** *(fixed in 72b4f4a)* Where: `CalendarScreen.tsx:786-790`.
|
||||
- [x] **U-086 · P2 · TL TP · Wide screens get wider columns, not more days.** *(fixed in 2288d28)* 3 days stays 3 and
|
||||
day stays 1. Fix: phase 2.
|
||||
- [x] **U-087 · P2 · PL · The month dropdown is a fixed 346 dp tall and sized from the window.** *(fixed in bfa02f1)*
|
||||
- It overflows in landscape and lands on a half page after rotation.
|
||||
- Where: `MonthDropdown.tsx:23,52,72`.
|
||||
- [x] **U-088 · P3 · all · Times and dates use English formats in other languages.** *(fixed in cf7275c)*
|
||||
- Hour labels hard-code AM/PM (`TimeGridScrollView.tsx:437,640`, `WeekView.tsx:234,318`).
|
||||
- Dates use US-order patterns (`EventDetailSheet.tsx:96-108`, `EventModal.tsx:525,545`,
|
||||
`TasksSheet.tsx:212`).
|
||||
- The AM/PM labels only show with the 12-hour setting, where they are the right notation; left
|
||||
as is.
|
||||
- [x] **U-089 · P3 · all · ICalSubscriptionSheet and RecurrenceEditor are English only.** *(no change needed: both components already go through t() and every key has a German entry; the parity doc is out of date)* From
|
||||
`docs/parity/05-calendar.md`.
|
||||
- [x] **U-090 · P3 · PP (font 1.3) · Month chips show only the start time, no title.** *(fixed in 95c3163)*
|
||||
- [x] **U-091 · P3 · all · An event that starts above the visible hours shows as an untitled
|
||||
block.** *(no change needed: this is a block scrolled partly out of the top of the grid, so its title is above the viewport; Google Calendar and Apple Calendar behave the same)*
|
||||
|
||||
### Contacts
|
||||
|
||||
- [x] **U-100 · P1 · all · Deleting a contact or group crashes the detail screen.** *(fixed in 295e292)*
|
||||
- Hooks run after an early return: `ContactDetailScreen.tsx:149` before `:216`, and
|
||||
`GroupDetailScreen.tsx:58` before `:130`.
|
||||
- The delete empties the store, the screen re-renders with fewer hooks, and React throws. A
|
||||
contact removed by a sync does the same.
|
||||
- [x] **U-101 · P2 · PL · The address-book chip row is clipped at the bottom.** *(fixed in 0b37102)* Screenshot
|
||||
`q_contacts` PL.
|
||||
- [x] **U-102 · P2 · all · Back doesn't leave selection or search; it leaves the tab.** *(fixed in 756ceaa)*
|
||||
- Where: `ContactsScreen.tsx`. Files and Settings already handle this.
|
||||
- [x] **U-103 · P2 · all · The contact form's discard check only covers the on-screen back.** *(fixed in 1ebc5c6)*
|
||||
- Hardware back drops edits silently.
|
||||
- Where: `ContactFormScreen.tsx:764`. Fix: `usePreventRemove`.
|
||||
- [x] **U-104 · P2 · all · Contact form fields have no next or submit chaining.** *(fixed in 33dc3cd)* Where:
|
||||
`ContactFormScreen.tsx:1197-1283`.
|
||||
- [x] **U-105 · P2 · TL TP · List and detail are separate full-screen pages.** *(fixed in 64a62b6)* Fix: phase 2.
|
||||
|
||||
### Files
|
||||
|
||||
- [x] **U-110 · P2 · all · The grid has a fixed 3 columns.** *(fixed in 2470e99)*
|
||||
- The last row stretches its cells, so the columns don't line up. Tablet cells are huge
|
||||
(`q_files_grid`).
|
||||
- Where: `FilesScreen.tsx:989,1606`.
|
||||
- [x] **U-111 · P2 · all · Dates and times ignore the app's language and the 12/24 h setting.** *(fixed in 63eefd7)*
|
||||
- Example: "10:47 PM" next to 24 h times everywhere else.
|
||||
- Where: `FilesScreen.tsx:127-132`.
|
||||
- [x] **U-112 · P2 · all · Removing someone's share access has no confirmation.** *(fixed in d4982f6)* Where:
|
||||
`ShareSheet.tsx:213`.
|
||||
- [x] **U-113 · P2 · TL TP · There is no side-by-side preview.** *(fixed in 2470e99)* Fix: phase 2.
|
||||
|
||||
### Settings
|
||||
|
||||
- [x] **U-120 · P2 · all · Deleting a tag has no confirmation, while the reset next to it does.** *(fixed in 275d674)*
|
||||
Where: `KeywordSettings.tsx:48,122`.
|
||||
- [x] **U-121 · P2 · PP · The vacation subject input is a fixed 240 dp.** *(fixed in f22e851)*
|
||||
- It squeezes its label into five lines.
|
||||
- Where: `VacationSettings.tsx:434`; the same pattern in `AccountSecuritySettings.tsx:234`.
|
||||
- [x] **U-122 · P2 · light · The amber banner in filters and the sieve editor is unreadable on
|
||||
light.** *(fixed in 8f68d24)*
|
||||
- Light yellow text on a faint brown fill.
|
||||
- Where: `FilterSettings.tsx:515-519`, `SieveEditorSheet.tsx:208-212`.
|
||||
- [x] **U-123 · P2 · TL TP · The section list and the page replace each other.** *(fixed in 8ab82cd)* Fix: phase 2.
|
||||
- [ ] **U-124 · P3 · all · The language list is in neither name nor code order.** *(deferred: the order copies the webmail language switcher on purpose (comment in src/i18n/index.ts); change both together)* Example: Česky,
|
||||
Slovenčina, Dansk.
|
||||
|
||||
### Login
|
||||
|
||||
- [x] **U-130 · P2 · TL TP · Login steps stretch to the full width.** *(fixed in 250c0a9)*
|
||||
- Fields and buttons are 1248 dp wide (`t_login_confirm`).
|
||||
- Fix: a centred column of about 480 dp.
|
||||
- [x] **U-131 · P2 · all · Small targets: show-password 20 dp, header back 32 dp, email suggestion
|
||||
chips about 26 dp.** *(fixed in ae1ef5b,c8434b0,776447d)* *(partly fixed in ae1ef5b (show password), c8434b0 (header); email suggestion chips still small)*
|
||||
- [x] **U-132 · P2 · all · The password step's return keys don't move on.** *(fixed in ae1ef5b)*
|
||||
- Username has no `next`. With TOTP on, the password field shows Next and does nothing.
|
||||
- Where: `PasswordStep.tsx:56,78`.
|
||||
- [ ] **U-133 · P2 · all · There is no way out while "Signing in" hangs.** *(deferred: a cancel needs an abortable sign-in in the auth flow, which is more than a UI fix; see product questions)* Hardware back is
|
||||
swallowed. Where: `SigningInStep.tsx`, `LoginScreen.tsx:165`.
|
||||
|
||||
### Found in the re-walk
|
||||
|
||||
- [x] **U-134 · P3 · all · The all-day label in the day and week grid reads "All".** A one-row
|
||||
strip cut the second line of "All day" ("Ganztägig" in German). *(fixed in e452f72)*
|
||||
- [x] **U-135 · P2 · PP, split · The selection header pushed Delete off the screen.** Eight 44 dp
|
||||
buttons with 12 dp gaps don't fit 412 dp; the count also kept its screen-reader label on the
|
||||
normal header afterwards. *(fixed in 0d96d8d)*
|
||||
- [ ] **U-136 · P3 · all · The day view keeps an empty all-day strip on days without all-day
|
||||
events.** The strip takes the height of the busiest day in the loaded range so the grid doesn't
|
||||
jump while paging (comment in `TimeGridScrollView.tsx`). *(deferred: a product call between a
|
||||
steady grid and less blank space; see product questions)*
|
||||
|
||||
## Adaptive layout plan
|
||||
|
||||
**Breakpoints** come from the window width (`useWindowDimensions`), not the device type, so
|
||||
split-screen, freeform windows and foldables work too:
|
||||
|
||||
| Class | Width | Typical |
|
||||
|---|---|---|
|
||||
| compact | < 600 dp | phones in portrait, narrow split-screen |
|
||||
| medium | 600–839 dp | tablet portrait, unfolded foldables |
|
||||
| expanded | ≥ 840 dp | tablet landscape |
|
||||
|
||||
A second flag, `shortHeight` (window height < 480 dp), covers phone landscape (914x411 dp). By
|
||||
width alone that would be "expanded", but two panes and a rail in 411 dp of height are worse than
|
||||
one pane. So a short window keeps the compact layouts and only compresses chrome: icon-only bars,
|
||||
a merged header row, no tab labels.
|
||||
|
||||
A small hook, `src/lib/use-layout.ts`, returns `{ widthClass, shortHeight, isSplit }` and a pure
|
||||
`layoutFor(width, height)` that can be tested.
|
||||
|
||||
**Navigation.** The phone stack stays as it is. A split view is a layout inside a tab, not a second
|
||||
navigator:
|
||||
|
||||
- Medium and expanded put the tab bar on the left as a rail (`tabBarPosition: 'left'`, material
|
||||
variant), like the webmail's navigation rail.
|
||||
- Stack routes still exist and are still used on compact widths. On medium and expanded, the list
|
||||
screens render the detail in a right-hand pane instead of pushing, and the detail screens' bodies
|
||||
are shared components that take props instead of route params.
|
||||
- If the window narrows while a pane shows something, the pane's selection is pushed as the normal
|
||||
stack route, so nothing is lost.
|
||||
|
||||
**Per area:**
|
||||
|
||||
| Area | compact | medium | expanded |
|
||||
|---|---|---|---|
|
||||
| Mail | list → push thread | list (360 dp) + thread pane | persistent folder sidebar (280 dp) + list + thread |
|
||||
| Calendar | as today | wider grid, 3-day shows 5 days | week shows 7 wide columns, 3-day shows 7, day shows 2 |
|
||||
| Contacts | list → push detail | list + detail pane | list + detail pane |
|
||||
| Files | list / grid | grid columns from width (about 140 dp each) | grid columns from width + preview pane when a file is selected |
|
||||
| Settings | list ↔ page | section list (320 dp) + page | section list + page |
|
||||
| Compose | full screen | centred panel, max 720 dp, dimmed backdrop | same |
|
||||
| Sheets, dialogs, toasts | full width at the bottom | max 560 dp, centred | max 560 dp, centred |
|
||||
| Login | as today | centred column, max 480 dp | same |
|
||||
|
||||
The thread, contact detail and settings pages cap their reading width (about 760 dp for the
|
||||
mail body, 640 dp for forms) inside wide panes.
|
||||
|
||||
**State across rotation.** The activity keeps its state through `configChanges` (orientation,
|
||||
screen size, smallest screen size, layout). Panes keep their selection in component state or the
|
||||
store. Lists that depend on width remount with their focused index or date carried over.
|
||||
|
||||
## Final report (2026-10-15)
|
||||
|
||||
### Result
|
||||
|
||||
86 findings (U-001 to U-136; numbers were skipped where a candidate turned out to be a duplicate).
|
||||
|
||||
| Severity | Fixed | No change needed | Deferred |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | 9 | 0 | 0 |
|
||||
| P2 | 57 | 1 | 2 |
|
||||
| P3 | 7 | 4 | 6 |
|
||||
|
||||
Every logic change has a vitest test that fails without it. The full suite (3273 tests), `tsc`
|
||||
and `npm run i18n:check` pass at the end.
|
||||
|
||||
### Areas and cells after the last walk
|
||||
|
||||
PP = phone portrait, PL = phone landscape, TP = tablet portrait, TL = tablet landscape. The last
|
||||
walk ran the phone in German and dark, the tablet in English and light. Font scale 1.3 was checked
|
||||
on PL and TL after a restart.
|
||||
|
||||
| Area | PP | PL | TP | TL |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Mail list, drawer, selection | ok | ok (search in header) | ok (list + message) | ok (folders + list + message) |
|
||||
| Thread | ok | ok (icon-only toolbar) | ok (docked) | ok (docked) |
|
||||
| Compose | ok | ok (format bar hidden while typing) | ok (panel) | ok (panel) |
|
||||
| Calendar month / week / day | ok | ok | ok | ok |
|
||||
| Contacts list, detail, form | ok | ok | ok (split) | ok (split) |
|
||||
| Files and preview | ok | ok | ok (docked preview) | ok (docked preview) |
|
||||
| Settings | ok | ok | ok (split) | ok (split) |
|
||||
| Login | ok | ok | ok (centred column) | ok (centred column) |
|
||||
|
||||
### Deferred
|
||||
|
||||
- U-016 scrim token: all scrims are already the same; a refactor with no visible change.
|
||||
- U-017 system alert vs Dialog: a style mismatch only; see the questions below.
|
||||
- U-018 font size change while the app is open: handling `fontScale` in the app was tried and left
|
||||
mixed old and new text sizes, worse than Android's recreate.
|
||||
- U-045 "Zurück"/"Weiter": the strings come from the webmail's German catalog.
|
||||
- U-069 recipient suggestions in phone landscape: no room above the keyboard; needs a new picker.
|
||||
- U-124 language order: copies the webmail switcher on purpose.
|
||||
- U-133 no way out of a hung sign-in: needs an abortable sign-in in the auth flow.
|
||||
- U-136 empty all-day strip in the day view: a product call; see below.
|
||||
|
||||
### Product questions
|
||||
|
||||
1. Dark-mode links stay blue (`#60a5fa`) while the rest follows the webmail's neutral palette.
|
||||
Recommendation: keep blue; links in mail bodies need to stand out.
|
||||
2. Should every confirmation use the app's Dialog instead of the system alert (U-017)?
|
||||
Recommendation: yes, for destructive actions first, as one pass of its own.
|
||||
3. Sign-in that hangs (U-133): add a Cancel that aborts discovery and the token request.
|
||||
Recommendation: yes; it is the only screen with no way back.
|
||||
4. Day view all-day strip (U-136): keep one height across days, or size it to the day on screen?
|
||||
Recommendation: size it to the visible days once paging settles.
|
||||
5. Month view on a phone now shows titles instead of start times even with "show time" on, since
|
||||
the time filled the whole chip. Recommendation: keep; times come back where cells are wide.
|
||||
6. Files keeps a full-screen preview on phones and docks it on tablets. Recommendation: keep.
|
||||
7. The dark background of plain-text mail is fixed at `#0a0a0a` and ignores the built-in themes.
|
||||
Recommendation: take it from the theme palette.
|
||||
8. APP.md still lists the dark palette as open; the app now uses the webmail standard theme. The
|
||||
note should be updated in the branding repo.
|
||||
|
||||
### Not tested
|
||||
|
||||
- iOS and iPad: nothing was run. The risk is in the `supportedOrientations` change on every Modal,
|
||||
the keyboard tracker (built around Android's missing frame after rotation) and the safe-area
|
||||
insets in landscape.
|
||||
- Real phones and tablets, foldables (fold and unfold mid-task), split-screen on real hardware.
|
||||
- Push notifications and their deep links.
|
||||
- Home-screen widgets: they truncate German labels; not changed here.
|
||||
- The test account came from a login created for this audit ("Stalwart test – uxaudit@example.org"
|
||||
in the Agent collection), because the "Stalwart test servers" item was not visible to the agent.
|
||||
@@ -6,6 +6,10 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
|
||||
import { pushBackgroundTask } from './src/lib/push-background-task';
|
||||
import { widgetTaskHandler } from './src/widgets/task-handler';
|
||||
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';
|
||||
|
||||
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
||||
@@ -16,6 +20,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter
|
||||
await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Notification quick actions (Mark as read, Delete): run without launching the app UI.
|
||||
AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => {
|
||||
const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task');
|
||||
await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]);
|
||||
});
|
||||
|
||||
// Device sync (Android, #34): the contacts and calendar sync adapters run the
|
||||
// sync engine through this task, headless or next to the UI. The engine is
|
||||
// required on first use so an app start does not load it.
|
||||
|
||||
+4506
-3645
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user