Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f |
@@ -0,0 +1,3 @@
|
||||
# patch-package patches must keep the patched files' line endings byte for byte
|
||||
# (react-native-android-widget ships CRLF sources).
|
||||
patches/*.patch -text
|
||||
@@ -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,148 @@ 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"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.aab }}
|
||||
path: |
|
||||
${{ steps.artifact.outputs.aab }}
|
||||
${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set. Play only accepts API
|
||||
# uploads after the first bundle was uploaded by hand in the Console, and
|
||||
# releases of an app that was never published must be drafts; switch
|
||||
# `status` to `completed` after the first production release.
|
||||
- name: Upload to Google Play (internal testing, draft)
|
||||
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: org.bulwarkmail.mobile
|
||||
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||
track: internal
|
||||
status: draft
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,64 @@
|
||||
Bulwark Mobile
|
||||
Copyright (C) 2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only, with the additional permission for app store
|
||||
distribution below. That permission is provisional; see the
|
||||
reservation at its end.
|
||||
|
||||
Additional permission under GNU AGPL version 3 section 7
|
||||
|
||||
The copyright holders of this Program give you additional permission to
|
||||
convey a covered work (this Program, a modified version of it, or a work
|
||||
that contains parts of it) through an app store, even though the app
|
||||
store's terms of service, usage rules or technical measures place
|
||||
restrictions on recipients that section 10 of this License would otherwise
|
||||
not allow, such as a limit on the number of devices a copy may be installed
|
||||
on, or digital rights management applied to the copy.
|
||||
|
||||
This permission applies only if you:
|
||||
|
||||
a) make the Corresponding Source of each version you convey through the
|
||||
app store available, at no charge and under the terms of this
|
||||
License, from a network server that anyone can access;
|
||||
|
||||
b) state, in the app store listing or inside the application, where
|
||||
that Corresponding Source can be obtained; and
|
||||
|
||||
c) do not impose any further restrictions of your own on the exercise
|
||||
of the rights granted under this License.
|
||||
|
||||
"App store" means a digital distribution service for applications that is
|
||||
operated by a third party, such as the Apple App Store and TestFlight,
|
||||
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
|
||||
AppGallery.
|
||||
|
||||
This permission covers only the parts of a covered work that its copyright
|
||||
holders license under this License; it does not extend to parts licensed
|
||||
under other terms. As section 7 of this License allows, you may remove this
|
||||
permission from copies you convey and from your modified versions.
|
||||
|
||||
Reservation
|
||||
|
||||
This additional permission has not been finalized. The contributors of
|
||||
earlier parts of this Program have been asked whether they agree to it:
|
||||
https://github.com/orgs/bulwarkmail/discussions/1113
|
||||
|
||||
Until that request is settled, the permission is granted subject to this
|
||||
reservation:
|
||||
|
||||
- It applies to all contributions made on or after 30 September 2026.
|
||||
These contributions are accepted under this License together with
|
||||
this permission.
|
||||
|
||||
- It applies to parts of this Program written before that date only
|
||||
where their copyright holders have agreed to it. Parts whose
|
||||
copyright holders do not agree will be rewritten or removed.
|
||||
|
||||
This reservation will be removed once every earlier contributor has
|
||||
agreed or their parts have been replaced.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,4 +51,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
|
||||
|
||||
@@ -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"/>
|
||||
@@ -74,7 +78,9 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<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"/>
|
||||
@@ -108,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
|
||||
|
||||
@@ -14,6 +14,9 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
add(BulwarkBuildModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
/**
|
||||
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
|
||||
* task when the app goes to the background. Run from the app itself the
|
||||
* refresh stalled: React Native pauses JS timers while the activity is in
|
||||
* the background, and fetch resolves every response through a timer, so the
|
||||
* first request went out and its answer was never read. A running headless
|
||||
* task keeps timers going, and the service keeps the process from being
|
||||
* frozen before the widgets are redrawn.
|
||||
*/
|
||||
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/** JS side: src/widgets/sync.ts. */
|
||||
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkWidgets"
|
||||
|
||||
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
|
||||
@ReactMethod
|
||||
fun refreshInBackground(promise: Promise) {
|
||||
try {
|
||||
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Background start limits (the app left the foreground too long ago).
|
||||
promise.resolve(false)
|
||||
} catch (e: SecurityException) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |