Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980506fadf | ||
|
|
3e23944851 | ||
|
|
5c05c87c55 | ||
|
|
87ed125672 | ||
|
|
add45b15a8 | ||
|
|
369274d331 | ||
|
|
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,337 @@
|
|||||||
|
name: iOS simulator tests
|
||||||
|
|
||||||
|
# Builds the app for the iOS Simulator (no signing needed) and runs the
|
||||||
|
# Maestro flows in .maestro/ on iPhone and iPad simulators:
|
||||||
|
#
|
||||||
|
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
|
||||||
|
# - features: the app feature by feature (.maestro/features), on iPhone and
|
||||||
|
# iPad in light and dark, plus the iPad in landscape (split layout)
|
||||||
|
#
|
||||||
|
# Each leg signs in with its own account on the test server (e2e-* accounts
|
||||||
|
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
|
||||||
|
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
|
||||||
|
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
|
||||||
|
#
|
||||||
|
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
|
||||||
|
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
|
||||||
|
# kept out of them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
app_run_id:
|
||||||
|
description: "Reuse the simulator app from this earlier run instead of building (run id)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
legs:
|
||||||
|
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
|
||||||
|
type: string
|
||||||
|
default: all
|
||||||
|
flows:
|
||||||
|
description: "Only these flows (comma list of file names without .yaml); empty runs all"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
perf_base_run_id:
|
||||||
|
description: "perf leg: compare against the simulator app of this run (run id)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
perf_rounds:
|
||||||
|
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
|
||||||
|
type: string
|
||||||
|
default: "5"
|
||||||
|
required: false
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: e2e-ios-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPO_NO_TELEMETRY: "1"
|
||||||
|
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||||
|
# The XCTest driver takes minutes to come up on a cold runner.
|
||||||
|
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
signin: ${{ steps.plan.outputs.signin }}
|
||||||
|
features: ${{ steps.plan.outputs.features }}
|
||||||
|
steps:
|
||||||
|
- id: plan
|
||||||
|
env:
|
||||||
|
LEGS: ${{ inputs.legs || 'all' }}
|
||||||
|
run: |
|
||||||
|
node -e '
|
||||||
|
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
|
||||||
|
const signin = ["phone", "tablet"]
|
||||||
|
.map((device) => ({ name: `signin-${device}`, device }))
|
||||||
|
.filter((l) => want(l.name, "signin"));
|
||||||
|
const features = [
|
||||||
|
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
|
||||||
|
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
|
||||||
|
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
|
||||||
|
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
|
||||||
|
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
|
||||||
|
].filter((l) => want(l.name, "features"));
|
||||||
|
// Push on iOS (.maestro/push): only on request, the app needs the
|
||||||
|
// notification extension (plugins/ios-push).
|
||||||
|
if (legs.includes("push")) {
|
||||||
|
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
|
||||||
|
}
|
||||||
|
const fs = require("fs");
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||||
|
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
|
||||||
|
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
|
||||||
|
'
|
||||||
|
|
||||||
|
build:
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache CocoaPods
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Caches/CocoaPods
|
||||||
|
~/.cocoapods/repos
|
||||||
|
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: pods-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate the iOS project
|
||||||
|
env:
|
||||||
|
# Simulator device tokens belong to the APNs sandbox.
|
||||||
|
APS_ENVIRONMENT: development
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
|
||||||
|
- name: Build for the simulator
|
||||||
|
env:
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
run: |
|
||||||
|
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||||
|
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||||
|
mkdir -p e2e-build
|
||||||
|
# Release bundles the JS into the app, so no Metro server is needed.
|
||||||
|
# arm64 only: the runner is Apple silicon, and a second simulator
|
||||||
|
# architecture would double the build time.
|
||||||
|
if ! xcodebuild build \
|
||||||
|
-workspace "$WORKSPACE" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
CODE_SIGN_STYLE=Manual \
|
||||||
|
CODE_SIGN_IDENTITY=- \
|
||||||
|
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER= \
|
||||||
|
> e2e-build/xcodebuild.log 2>&1; then
|
||||||
|
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
|
||||||
|
tail -n 80 e2e-build/xcodebuild.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
|
||||||
|
# Xcode embeds the simulator entitlements the keychain needs. An
|
||||||
|
# unsigned build has none, and every expo-secure-store call fails
|
||||||
|
# with "A required entitlement isn't present".
|
||||||
|
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
|
||||||
|
echo "Simulated entitlements:"
|
||||||
|
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
|
||||||
|
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
|
||||||
|
# Packed as a tar: artifacts drop the executable bit, which would
|
||||||
|
# leave a reused app unable to launch.
|
||||||
|
tar -C build/Build/Products/Release-iphonesimulator -czf "$RUNNER_TEMP/simulator-app.tar.gz" BulwarkMobile.app
|
||||||
|
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||||
|
|
||||||
|
- name: Upload the simulator app
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}/simulator-app.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload the build log
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: xcodebuild-log
|
||||||
|
path: e2e-build/
|
||||||
|
|
||||||
|
signin:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
fromJson(needs.plan.outputs.signin).include[0] != null
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 150
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: ${{ matrix.device }}
|
||||||
|
APPEARANCE: light
|
||||||
|
ORIENTATION: portrait
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||||
|
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
|
||||||
|
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
|
||||||
|
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||||
|
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/e2e-ios-leg
|
||||||
|
with:
|
||||||
|
suite: signin
|
||||||
|
app_run_id: ${{ inputs.app_run_id }}
|
||||||
|
flows: ${{ inputs.flows }}
|
||||||
|
playwright: "true"
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
features:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
fromJson(needs.plan.outputs.features).include[0] != null
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.features) }}
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: ${{ matrix.device }}
|
||||||
|
APPEARANCE: ${{ matrix.appearance }}
|
||||||
|
ORIENTATION: ${{ matrix.orientation }}
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||||
|
E2E_ACCOUNT: ${{ matrix.account }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||||
|
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/e2e-ios-leg
|
||||||
|
with:
|
||||||
|
suite: ${{ matrix.suite || 'features' }}
|
||||||
|
app_run_id: ${{ inputs.app_run_id }}
|
||||||
|
flows: ${{ inputs.flows }}
|
||||||
|
playwright: "false"
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
|
||||||
|
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
|
||||||
|
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
|
||||||
|
# them on one simulator so both sides share the runner.
|
||||||
|
perf:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
contains(inputs.legs, 'perf')
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: phone
|
||||||
|
APPEARANCE: light
|
||||||
|
ORIENTATION: portrait
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_ACCOUNT: e2e-bulk
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download the app under test (this run)
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}/cand
|
||||||
|
- name: Download the app under test (earlier run)
|
||||||
|
if: inputs.app_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.app_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}/cand
|
||||||
|
- name: Download the baseline app
|
||||||
|
if: inputs.perf_base_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.perf_base_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}/base
|
||||||
|
|
||||||
|
- name: Unpack
|
||||||
|
run: |
|
||||||
|
for side in cand base; do
|
||||||
|
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
|
||||||
|
mkdir -p "$RUNNER_TEMP/$side/app"
|
||||||
|
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
|
||||||
|
done
|
||||||
|
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Boot a simulator
|
||||||
|
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||||
|
|
||||||
|
- name: Install Maestro
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
|
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sign in once
|
||||||
|
run: |
|
||||||
|
mkdir -p "$OUT/screenshots"
|
||||||
|
cd "$OUT/screenshots"
|
||||||
|
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
|
||||||
|
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
|
||||||
|
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
|
||||||
|
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
|
||||||
|
|
||||||
|
- name: Measure
|
||||||
|
run: |
|
||||||
|
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
|
||||||
|
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
|
||||||
|
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
|
||||||
|
fi
|
||||||
|
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 3 \
|
||||||
|
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
|
||||||
|
|
||||||
|
- name: Upload results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-ios-perf
|
||||||
|
path: e2e-output/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Release Android APK
|
name: Release Android
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew assembleRelease --no-daemon
|
./gradlew assembleGithubRelease --no-daemon
|
||||||
|
|
||||||
- name: Put apksigner on the PATH
|
- name: Put apksigner on the PATH
|
||||||
run: |
|
run: |
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
||||||
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
||||||
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
||||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||||
apksigner sign \
|
apksigner sign \
|
||||||
--v4-signing-enabled false \
|
--v4-signing-enabled false \
|
||||||
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||||
@@ -210,7 +210,7 @@ jobs:
|
|||||||
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||||
apksigner verify --verbose --print-certs "$APK"
|
apksigner verify --verbose --print-certs "$APK"
|
||||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||||
@@ -248,12 +248,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
SRC=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||||
cp "$SRC" "$DEST"
|
cp "$SRC" "$DEST"
|
||||||
sha256sum "$DEST" > "$DEST.sha256"
|
sha256sum "$DEST" > "$DEST.sha256"
|
||||||
|
|
||||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt
|
||||||
MAPPING=""
|
MAPPING=""
|
||||||
if [ -f "$MAPPING_SRC" ]; then
|
if [ -f "$MAPPING_SRC" ]; then
|
||||||
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||||
@@ -299,12 +299,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
|
# A manual run with no tag only builds: it must not replace the APK
|
||||||
|
# of the latest release with one from another commit.
|
||||||
if [ "${{ github.event_name }}" = "release" ]; then
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
TAG="${{ github.event.release.tag_name }}"
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
|
||||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
|
||||||
else
|
else
|
||||||
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||||
fi
|
fi
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
if [ -n "$TAG" ]; then
|
if [ -n "$TAG" ]; then
|
||||||
@@ -319,3 +319,148 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||||
files: ${{ steps.artifact.outputs.files }}
|
files: ${{ steps.artifact.outputs.files }}
|
||||||
|
|
||||||
|
# The Google Play bundle (docs/android-release.md, "Google Play"). It is
|
||||||
|
# signed with the release key, which Play knows as the upload key; Play
|
||||||
|
# re-signs what it delivers with its own app signing key. A separate job so
|
||||||
|
# the GitHub APK isn't held up by the three-ABI native build.
|
||||||
|
play-bundle:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 75
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
steps:
|
||||||
|
- name: Require the release signing secrets
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
missing=""
|
||||||
|
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
|
||||||
|
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
|
||||||
|
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Decode and check the upload key
|
||||||
|
id: key
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||||
|
CERT="$RUNNER_TEMP/release-cert.der"
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||||
|
keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||||
|
-storepass:env KEYSTORE_PASSWORD -file "$CERT"
|
||||||
|
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
|
||||||
|
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then
|
||||||
|
echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Play splits a bundle per ABI, so the extra architectures cost a phone
|
||||||
|
# nothing and let 32-bit ARM phones and x86_64 Chromebooks install it.
|
||||||
|
- name: Build the Play bundle
|
||||||
|
working-directory: android
|
||||||
|
env:
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
|
||||||
|
|
||||||
|
# Play rejects a bundle signed with another key than the registered
|
||||||
|
# upload key, and its policies reject a self-updater and the media
|
||||||
|
# permission. Catch all three here instead of in the Play Console.
|
||||||
|
- name: Check the bundle
|
||||||
|
env:
|
||||||
|
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab
|
||||||
|
SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')
|
||||||
|
echo "Bundle signer SHA-256: $SIGNER"
|
||||||
|
if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then
|
||||||
|
echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n')
|
||||||
|
if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then
|
||||||
|
echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do
|
||||||
|
if grep -q "android.permission.$permission" <<< "$MANIFEST"; then
|
||||||
|
echo "::error title=Permission not allowed on Play::The Play bundle declares $permission."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Name the bundle and R8 mapping with version
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab"
|
||||||
|
MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||||
|
cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB"
|
||||||
|
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||||
|
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ steps.artifact.outputs.aab }}
|
||||||
|
path: |
|
||||||
|
${{ steps.artifact.outputs.aab }}
|
||||||
|
${{ steps.artifact.outputs.mapping }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set. Play only accepts API
|
||||||
|
# uploads after the first bundle was uploaded by hand in the Console, and
|
||||||
|
# releases of an app that was never published must be drafts; switch
|
||||||
|
# `status` to `completed` after the first production release.
|
||||||
|
- name: Upload to Google Play (internal testing, draft)
|
||||||
|
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: org.bulwarkmail.mobile
|
||||||
|
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||||
|
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||||
|
track: internal
|
||||||
|
status: draft
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ jobs:
|
|||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.export.outputs.version }}
|
||||||
|
uploaded: ${{ steps.upload.outputs.done }}
|
||||||
env:
|
env:
|
||||||
EXPO_NO_TELEMETRY: "1"
|
EXPO_NO_TELEMETRY: "1"
|
||||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||||
@@ -56,9 +59,6 @@ jobs:
|
|||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Generate the iOS project
|
|
||||||
run: npx expo prebuild --platform ios --clean
|
|
||||||
|
|
||||||
- name: Check for signing credentials
|
- name: Check for signing credentials
|
||||||
id: creds
|
id: creds
|
||||||
env:
|
env:
|
||||||
@@ -66,14 +66,69 @@ jobs:
|
|||||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||||
|
if [ -z "$NSE_PROFILE" ]; then
|
||||||
|
# The notification service extension (plugins/ios-push) is its
|
||||||
|
# own App ID and needs its own App Store profile.
|
||||||
|
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Install the provisioning profiles
|
||||||
|
if: steps.creds.outputs.signed == 'true'
|
||||||
|
id: profiles
|
||||||
|
env:
|
||||||
|
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
# Xcode moved the profile directory in 16; write both so this does not
|
||||||
|
# silently stop working on a runner image bump.
|
||||||
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
|
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||||
|
|
||||||
|
# One App Store profile per target: the app and its notification
|
||||||
|
# service extension (plugins/ios-push).
|
||||||
|
for side in app nse; do
|
||||||
|
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
|
||||||
|
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
|
||||||
|
echo "$DATA" | base64 -d > "$PROFILE"
|
||||||
|
|
||||||
|
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||||
|
# before anything inside it can be read.
|
||||||
|
PLIST=$(security cms -D -i "$PROFILE")
|
||||||
|
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||||
|
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||||
|
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
|
||||||
|
# ID from the profile keeps ExportOptions honest if app.config.js
|
||||||
|
# changes.
|
||||||
|
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||||
|
BUNDLE_ID="${APP_ID#*.}"
|
||||||
|
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||||
|
|
||||||
|
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||||
|
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||||
|
|
||||||
|
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Generate the iOS project
|
||||||
|
env:
|
||||||
|
# plugins/ios-push names each target's profile: the command line
|
||||||
|
# can't, it would give every target the same one.
|
||||||
|
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
|
||||||
|
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
|
||||||
- name: Resolve workspace and scheme
|
- name: Resolve workspace and scheme
|
||||||
id: xcode
|
id: xcode
|
||||||
run: |
|
run: |
|
||||||
@@ -138,38 +193,6 @@ jobs:
|
|||||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||||
|
|
||||||
- name: Install the provisioning profile
|
|
||||||
if: steps.creds.outputs.signed == 'true'
|
|
||||||
id: profile
|
|
||||||
env:
|
|
||||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
|
||||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
|
||||||
|
|
||||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
|
||||||
# before anything inside it can be read.
|
|
||||||
PLIST=$(security cms -D -i "$PROFILE")
|
|
||||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
|
||||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
|
||||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
|
||||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
|
||||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
|
||||||
BUNDLE_ID="${APP_ID#*.}"
|
|
||||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
|
||||||
|
|
||||||
# Xcode moved the profile directory in 16; write both so this does not
|
|
||||||
# silently stop working on a runner image bump.
|
|
||||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
|
||||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
|
||||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
|
||||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
|
||||||
|
|
||||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
|
||||||
|
|
||||||
- name: Archive
|
- name: Archive
|
||||||
if: steps.creds.outputs.signed == 'true'
|
if: steps.creds.outputs.signed == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -189,8 +212,7 @@ jobs:
|
|||||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||||
CODE_SIGN_STYLE=Manual \
|
CODE_SIGN_STYLE=Manual \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
CODE_SIGN_IDENTITY="Apple Distribution"
|
||||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
|
||||||
|
|
||||||
# Signing failures report a symptom ("no profiles found") rather than the
|
# Signing failures report a symptom ("no profiles found") rather than the
|
||||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||||
@@ -212,8 +234,10 @@ jobs:
|
|||||||
id: export
|
id: export
|
||||||
env:
|
env:
|
||||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
|
||||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
|
||||||
|
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
|
||||||
|
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
|
||||||
run: |
|
run: |
|
||||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -227,6 +251,7 @@ jobs:
|
|||||||
<key>provisioningProfiles</key>
|
<key>provisioningProfiles</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||||
|
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>uploadSymbols</key><true/>
|
<key>uploadSymbols</key><true/>
|
||||||
<key>destination</key><string>export</string>
|
<key>destination</key><string>export</string>
|
||||||
@@ -254,6 +279,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Upload to TestFlight
|
- name: Upload to TestFlight
|
||||||
|
id: upload
|
||||||
# The IPA is not attached to the GitHub release: without an Apple device
|
# 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
|
# provisioned for it, it is not installable, and TestFlight is the only
|
||||||
# distribution channel for it.
|
# distribution channel for it.
|
||||||
@@ -271,4 +297,35 @@ jobs:
|
|||||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
--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."
|
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,71 @@
|
|||||||
|
# Opening messages: plain text, HTML with blocked remote content, a thread,
|
||||||
|
# and a PDF attachment in the in-app preview.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Read messages
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
|
||||||
|
# Plain text
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Grüße aus München
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Jürgen.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: read-01-plain
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# HTML newsletter
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*5 new articles.*|Weekly digest"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: read-02-html
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Load images"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Load images"
|
||||||
|
- takeScreenshot: read-03-html-images
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# Attachment and its preview
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Q3 report
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*q3-report.pdf.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: read-04-attachment
|
||||||
|
- tapOn: ".*q3-report.pdf.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Share"
|
||||||
|
timeout: 30000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: read-05-attachment-preview
|
||||||
|
- assertVisible: "q3-report.pdf"
|
||||||
|
- tapOn: "Close"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# A thread (Projects/Alpha)
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Alpha
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Project Alpha kickoff
|
||||||
|
- takeScreenshot: read-06-thread
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Compose a new message and send it to userb; userb's mailbox (checked over
|
||||||
|
# JMAP) must get it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Compose and send
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- tapOn:
|
||||||
|
text: "Subject"
|
||||||
|
index: 1
|
||||||
|
- inputText: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
BODY: "Hello from the iOS simulator test."
|
||||||
|
- takeScreenshot: compose-01-filled
|
||||||
|
|
||||||
|
# The attach sheet offers its options one by one (it used to be a single
|
||||||
|
# accessibility element). The system photo and file pickers run in another
|
||||||
|
# process that XCTest can't drive, so a picked attachment is covered by the
|
||||||
|
# forward in 04-incoming-reply-forward instead.
|
||||||
|
- tapOn: "Attach"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Photos & Videos"
|
||||||
|
timeout: 10000
|
||||||
|
- assertVisible: "Camera"
|
||||||
|
- assertVisible: "Files"
|
||||||
|
- takeScreenshot: compose-02-attach-sheet
|
||||||
|
- tapOn: "Photos & Videos"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-03-photo-picker
|
||||||
|
# Dismiss the picker sheet.
|
||||||
|
- swipe:
|
||||||
|
start: 50%,12%
|
||||||
|
end: 50%,95%
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "New Message"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,12%
|
||||||
|
end: 50%,95%
|
||||||
|
duration: 400
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn: "Send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Incoming mail while the app is open (sent by userb over JMAP), then reply
|
||||||
|
# and forward it; userb receives both, the forward with the attachment.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Incoming, reply, forward
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1
|
||||||
|
# Arrives without a pull to refresh (push over EventSource).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.incoming}.*"
|
||||||
|
timeout: 90000
|
||||||
|
- takeScreenshot: incoming-01-arrived
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.incoming}
|
||||||
|
- takeScreenshot: incoming-02-open
|
||||||
|
|
||||||
|
# Reply
|
||||||
|
- tapOn:
|
||||||
|
text: "Reply"
|
||||||
|
index: 0
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Re: ${output.incoming}"
|
||||||
|
timeout: 15000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
BODY: "Reply from the iOS simulator test."
|
||||||
|
- takeScreenshot: incoming-03-reply
|
||||||
|
- tapOn: "Send"
|
||||||
|
# The toast is gone after a few seconds; delivery is checked below.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: "Re: ${output.incoming}"
|
||||||
|
|
||||||
|
# Forward with the attachment
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Forward"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Forward"
|
||||||
|
index: 0
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Fwd: ${output.incoming}"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- takeScreenshot: incoming-04-forward
|
||||||
|
- tapOn: "Send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: "Fwd: ${output.incoming}"
|
||||||
|
ATTACHMENT: e2e-attachment.pdf
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
@@ -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,36 @@
|
|||||||
|
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
|
||||||
|
# right toggles read.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Swipe actions and undo
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- swipe:
|
||||||
|
from: ".*Grüße aus München.*"
|
||||||
|
direction: LEFT
|
||||||
|
duration: 400
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email archived"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: swipe-01-archived
|
||||||
|
- assertNotVisible: ".*Grüße aus München.*"
|
||||||
|
- tapOn: "UNDO"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Grüße aus München.*"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: swipe-02-undone
|
||||||
|
|
||||||
|
- swipe:
|
||||||
|
from: ".*Grüße aus München.*"
|
||||||
|
direction: RIGHT
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- takeScreenshot: swipe-03-read-toggled
|
||||||
|
- swipe:
|
||||||
|
from: ".*Grüße aus München.*"
|
||||||
|
direction: RIGHT
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Message actions with undo: move to a folder, delete to Trash, select and
|
||||||
|
# star from the list.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Move, delete, select
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
|
||||||
|
# Move via More → Move to...
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- tapOn: "more"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "More actions"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: actions-01-more
|
||||||
|
- tapOn: "Move to..."
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Move to..."
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Projects"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Moved to Projects"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: actions-02-moved
|
||||||
|
- tapOn: "UNDO"
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# Delete (to Trash) and undo
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- takeScreenshot: actions-03a-before-delete
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- takeScreenshot: actions-03b-after-delete
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email moved to Trash"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: actions-03-deleted
|
||||||
|
- tapOn: "UNDO"
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# Select from the list and star, then unstar
|
||||||
|
- longPressOn: ".*Grüße aus München.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Clear selection"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: actions-04-selected
|
||||||
|
- tapOn: "Star"
|
||||||
|
# A bulk action ends selection mode by itself.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
|
- longPressOn: ".*Grüße aus München.*"
|
||||||
|
- tapOn: "Unstar|Star"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Scheduled send: schedule a message for later, find it under Scheduled,
|
||||||
|
# cancel it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Scheduled send
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- tapOn:
|
||||||
|
text: "Subject"
|
||||||
|
index: 1
|
||||||
|
- inputText: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
BODY: "Scheduled from the iOS simulator test."
|
||||||
|
- tapOn: "Schedule send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Tomorrow morning.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: scheduled-01-options
|
||||||
|
- tapOn: "Tomorrow morning.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-02-confirmed
|
||||||
|
- tapOn: "OK"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Scheduled
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-03-list
|
||||||
|
- tapOn: ".*${output.subject}.*"
|
||||||
|
- tapOn: "Cancel send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Keep scheduled"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Cancel send"
|
||||||
|
below: "Keep scheduled|.*will not be delivered.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.subject}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-04-cancelled
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Calendar: the views, create an event with a reminder, edit it, delete it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Calendar
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
# The first visit asks for notification permission (event reminders).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: calendar-01-month
|
||||||
|
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Schedule"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-02-schedule
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Day"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-03-day
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Week"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-04-week
|
||||||
|
|
||||||
|
# Create
|
||||||
|
- tapOn: "New event"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Save"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Title"
|
||||||
|
- inputText: ${output.event}
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Add reminder"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Add reminder"
|
||||||
|
- tapOn: "15 minutes before"
|
||||||
|
- takeScreenshot: calendar-05-editor
|
||||||
|
- tapOn: "Save"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "New event"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# Find it in the schedule and open it
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Schedule"
|
||||||
|
- tapOn: "Go to today"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${output.event}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: ".*${output.event}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Edit"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: calendar-06-detail
|
||||||
|
- assertVisible: ".*15 minutes before.*"
|
||||||
|
|
||||||
|
# Edit: add a location (an empty field, so no cursor games in the title)
|
||||||
|
- tapOn: "Edit"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Edit event"
|
||||||
|
timeout: 10000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Place or address"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Place or address"
|
||||||
|
- inputText: "Room 42"
|
||||||
|
- tapOn: "Save"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Room 42.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: calendar-07-edited
|
||||||
|
|
||||||
|
# Delete (the detail sheet is still open after saving)
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Delete"
|
||||||
|
commands:
|
||||||
|
- tapOn: ".*${output.event}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete this event.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Delete this event.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.event}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: calendar-08-deleted
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -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,58 @@
|
|||||||
|
# Files: open a folder, preview a text and a PDF file, create and delete a
|
||||||
|
# folder.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Files
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-files
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Documents.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: files-01-root
|
||||||
|
- tapOn: ".*Documents.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*readme.txt.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: ".*readme.txt.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "readme.txt"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: files-02-text-preview
|
||||||
|
- tapOn: "Close"
|
||||||
|
- tapOn: ".*q3-report.pdf.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "q3-report.pdf"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: files-03-pdf-preview
|
||||||
|
- tapOn: "Close"
|
||||||
|
- tapOn: "Back"
|
||||||
|
|
||||||
|
- tapOn: "New Folder"
|
||||||
|
- tapOn: "Folder name"
|
||||||
|
- inputText: ${output.folder}
|
||||||
|
- tapOn: "Create"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.folder}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: files-04-folder-created
|
||||||
|
- longPressOn: ".*${output.folder}.*"
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete this item.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Delete this item.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.folder}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Every settings page opens and shows its content.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Settings pages
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Search settings"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-00-list
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", PROOF: "Date Format"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", PROOF: "Email Notifications"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", PROOF: "Choose your preferred color scheme"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", PROOF: "Tune the email list interactions for mobile."}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", PROOF: "Mark as Read"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", PROOF: "Composer"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", PROOF: "Sending Identities"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", PROOF: "Reply automatically"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", PROOF: "Email Filters"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", PROOF: "Email Templates"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", PROOF: ".*Inbox.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", PROOF: "Email Tags"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", PROOF: "Email .*eml.* filename"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", PROOF: "Account Security|Detecting server capabilities.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", PROOF: "External Content"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", PROOF: "Default view"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", PROOF: "Group by first letter"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", PROOF: "Default View"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", PROOF: "Keep Apps Loaded"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", PROOF: "The default light and dark palettes."}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", PROOF: "Offline mail.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", PROOF: "This device"}}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -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,69 @@
|
|||||||
|
# Push on iOS: the relay's payload carries only ids; the notification service
|
||||||
|
# extension (plugins/ios-push) fetches sender and subject before the banner
|
||||||
|
# shows. A tap opens the message, with the app running and from a cold start.
|
||||||
|
# The helper sends the message as userb and pushes with simctl, so this needs
|
||||||
|
# no APNs key.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Push notification on iOS
|
||||||
|
tags:
|
||||||
|
- push
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
# Without notification permission iOS drops the push silently, and the
|
||||||
|
# launch-time grant doesn't reach it on these simulators: let the app ask
|
||||||
|
# (the Calendar tab does, for event reminders) and allow.
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- pressKey: Home
|
||||||
|
|
||||||
|
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject)}
|
||||||
|
# The banner shows for a few seconds: catch it with either text, then check.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject}.*|.*New message.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: push-01-banner
|
||||||
|
# Sender and subject come from the extension, not the relay.
|
||||||
|
- assertVisible: ".*User B.*"
|
||||||
|
- assertNotVisible: ".*New message.*"
|
||||||
|
- tapOn: ".*${output.subject}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 60000
|
||||||
|
- assertVisible: ".*${output.subject}.*"
|
||||||
|
- takeScreenshot: push-02-opened-warm
|
||||||
|
|
||||||
|
# Cold start: the app isn't running when the notification is tapped.
|
||||||
|
- stopApp
|
||||||
|
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject2)}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject2}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: push-03-banner-cold
|
||||||
|
- tapOn: ".*${output.subject2}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 90000
|
||||||
|
- assertVisible: ".*${output.subject2}.*"
|
||||||
|
- takeScreenshot: push-04-opened-cold
|
||||||
@@ -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,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,19 @@
|
|||||||
|
# In the composer: type BODY into the editor. The rich editor is a WebView
|
||||||
|
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
|
||||||
|
# the screen, which is the body area below Subject (above the keyboard).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Write your message.*"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${BODY}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# A sign-in link from another app asks first. Continue.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
# simctl openurl acts like a link from another app: iOS asks first.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
|
||||||
|
timeout: ${LINK_WAIT || 60000}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Open"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a link.|Open the sign-in page of.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: link-confirm-prompt
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Sign in with a link."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# iOS shows a one-time slide-to-type tip over the keyboard on a fresh
|
||||||
|
# simulator. The workflow turns it off, but dismiss it if it still shows up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Speed up your typing by sliding.*"
|
||||||
|
commands:
|
||||||
|
# The sign-in screen has its own Continue button; take the tip's.
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Speed up your typing by sliding.*"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Some routes hand the app a password for a TOTP account; the app then asks
|
||||||
|
# for a code itself. Answer it when it shows up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code|Inbox"
|
||||||
|
timeout: 90000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Authentication code"
|
||||||
|
commands:
|
||||||
|
- runFlow: two-factor.yaml
|
||||||
@@ -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,22 @@
|
|||||||
|
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
|
||||||
|
# one-time prompts on the way.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: tab-mail
|
||||||
|
timeout: 90000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Not now"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Not now"
|
||||||
|
# Adding an account from Settings returns to Settings.
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
|
# Rows are one element: sender, subject and preview together.
|
||||||
|
- assertVisible: ${INBOX_TEXT || '.*Your verification code.*'}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# From the sign-in choices: enter a server address by hand and continue to the
|
||||||
|
# confirm step. env HOST: the address as a user types it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: "Enter server details manually"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${HOST}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
@@ -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,13 @@
|
|||||||
|
# From a message list: open the message whose row mentions SUBJECT.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: ".*${SUBJECT}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
@@ -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,24 @@
|
|||||||
|
# Settings list: scroll to the row ROW (regex on "Title, summary"), open it,
|
||||||
|
# check PROOF shows, screenshot, and go back on a phone. On the iPad the list
|
||||||
|
# is a left column, so scroll there instead of the middle of the screen.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${ROW}
|
||||||
|
times: 10
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: "${DEVICE == 'tablet' ? '18%,80%' : '50%,80%'}"
|
||||||
|
end: "${DEVICE == 'tablet' ? '18%,40%' : '50%,40%'}"
|
||||||
|
duration: 600
|
||||||
|
- tapOn: ${ROW}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${PROOF}
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-${NAME}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Signed in: sign out of the active account from the end of Settings.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Sign out"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: "Sign out"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "The account is removed from this device.*"
|
||||||
|
timeout: 10000
|
||||||
|
# The dialog's button, not the Settings row of the same name behind it.
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign out"
|
||||||
|
rightOf: "Cancel"
|
||||||
|
below: "The account is removed from this device.*"
|
||||||
|
- takeScreenshot: sign-out-tapped
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
|
||||||
|
# Keeps the app's data between flows; signs in only when needed.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
permissions:
|
||||||
|
notifications: allow
|
||||||
|
photos: allow
|
||||||
|
camera: allow
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually|Inbox"
|
||||||
|
timeout: 120000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
file: password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- runFlow: go-to-inbox.yaml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any
|
||||||
|
# non-empty value: the account needs a one-time code).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "you@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: stalwart-login-page
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "you@example.com"
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
- tapOn: "••••••••"
|
||||||
|
- inputText: ${PASSWORD}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TOTP == 'yes'}
|
||||||
|
commands:
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- tapOn: "000000"
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign in"
|
||||||
|
index: 1
|
||||||
@@ -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,25 @@
|
|||||||
|
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
|
||||||
|
# pages in the auth session), and check it arrived as typed. Now and then a
|
||||||
|
# stray character lands in front of it on the CI simulator; then select the
|
||||||
|
# field's text and type over it. SECRET=yes skips the check (a password
|
||||||
|
# field shows dots).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: ${HINT}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${TEXT}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${SECRET != 'yes'}
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: ${TEXT}
|
||||||
|
commands:
|
||||||
|
- longPressOn: ".*${TEXT}.*"
|
||||||
|
- tapOn:
|
||||||
|
text: "Select All"
|
||||||
|
optional: true
|
||||||
|
- inputText: ${TEXT}
|
||||||
|
- assertVisible: ${TEXT}
|
||||||
@@ -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 React from 'react';
|
||||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useColorScheme } from 'react-native';
|
import { useColorScheme } from 'react-native';
|
||||||
import {
|
import {
|
||||||
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
||||||
} from '@react-navigation/native';
|
} from '@react-navigation/native';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
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 { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||||
import { jmapClient } from './src/api/jmap-client';
|
import { jmapClient } from './src/api/jmap-client';
|
||||||
@@ -38,15 +39,18 @@ import {
|
|||||||
type NotificationTapPayload,
|
type NotificationTapPayload,
|
||||||
} from './src/lib/push-notifications';
|
} from './src/lib/push-notifications';
|
||||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
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 type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||||
import ComposeScreen from './src/screens/ComposeScreen';
|
import ComposeScreen from './src/screens/ComposeScreen';
|
||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
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 FilesScreen from './src/screens/FilesScreen';
|
||||||
import CalendarScreen from './src/screens/CalendarScreen';
|
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 ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
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 { useAuthStore } from './src/stores/auth-store';
|
||||||
import { useCalendarStore } from './src/stores/calendar-store';
|
import { useCalendarStore } from './src/stores/calendar-store';
|
||||||
import { useContactsStore } from './src/stores/contacts-store';
|
import { useContactsStore } from './src/stores/contacts-store';
|
||||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
import { useEmailStore } from './src/stores/email-store';
|
||||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
|
||||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||||
import { useSettingsStore } from './src/stores/settings-store';
|
import { useSettingsStore } from './src/stores/settings-store';
|
||||||
import { useLocaleStore } from './src/stores/locale-store';
|
import { useLocaleStore } from './src/stores/locale-store';
|
||||||
|
import { useLayout } from './src/lib/use-layout';
|
||||||
import { toast } from './src/stores/toast-store';
|
import { toast } from './src/stores/toast-store';
|
||||||
import { useNetworkStore } from './src/stores/network-store';
|
import { useNetworkStore } from './src/stores/network-store';
|
||||||
import { useUpdatesStore } from './src/stores/updates-store';
|
import { useUpdatesStore } from './src/stores/updates-store';
|
||||||
@@ -119,9 +123,11 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
|||||||
navigationRef.navigate('EmailThread', {
|
navigationRef.navigate('EmailThread', {
|
||||||
emailId: payload.emailId,
|
emailId: payload.emailId,
|
||||||
threadId: payload.threadId,
|
threadId: payload.threadId,
|
||||||
|
emailIds: [payload.emailId],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
// A group mailbox's message lives under another JMAP account (#839).
|
// A group mailbox's message lives under another JMAP account (#839).
|
||||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||||
|
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +247,15 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
settings: t('sidebar.settings', 'Settings'),
|
settings: t('sidebar.settings', 'Settings'),
|
||||||
};
|
};
|
||||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
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 (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<OfflineCacheBanner />
|
<OfflineCacheBanner />
|
||||||
<PushOnboardingPrompt />
|
<PushOnboardingPrompt />
|
||||||
@@ -253,9 +265,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Tabs out of view stop re-rendering until they are shown again.
|
// Tabs out of view stop re-rendering until they are shown again.
|
||||||
freezeOnBlur: true,
|
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,
|
tabBarActiveTintColor: c.text,
|
||||||
tabBarInactiveTintColor: c.textSecondary,
|
tabBarInactiveTintColor: c.textSecondary,
|
||||||
tabBarStyle: {
|
tabBarStyle: isSplit ? {
|
||||||
|
backgroundColor: c.secondary,
|
||||||
|
borderRightColor: c.border,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
elevation: 0,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
shadowColor: 'transparent',
|
||||||
|
} : {
|
||||||
backgroundColor: c.background,
|
backgroundColor: c.background,
|
||||||
borderTopColor: c.border,
|
borderTopColor: c.border,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
@@ -273,6 +297,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
name="Mail"
|
name="Mail"
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.mail,
|
title: tabLabels.mail,
|
||||||
|
tabBarButtonTestID: 'tab-mail',
|
||||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||||
tabBarBadgeStyle: {
|
tabBarBadgeStyle: {
|
||||||
@@ -289,36 +314,14 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => <MailTab navigation={navigation} />}
|
||||||
<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,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tab.Screen>
|
</Tab.Screen>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Calendar"
|
name="Calendar"
|
||||||
component={CalendarScreen}
|
component={CalendarScreen}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.calendar,
|
title: tabLabels.calendar,
|
||||||
|
tabBarButtonTestID: 'tab-calendar',
|
||||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||||
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
||||||
@@ -331,9 +334,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Contacts"
|
name="Contacts"
|
||||||
component={ContactsScreen}
|
component={ContactsTab}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.contacts,
|
title: tabLabels.contacts,
|
||||||
|
tabBarButtonTestID: 'tab-contacts',
|
||||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||||
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
||||||
@@ -349,6 +353,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
component={FilesScreen}
|
component={FilesScreen}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.files,
|
title: tabLabels.files,
|
||||||
|
tabBarButtonTestID: 'tab-files',
|
||||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||||
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
||||||
@@ -363,6 +368,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
name="Settings"
|
name="Settings"
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.settings,
|
title: tabLabels.settings,
|
||||||
|
tabBarButtonTestID: 'tab-settings',
|
||||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -400,6 +406,7 @@ export default function App() {
|
|||||||
// cached mail list instead of the "Restoring session" spinner; the real
|
// cached mail list instead of the "Restoring session" spinner; the real
|
||||||
// JMAP session comes up in the background.
|
// JMAP session comes up in the background.
|
||||||
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
||||||
|
const { isSplit } = useLayout();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!hasRestoredSession) {
|
if (!hasRestoredSession) {
|
||||||
@@ -685,6 +692,41 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
}, [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
|
// Live updates (SSE with polling fallback), re-armed on every account
|
||||||
// switch and every re-established session — the singleton `client` object
|
// switch and every re-established session — the singleton `client` object
|
||||||
// never changes identity, so it cannot be the dependency on its own.
|
// never changes identity, so it cannot be the dependency on its own.
|
||||||
@@ -830,10 +872,11 @@ export default function App() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Compose"
|
name="Compose"
|
||||||
component={ComposeScreen}
|
component={ComposeScreen}
|
||||||
options={{
|
// A tablet shows the composer as a panel over the screen it came
|
||||||
presentation: 'modal',
|
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||||
animation: 'slide_from_bottom',
|
options={isSplit
|
||||||
}}
|
? { presentation: 'transparentModal', animation: 'fade' }
|
||||||
|
: { presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||||
<Stack.Screen
|
<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
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
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.
|
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
|
## What works today
|
||||||
|
|
||||||
- Sign in to any JMAP server (e.g. Stalwart)
|
- 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
|
- 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)
|
- 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
|
- 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
|
## Run locally
|
||||||
|
|
||||||
@@ -46,4 +51,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
|
|||||||
|
|
||||||
## License
|
## 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 */
|
/* Variants */
|
||||||
// The list of variants to that are debuggable. For those we're going to
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
// The distribution flavors (github, play) need their debug variants listed.
|
||||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
debuggableVariants = ["githubDebug", "playDebug"]
|
||||||
|
|
||||||
/* Bundling */
|
/* Bundling */
|
||||||
// A list containing the node command and its flags. Default is just 'node'.
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
@@ -154,13 +154,39 @@ android {
|
|||||||
versionCode appVersionCode.toInteger()
|
versionCode appVersionCode.toInteger()
|
||||||
versionName appVersionName
|
versionName appVersionName
|
||||||
|
|
||||||
// Android account type of device sync (#34), derived from the package
|
|
||||||
// name so a later rename needs no other change. The XML in res/xml and
|
|
||||||
// the Kotlin in sync/ read it as @string/device_sync_account_type.
|
|
||||||
resValue "string", "device_sync_account_type", "${applicationId}.account"
|
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the build is distributed. The two are separate apps on a phone:
|
||||||
|
*
|
||||||
|
* - github: the APK on GitHub Releases, sideloaded, updated by the in-app
|
||||||
|
* updater. Keeps the original package name so existing installs update.
|
||||||
|
* - play: the Google Play bundle. Play forbids an app updating itself
|
||||||
|
* outside Play, so this flavor has no installer permission and the JS
|
||||||
|
* hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities).
|
||||||
|
*
|
||||||
|
* See docs/android-release.md.
|
||||||
|
*/
|
||||||
|
flavorDimensions = ["distribution"]
|
||||||
|
productFlavors {
|
||||||
|
github {
|
||||||
|
dimension "distribution"
|
||||||
|
applicationId 'com.anonymous.bulwarkmobile'
|
||||||
|
buildConfigField "String", "DISTRIBUTION", '"github"'
|
||||||
|
}
|
||||||
|
play {
|
||||||
|
dimension "distribution"
|
||||||
|
applicationId 'org.bulwarkmail.mobile'
|
||||||
|
buildConfigField "String", "DISTRIBUTION", '"play"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
// Android account type of device sync (#34), derived from the package
|
||||||
|
// name of each flavor. The XML in res/xml and the Kotlin in sync/ read
|
||||||
|
// it as @string/device_sync_account_type.
|
||||||
|
variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account"
|
||||||
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
storeFile file('debug.keystore')
|
storeFile file('debug.keystore')
|
||||||
@@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tasks.configureEach { task ->
|
tasks.configureEach { task ->
|
||||||
if (task.name == 'preReleaseBuild') {
|
if (task.name ==~ /pre\w*ReleaseBuild/) {
|
||||||
task.dependsOn(checkReleaseSigning)
|
task.dependsOn(checkReleaseSigning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"project_info": {
|
"project_info": {
|
||||||
"project_number": "504486613721",
|
"project_number": "752109678859",
|
||||||
"project_id": "bulwark-mobile",
|
"project_id": "bulwark-20c72",
|
||||||
"storage_bucket": "bulwark-mobile.firebasestorage.app"
|
"storage_bucket": "bulwark-20c72.firebasestorage.app"
|
||||||
},
|
},
|
||||||
"client": [
|
"client": [
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
|
"mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.anonymous.bulwarkmobile"
|
"package_name": "com.anonymous.bulwarkmobile"
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"oauth_client": [],
|
"oauth_client": [],
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
@@ -26,15 +26,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
|
"mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.bulwark.bulwarkmobile"
|
"package_name": "org.bulwarkmail.mobile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [],
|
"oauth_client": [],
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
|
|||||||
@@ -0,0 +1,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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<!-- REQUEST_INSTALL_PACKAGES (in-app updater) is declared by the github flavor only. -->
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<!-- Photos are picked with the system photo picker, which needs no media
|
||||||
|
permission; Play's photo and video policy rejects READ_MEDIA_IMAGES for that. -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||||
|
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
@@ -21,11 +25,11 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</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.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_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
@@ -74,7 +78,9 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
<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"/>
|
<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">
|
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
<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">
|
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts about this build that JS can't see on its own. `distribution` is the
|
||||||
|
* product flavor (app/build.gradle): "github" for the sideloaded APK with its
|
||||||
|
* own updater, "play" for the Google Play bundle, which must not update
|
||||||
|
* itself (lib/platform-capabilities).
|
||||||
|
*/
|
||||||
|
class BulwarkBuildModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
override fun getName() = "BulwarkBuild"
|
||||||
|
|
||||||
|
override fun getConstants(): Map<String, Any> = mapOf(
|
||||||
|
"distribution" to BuildConfig.DISTRIBUTION,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
@@ -72,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||||
?: accountId ?: "Bulwark Mail"
|
?: 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
|
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||||
// block waiting for the favicon request.
|
// block waiting for the favicon request.
|
||||||
thread(name = "bulwark-notification") {
|
thread(name = "bulwark-notification") {
|
||||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||||
?: makeLetterAvatar(initials, bgColorHex)
|
?: makeLetterAvatar(initials, bgColorHex)
|
||||||
postNotification(
|
postNotification(
|
||||||
notificationId, title, body, largeIcon, bgColorHex,
|
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||||
|
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||||
)
|
)
|
||||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||||
promise.resolve(null)
|
promise.resolve(null)
|
||||||
@@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
notificationId: String,
|
notificationId: String,
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
|
preview: String?,
|
||||||
largeIcon: Bitmap,
|
largeIcon: Bitmap,
|
||||||
colorHex: String,
|
colorHex: String,
|
||||||
emailId: String?,
|
emailId: String?,
|
||||||
@@ -110,10 +118,15 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
accountId: String?,
|
accountId: String?,
|
||||||
jmapAccountId: String?,
|
jmapAccountId: String?,
|
||||||
groupKey: String?,
|
groupKey: String?,
|
||||||
|
groupTitle: String?,
|
||||||
|
markReadLabel: String,
|
||||||
|
deleteLabel: String,
|
||||||
|
replyLabel: String,
|
||||||
) {
|
) {
|
||||||
val ctx = reactApplicationContext
|
val ctx = reactApplicationContext
|
||||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
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 (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||||
@@ -127,12 +140,27 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
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)
|
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setLargeIcon(largeIcon)
|
.setLargeIcon(largeIcon)
|
||||||
.setContentTitle(title)
|
.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)
|
.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")))
|
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
@@ -142,6 +170,72 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
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
|
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||||
}
|
}
|
||||||
@@ -157,7 +251,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
it.groupKey?.endsWith(groupKey) == true &&
|
it.groupKey?.endsWith(groupKey) == true &&
|
||||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
(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()
|
val inbox = NotificationCompat.InboxStyle()
|
||||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||||
val extras = sbn.notification.extras
|
val extras = sbn.notification.extras
|
||||||
@@ -172,6 +270,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
|
|
||||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
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)
|
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||||
}
|
}
|
||||||
val pending = PendingIntent.getActivity(
|
val pending = PendingIntent.getActivity(
|
||||||
@@ -273,6 +372,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
|
|
||||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
@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?) {
|
fun emit(eventName: String, params: WritableMap?) {
|
||||||
val module = currentInstance ?: return
|
val module = currentInstance ?: return
|
||||||
val ctx = module.reactApplicationContext
|
val ctx = module.reactApplicationContext
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class BulwarkFcmPackage : ReactPackage {
|
|||||||
add(BulwarkUnifiedPushModule(reactContext))
|
add(BulwarkUnifiedPushModule(reactContext))
|
||||||
add(BulwarkClientCertModule(reactContext))
|
add(BulwarkClientCertModule(reactContext))
|
||||||
add(BulwarkDeviceSyncModule(reactContext))
|
add(BulwarkDeviceSyncModule(reactContext))
|
||||||
|
add(BulwarkWidgetsModule(reactContext))
|
||||||
|
add(BulwarkPdfModule(reactContext))
|
||||||
|
add(BulwarkBuildModule(reactContext))
|
||||||
// The bridge registers React Native's own module under this name.
|
// The bridge registers React Native's own module under this name.
|
||||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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 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
|
pending = payload
|
||||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||||
extras.remove(EXTRA_EMAIL_ID)
|
extras.remove(EXTRA_EMAIL_ID)
|
||||||
@@ -51,6 +52,7 @@ object NotificationTapStore {
|
|||||||
extras.remove(EXTRA_SUBJECT)
|
extras.remove(EXTRA_SUBJECT)
|
||||||
extras.remove(EXTRA_ACCOUNT_ID)
|
extras.remove(EXTRA_ACCOUNT_ID)
|
||||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||||
|
extras.remove(EXTRA_ACTION)
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@ object NotificationTapStore {
|
|||||||
val subject: String?,
|
val subject: String?,
|
||||||
val accountId: String?,
|
val accountId: String?,
|
||||||
val jmapAccountId: String?,
|
val jmapAccountId: String?,
|
||||||
|
val action: String? = null,
|
||||||
) {
|
) {
|
||||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||||
putString("emailId", emailId)
|
putString("emailId", emailId)
|
||||||
@@ -67,6 +70,7 @@ object NotificationTapStore {
|
|||||||
if (subject != null) putString("subject", subject)
|
if (subject != null) putString("subject", subject)
|
||||||
if (accountId != null) putString("accountId", accountId)
|
if (accountId != null) putString("accountId", accountId)
|
||||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
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_SUBJECT = "bulwark.notification.subject"
|
||||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
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 |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |