mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
Merge branch 'main' into feat/stalwart-1.0
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# patch-package patches must keep the patched files' line endings byte for byte
|
||||||
|
# (react-native-android-widget ships CRLF sources).
|
||||||
|
patches/*.patch -text
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: iOS simulator test leg
|
||||||
|
description: Boot a simulator, install the app and run one suite of Maestro flows
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
suite:
|
||||||
|
description: Folder under .maestro (signin or features)
|
||||||
|
required: true
|
||||||
|
name:
|
||||||
|
description: Leg name, used for the artifact
|
||||||
|
required: true
|
||||||
|
app_run_id:
|
||||||
|
description: Take the simulator app from this run instead of the current one
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
flows:
|
||||||
|
description: Comma list of flow names to run instead of the whole suite
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
playwright:
|
||||||
|
description: Install Playwright for the helper's webmail pairing
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Download the simulator app (this run)
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}
|
||||||
|
|
||||||
|
- name: Download the simulator app (earlier run)
|
||||||
|
if: inputs.app_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.app_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}
|
||||||
|
|
||||||
|
- name: Unpack the simulator app
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/app"
|
||||||
|
tar -C "$RUNNER_TEMP/app" -xzf "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||||
|
echo "APP=$RUNNER_TEMP/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Boot a simulator
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||||
|
|
||||||
|
- name: Install Maestro
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
|
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install Playwright for the helper
|
||||||
|
if: inputs.playwright == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/pw"
|
||||||
|
cd "$RUNNER_TEMP/pw"
|
||||||
|
npm init -y >/dev/null
|
||||||
|
npm install --no-audit --no-fund playwright@1.59.1 >/dev/null
|
||||||
|
npx playwright install chromium >/dev/null
|
||||||
|
echo "PLAYWRIGHT_DIR=$RUNNER_TEMP/pw/node_modules" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Start the test helper
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$OUT/helper"
|
||||||
|
HELPER_SHOTS="$OUT/helper" nohup node scripts/e2e/helper.mjs > "$OUT/helper/helper.log" 2>&1 &
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
curl -fs http://127.0.0.1:8787/health >/dev/null && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
curl -fs http://127.0.0.1:8787/health
|
||||||
|
|
||||||
|
- name: Run the flows
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/e2e/run-flows.sh "${{ inputs.suite }}" "${{ inputs.flows }}"
|
||||||
|
|
||||||
|
- name: Upload screenshots, recording and logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-ios-${{ inputs.name }}
|
||||||
|
path: e2e-output/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
name: iOS simulator tests
|
||||||
|
|
||||||
|
# Builds the app for the iOS Simulator (no signing needed) and runs the
|
||||||
|
# Maestro flows in .maestro/ on iPhone and iPad simulators:
|
||||||
|
#
|
||||||
|
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
|
||||||
|
# - features: the app feature by feature (.maestro/features), on iPhone and
|
||||||
|
# iPad in light and dark, plus the iPad in landscape (split layout)
|
||||||
|
#
|
||||||
|
# Each leg signs in with its own account on the test server (e2e-* accounts
|
||||||
|
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
|
||||||
|
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
|
||||||
|
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
|
||||||
|
#
|
||||||
|
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
|
||||||
|
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
|
||||||
|
# kept out of them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
app_run_id:
|
||||||
|
description: "Reuse the simulator app from this earlier run instead of building (run id)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
legs:
|
||||||
|
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
|
||||||
|
type: string
|
||||||
|
default: all
|
||||||
|
flows:
|
||||||
|
description: "Only these flows (comma list of file names without .yaml); empty runs all"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
perf_base_run_id:
|
||||||
|
description: "perf leg: compare against the simulator app of this run (run id)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
perf_rounds:
|
||||||
|
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
|
||||||
|
type: string
|
||||||
|
default: "5"
|
||||||
|
required: false
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Per branch and set of legs: a perf run doesn't cancel a feature run.
|
||||||
|
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPO_NO_TELEMETRY: "1"
|
||||||
|
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||||
|
# The XCTest driver takes minutes to come up on a cold runner.
|
||||||
|
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
signin: ${{ steps.plan.outputs.signin }}
|
||||||
|
features: ${{ steps.plan.outputs.features }}
|
||||||
|
steps:
|
||||||
|
- id: plan
|
||||||
|
env:
|
||||||
|
LEGS: ${{ inputs.legs || 'all' }}
|
||||||
|
run: |
|
||||||
|
node -e '
|
||||||
|
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
|
||||||
|
const signin = ["phone", "tablet"]
|
||||||
|
.map((device) => ({ name: `signin-${device}`, device }))
|
||||||
|
.filter((l) => want(l.name, "signin"));
|
||||||
|
const features = [
|
||||||
|
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
|
||||||
|
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
|
||||||
|
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
|
||||||
|
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
|
||||||
|
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
|
||||||
|
].filter((l) => want(l.name, "features"));
|
||||||
|
// Push on iOS (.maestro/push): only on request, the app needs the
|
||||||
|
// notification extension (plugins/ios-push).
|
||||||
|
if (legs.includes("push")) {
|
||||||
|
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
|
||||||
|
}
|
||||||
|
const fs = require("fs");
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||||
|
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
|
||||||
|
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
|
||||||
|
'
|
||||||
|
|
||||||
|
build:
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache CocoaPods
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Caches/CocoaPods
|
||||||
|
~/.cocoapods/repos
|
||||||
|
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: pods-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate the iOS project
|
||||||
|
env:
|
||||||
|
# Simulator device tokens belong to the APNs sandbox.
|
||||||
|
APS_ENVIRONMENT: development
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
|
||||||
|
- name: Build for the simulator
|
||||||
|
env:
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
# The bundle times each module's evaluation for the perf leg, and
|
||||||
|
# Metro writes which id is which module (metro.config.js; a branch
|
||||||
|
# without that support ignores both).
|
||||||
|
BULWARK_MODULE_TIMINGS: '1'
|
||||||
|
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
|
||||||
|
run: |
|
||||||
|
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||||
|
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||||
|
mkdir -p e2e-build
|
||||||
|
# Release bundles the JS into the app, so no Metro server is needed.
|
||||||
|
# arm64 only: the runner is Apple silicon, and a second simulator
|
||||||
|
# architecture would double the build time.
|
||||||
|
if ! xcodebuild build \
|
||||||
|
-workspace "$WORKSPACE" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
CODE_SIGN_STYLE=Manual \
|
||||||
|
CODE_SIGN_IDENTITY=- \
|
||||||
|
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER= \
|
||||||
|
> e2e-build/xcodebuild.log 2>&1; then
|
||||||
|
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
|
||||||
|
tail -n 80 e2e-build/xcodebuild.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
|
||||||
|
# Xcode embeds the simulator entitlements the keychain needs. An
|
||||||
|
# unsigned build has none, and every expo-secure-store call fails
|
||||||
|
# with "A required entitlement isn't present".
|
||||||
|
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
|
||||||
|
echo "Simulated entitlements:"
|
||||||
|
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
|
||||||
|
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
|
||||||
|
# Packed as a tar: artifacts drop the executable bit, which would
|
||||||
|
# leave a reused app unable to launch.
|
||||||
|
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
|
||||||
|
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
|
||||||
|
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
|
||||||
|
else
|
||||||
|
echo "no module map (branch without module timings)"
|
||||||
|
fi
|
||||||
|
gzip -f "$RUNNER_TEMP/simulator-app.tar"
|
||||||
|
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||||
|
|
||||||
|
- name: Upload the simulator app
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}/simulator-app.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload the build log
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: xcodebuild-log
|
||||||
|
path: e2e-build/
|
||||||
|
|
||||||
|
signin:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
fromJson(needs.plan.outputs.signin).include[0] != null
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 150
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: ${{ matrix.device }}
|
||||||
|
APPEARANCE: light
|
||||||
|
ORIENTATION: portrait
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||||
|
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
|
||||||
|
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
|
||||||
|
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||||
|
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/e2e-ios-leg
|
||||||
|
with:
|
||||||
|
suite: signin
|
||||||
|
app_run_id: ${{ inputs.app_run_id }}
|
||||||
|
flows: ${{ inputs.flows }}
|
||||||
|
playwright: "true"
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
features:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
fromJson(needs.plan.outputs.features).include[0] != null
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.features) }}
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: ${{ matrix.device }}
|
||||||
|
APPEARANCE: ${{ matrix.appearance }}
|
||||||
|
ORIENTATION: ${{ matrix.orientation }}
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||||
|
E2E_ACCOUNT: ${{ matrix.account }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||||
|
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/e2e-ios-leg
|
||||||
|
with:
|
||||||
|
suite: ${{ matrix.suite || 'features' }}
|
||||||
|
app_run_id: ${{ inputs.app_run_id }}
|
||||||
|
flows: ${{ inputs.flows }}
|
||||||
|
playwright: "false"
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
|
||||||
|
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
|
||||||
|
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
|
||||||
|
# them on one simulator so both sides share the runner.
|
||||||
|
perf:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
contains(inputs.legs, 'perf')
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: phone
|
||||||
|
APPEARANCE: light
|
||||||
|
ORIENTATION: portrait
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_ACCOUNT: e2e-bulk
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download the app under test (this run)
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}/cand
|
||||||
|
- name: Download the app under test (earlier run)
|
||||||
|
if: inputs.app_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.app_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}/cand
|
||||||
|
- name: Download the baseline app
|
||||||
|
if: inputs.perf_base_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.perf_base_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}/base
|
||||||
|
|
||||||
|
- name: Unpack
|
||||||
|
run: |
|
||||||
|
for side in cand base; do
|
||||||
|
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
|
||||||
|
mkdir -p "$RUNNER_TEMP/$side/app"
|
||||||
|
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
|
||||||
|
done
|
||||||
|
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Boot a simulator
|
||||||
|
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||||
|
|
||||||
|
- name: Install Maestro
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
|
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sign in once
|
||||||
|
run: |
|
||||||
|
mkdir -p "$OUT/screenshots"
|
||||||
|
cd "$OUT/screenshots"
|
||||||
|
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
|
||||||
|
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
|
||||||
|
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
|
||||||
|
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
|
||||||
|
|
||||||
|
- name: Measure
|
||||||
|
run: |
|
||||||
|
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
|
||||||
|
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
|
||||||
|
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
|
||||||
|
fi
|
||||||
|
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
|
||||||
|
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
|
||||||
|
|
||||||
|
- name: Upload results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-ios-perf
|
||||||
|
path: e2e-output/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Release Android APK
|
name: Release Android
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -23,6 +23,8 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
steps:
|
steps:
|
||||||
# Fail in seconds instead of after a 20-minute build. There is no
|
# Fail in seconds instead of after a 20-minute build. There is no
|
||||||
# unsigned or debug-signed fallback: anything signed with the debug key
|
# unsigned or debug-signed fallback: anything signed with the debug key
|
||||||
@@ -135,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: |
|
||||||
@@ -186,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 \
|
||||||
@@ -208,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: …".
|
||||||
@@ -246,11 +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"
|
||||||
|
|
||||||
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"
|
||||||
@@ -266,10 +269,18 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "files<<FILES_EOF"
|
echo "files<<FILES_EOF"
|
||||||
echo "$DEST"
|
echo "$DEST"
|
||||||
|
echo "$DEST.sha256"
|
||||||
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
||||||
echo "FILES_EOF"
|
echo "FILES_EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Ties the APK to this commit and workflow run, which the signing key
|
||||||
|
# alone does not: `gh attestation verify <apk> --repo bulwarkmail/native`.
|
||||||
|
- name: Attest build provenance
|
||||||
|
uses: actions/attest@v4
|
||||||
|
with:
|
||||||
|
subject-path: ${{ steps.artifact.outputs.path }}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.artifact.outputs.path }}
|
name: ${{ steps.artifact.outputs.path }}
|
||||||
@@ -288,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
|
||||||
@@ -308,3 +319,150 @@ 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"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ steps.artifact.outputs.aab }}
|
||||||
|
path: |
|
||||||
|
${{ steps.artifact.outputs.aab }}
|
||||||
|
${{ steps.artifact.outputs.mapping }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||||
|
# uploads after the first bundle was uploaded by hand in the Console).
|
||||||
|
# Every release goes out to the internal and closed (beta) testers right
|
||||||
|
# away; closed testing may wait for Google's review first.
|
||||||
|
- name: Upload to Google Play (internal and closed testing)
|
||||||
|
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: org.bulwarkmail.mobile
|
||||||
|
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||||
|
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||||
|
releaseName: ${{ steps.artifact.outputs.version }}
|
||||||
|
tracks: internal,beta
|
||||||
|
status: completed
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ jobs:
|
|||||||
timeout-minutes: 90
|
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,75 @@
|
|||||||
|
# Opening messages: plain text, HTML with blocked remote content, a thread,
|
||||||
|
# and a PDF attachment in the in-app preview.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Read messages
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
|
||||||
|
# Plain text
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Grüße aus München
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Jürgen.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: read-01-plain
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# HTML newsletter
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*5 new articles.*|Weekly digest"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: read-02-html
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Load images"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Load images"
|
||||||
|
- takeScreenshot: read-03-html-images
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# Attachment and its preview
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Q3 report
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*q3-report.pdf.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: read-04-attachment
|
||||||
|
# The message's attachment chip, labelled with the file name alone (on the
|
||||||
|
# iPad the list row next to it mentions the name in its longer label).
|
||||||
|
- tapOn: "q3-report.pdf"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Share"
|
||||||
|
timeout: 30000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: read-05-attachment-preview
|
||||||
|
- assertVisible: "q3-report.pdf"
|
||||||
|
- tapOn: "Close"
|
||||||
|
# Back on the message (the iPad pane has a quick reply instead of Reply All).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Share"
|
||||||
|
timeout: 10000
|
||||||
|
- assertVisible: ".*q3-report.pdf.*"
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# A thread (Projects/Alpha)
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Alpha
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Project Alpha kickoff
|
||||||
|
- takeScreenshot: read-06-thread
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Compose a new message and send it to userb; userb's mailbox (checked over
|
||||||
|
# JMAP) must get it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Compose and send
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- tapOn:
|
||||||
|
text: "Subject"
|
||||||
|
index: 1
|
||||||
|
- inputText: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
FROM_SUBJECT: "yes"
|
||||||
|
BODY: "Hello from the iOS simulator test."
|
||||||
|
- takeScreenshot: compose-01-filled
|
||||||
|
|
||||||
|
# The attach sheet offers its options one by one (it used to be a single
|
||||||
|
# accessibility element, and picking one opened no picker on iOS).
|
||||||
|
- tapOn: "Attach"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Photos & Videos"
|
||||||
|
timeout: 10000
|
||||||
|
- assertVisible: "Camera"
|
||||||
|
- assertVisible: "Files"
|
||||||
|
- takeScreenshot: compose-02-attach-sheet
|
||||||
|
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
|
||||||
|
# The system photo picker runs in another process: XCTest sees none of its
|
||||||
|
# elements, but taps by position reach it. Pick the first photo (the app
|
||||||
|
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
|
||||||
|
# landscape lays the picker out differently, so it only closes the sheet.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${!output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Photos & Videos"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Draft saved|New Message"
|
||||||
|
timeout: 10000
|
||||||
|
optional: true
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-03-photo-picker
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "44%,33%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "90%,26%"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "16%,25%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "90%,13%"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-04-with-photo
|
||||||
|
|
||||||
|
- tapOn: "Send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.attachments.length >= 1}
|
||||||
|
label: "${output.subject} carries the picked photo"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Incoming mail while the app is open (sent by userb over JMAP), then reply
|
||||||
|
# and forward it; userb receives both, the forward with the attachment.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Incoming, reply, forward
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1
|
||||||
|
# Arrives without a pull to refresh (push over EventSource).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.incoming}.*"
|
||||||
|
timeout: 90000
|
||||||
|
- takeScreenshot: incoming-01-arrived
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.incoming}
|
||||||
|
- takeScreenshot: incoming-02-open
|
||||||
|
|
||||||
|
# Reply
|
||||||
|
- tapOn:
|
||||||
|
text: "Reply"
|
||||||
|
index: 0
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Re: ${output.incoming}"
|
||||||
|
timeout: 15000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
BODY: "Reply from the iOS simulator test."
|
||||||
|
- takeScreenshot: incoming-03-reply
|
||||||
|
- tapOn: "Send"
|
||||||
|
# The toast is gone after a few seconds; delivery is checked below.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: "Re: ${output.incoming}"
|
||||||
|
|
||||||
|
# Forward with the attachment
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Forward"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Forward"
|
||||||
|
index: 0
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Fwd: ${output.incoming}"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- takeScreenshot: incoming-04-forward
|
||||||
|
- tapOn: "Send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: "Fwd: ${output.incoming}"
|
||||||
|
ATTACHMENT: e2e-attachment.pdf
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Search: a hit, a miss, clearing.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Search
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- tapOn: "Search mail..."
|
||||||
|
- inputText: "verification"
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Your verification code.*"
|
||||||
|
timeout: 30000
|
||||||
|
- assertNotVisible: ".*Weekly digest.*"
|
||||||
|
- takeScreenshot: search-01-hit
|
||||||
|
- tapOn: "Clear search"
|
||||||
|
- tapOn: "Search mail..."
|
||||||
|
- inputText: "zzqx-no-such-mail"
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "No results found"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: search-02-miss
|
||||||
|
- tapOn: "Clear"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
|
||||||
|
# right toggles read.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Swipe actions and undo
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*Grüße aus München.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
timeout: 20000
|
||||||
|
- swipe:
|
||||||
|
from: ".*Grüße aus München.*"
|
||||||
|
direction: LEFT
|
||||||
|
duration: 400
|
||||||
|
# The undo bar shows for 5 s; when it goes, the Compose button slides into
|
||||||
|
# its place, and a late tap on UNDO opens the composer. So tap at once and
|
||||||
|
# check the rest afterwards.
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Grüße aus München.*"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: swipe-02-undone
|
||||||
|
|
||||||
|
- swipe:
|
||||||
|
from: ".*Grüße aus München.*"
|
||||||
|
direction: RIGHT
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- takeScreenshot: swipe-03-read-toggled
|
||||||
|
- swipe:
|
||||||
|
from: ".*Grüße aus München.*"
|
||||||
|
direction: RIGHT
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Message actions with undo: move to a folder, delete to Trash, select and
|
||||||
|
# star from the list.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Move, delete, select
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
|
||||||
|
# Move via More → Move to...
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- tapOn: "more"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "More actions"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: actions-01-more
|
||||||
|
- tapOn: "Move to..."
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Move to..."
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Projects"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- takeScreenshot: actions-02-moved
|
||||||
|
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# Delete (to Trash) and undo
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- takeScreenshot: actions-03a-before-delete
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- takeScreenshot: actions-03-deleted
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# Select from the list and star, then unstar. On a phone the row can be
|
||||||
|
# below the fold (earlier runs left mail on top).
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*Grüße aus München.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
timeout: 20000
|
||||||
|
- longPressOn: ".*Grüße aus München.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Clear selection"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: actions-04-selected
|
||||||
|
# Star (or Unstar, when an earlier run left it starred), then toggle back.
|
||||||
|
- tapOn: "Star|Unstar"
|
||||||
|
# A bulk action ends selection mode by itself.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
|
- longPressOn: ".*Grüße aus München.*"
|
||||||
|
- tapOn: "Unstar|Star"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Scheduled send: schedule a message for later, find it under Scheduled,
|
||||||
|
# cancel it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Scheduled send
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- tapOn:
|
||||||
|
text: "Subject"
|
||||||
|
index: 1
|
||||||
|
- inputText: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
FROM_SUBJECT: "yes"
|
||||||
|
BODY: "Scheduled from the iOS simulator test."
|
||||||
|
- tapOn: "Schedule send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Tomorrow morning.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: scheduled-01-options
|
||||||
|
- tapOn: "Tomorrow morning.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-02-confirmed
|
||||||
|
- tapOn: "OK"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Scheduled
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-03-list
|
||||||
|
- tapOn: ".*${output.subject}.*"
|
||||||
|
- tapOn: "Cancel send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Keep scheduled"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Cancel send"
|
||||||
|
below: "Keep scheduled|.*will not be delivered.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.subject}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-04-cancelled
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Calendar: the views, create an event with a reminder, edit it, delete it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Calendar
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
# The first visit asks for notification permission (event reminders).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow: ../subflows/allow-notifications.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: calendar-01-month
|
||||||
|
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Schedule"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-02-schedule
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Day"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-03-day
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Week"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-04-week
|
||||||
|
|
||||||
|
# Create
|
||||||
|
- tapOn: "New event"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Save"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Title"
|
||||||
|
- inputText: ${output.event}
|
||||||
|
# Close the keyboard first: a tap while it slides away lands on a form that
|
||||||
|
# is still moving.
|
||||||
|
- pressKey: Enter
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Add reminder"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "15 minutes before"
|
||||||
|
times: 2
|
||||||
|
commands:
|
||||||
|
- tapOn: "Add reminder"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "15 minutes before"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "15 minutes before"
|
||||||
|
- takeScreenshot: calendar-05-editor
|
||||||
|
- tapOn: "Save"
|
||||||
|
# The editor closes (its Save goes; the FAB is labelled "New event" too).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Save"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# Find it in the schedule and open it
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Schedule"
|
||||||
|
- tapOn: "Go to today"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${output.event}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: ".*${output.event}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Edit"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: calendar-06-detail
|
||||||
|
- assertVisible: ".*15 minutes before.*"
|
||||||
|
|
||||||
|
# Edit: add a location (an empty field, so no cursor games in the title)
|
||||||
|
- tapOn: "Edit"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Edit event"
|
||||||
|
timeout: 10000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Place or address"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Place or address"
|
||||||
|
- inputText: "Room 42"
|
||||||
|
- tapOn: "Save"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Room 42.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: calendar-07-edited
|
||||||
|
|
||||||
|
# Delete (the detail sheet is still open after saving)
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Delete"
|
||||||
|
commands:
|
||||||
|
- tapOn: ".*${output.event}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete this event.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Delete this event.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.event}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: calendar-08-deleted
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Contacts: the list, a contact's detail, create one and delete it again.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Contacts
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.last = 'Tester' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-contacts
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "All Contacts"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: contacts-01-list
|
||||||
|
- tapOn: ".*Alice.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: contacts-02-detail
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
|
||||||
|
- tapOn: "New Contact"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Save"
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: contact-first-name
|
||||||
|
TEXT: E2E
|
||||||
|
# Return moves to Middle name, then Last name: tapping the field would hit
|
||||||
|
# the keyboard covering it.
|
||||||
|
- pressKey: Enter
|
||||||
|
- pressKey: Enter
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: contact-last-name
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${output.last}
|
||||||
|
- takeScreenshot: contacts-03-form
|
||||||
|
- tapOn: "Save"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.last}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: contacts-04-created
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "More actions"
|
||||||
|
commands:
|
||||||
|
- tapOn: ".*E2E ${output.last}.*"
|
||||||
|
- tapOn: "More actions"
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete contact"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Are you sure.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*E2E ${output.last}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: contacts-05-deleted
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Files: open a folder, preview a text and a PDF file, create and delete a
|
||||||
|
# folder.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Files
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-files
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Documents.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: files-01-root
|
||||||
|
- tapOn: ".*Documents.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*readme.txt.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: ".*readme.txt.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "readme.txt"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: files-02-text-preview
|
||||||
|
- tapOn: "Close"
|
||||||
|
- tapOn: ".*q3-report.pdf.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "q3-report.pdf"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: files-03-pdf-preview
|
||||||
|
- tapOn: "Close"
|
||||||
|
- tapOn: "Back"
|
||||||
|
|
||||||
|
- tapOn: "New Folder"
|
||||||
|
- tapOn: "Folder name"
|
||||||
|
- inputText: ${output.folder}
|
||||||
|
- tapOn: "Create"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.folder}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: files-04-folder-created
|
||||||
|
- longPressOn: ".*${output.folder}.*"
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Delete this item.*"
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete this item.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Delete this item.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.folder}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Every settings page opens and shows its content.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Settings pages
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Search settings"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-00-list
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "Folders", PROOF: ".*Inbox.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content", PROOF: "External Content"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Calendar", PROOF: "Default view"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Contacts", PROOF: "Group by first letter"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "The default light and dark palettes."}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Fresh install: the app starts and shows the sign-in choices, and typing
|
||||||
|
# into a field works. Needs no server, so it runs before every suite as the
|
||||||
|
# warm-up for the XCTest driver. It types only a made-up address, so its
|
||||||
|
# debug log is safe to print.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Launch to sign-in
|
||||||
|
tags:
|
||||||
|
- smoke
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
clearKeychain: true
|
||||||
|
# A cold start of a release build on a CI simulator can be slow.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 120000
|
||||||
|
- takeScreenshot: warmup-01-sign-in-choices
|
||||||
|
- tapOn: "Enter server details manually"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow: subflows/dismiss-keyboard-tip.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: typing-probe.example.net
|
||||||
|
- takeScreenshot: warmup-02-typed
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Perf: open a message and go back, five times. The app is already running
|
||||||
|
# with -perfMarks YES (no launchApp here: it would drop the launch argument).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf open message
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
|
- repeat:
|
||||||
|
times: 5
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: email-row
|
||||||
|
index: 1
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 30000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Perf: switch through the tabs, three times.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf tab switches
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-contacts
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- takeScreenshot: perf-contacts
|
||||||
|
- tapOn:
|
||||||
|
id: tab-files
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Perf: open the composer and close it untouched, five times.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf compose open
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- repeat:
|
||||||
|
times: 5
|
||||||
|
commands:
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn: "Close"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Discard"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Discard"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Perf: fling through the long inbox (thousands of messages), then back up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf list scroll
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 30000
|
||||||
|
- repeat:
|
||||||
|
times: 8
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,80%
|
||||||
|
end: 50%,20%
|
||||||
|
duration: 250
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 4000
|
||||||
|
- repeat:
|
||||||
|
times: 8
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,20%
|
||||||
|
end: 50%,80%
|
||||||
|
duration: 250
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 4000
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Performance runs: sign in once (ACCOUNT, the bulk account with thousands of
|
||||||
|
# messages). Later installs of other builds keep the data and the keychain.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf sign-in
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
INBOX_TEXT: ".*Bulk.*"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Push on iOS: the relay's payload carries only ids and a placeholder text.
|
||||||
|
# A tap opens the message, with the app running and from a cold start. The
|
||||||
|
# helper sends the message as userb and pushes the relay's payload with
|
||||||
|
# simctl, so this needs no APNs key.
|
||||||
|
#
|
||||||
|
# simctl push hands the payload to the simulator as a local notification
|
||||||
|
# request: the notification service extension (plugins/ios-push), which
|
||||||
|
# puts sender and subject in, never runs that way (the log shows no
|
||||||
|
# NotificationService process). So the banner shows the placeholder here;
|
||||||
|
# the extension needs a real APNs push to be tested.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Push notification on iOS
|
||||||
|
tags:
|
||||||
|
- push
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
# Without notification permission iOS drops the push silently, and the
|
||||||
|
# launch-time grant doesn't reach it on these simulators: let the app ask
|
||||||
|
# (the Calendar tab does, for event reminders) and allow.
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- pressKey: Home
|
||||||
|
|
||||||
|
# 1. The banner shows (and what it says).
|
||||||
|
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject0}.*|.*New message.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: push-01-banner
|
||||||
|
# Let it go, so the next tap can't hit this one.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.subject0}.*|.*New message.*"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# 2. A tap opens the message, app in the background. The banner shows for a
|
||||||
|
# few seconds and finding it takes Maestro about as long; taps on the lock
|
||||||
|
# screen list do nothing on these simulators. So tap where the banner
|
||||||
|
# appears, right after the push (push-and-tap.yaml).
|
||||||
|
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/push-and-tap.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 60000
|
||||||
|
- assertVisible: ".*${output.subject}.*"
|
||||||
|
- takeScreenshot: push-02-opened-warm
|
||||||
|
|
||||||
|
# 3. The same from a cold start: the app isn't running.
|
||||||
|
- stopApp
|
||||||
|
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/push-and-tap.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject2}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 90000
|
||||||
|
- assertVisible: ".*${output.subject2}.*"
|
||||||
|
- takeScreenshot: push-04-opened-cold
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Calls the test helper (scripts/e2e/helper.mjs) on the runner.
|
||||||
|
// env CALL: path and query, e.g. "/totp" or "/pair?user=x&deliver=openurl".
|
||||||
|
// The answer lands in output.helper.
|
||||||
|
const response = http.get(HELPER + CALL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('helper ' + CALL.split('?')[0] + ' failed: ' + response.status + ' ' + response.body);
|
||||||
|
}
|
||||||
|
output.helper = json(response.body);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Password sign-in as the first account, sign out, sign in again.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Password, sign out, sign in again
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- takeScreenshot: password-01-choices
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- takeScreenshot: password-02-confirm
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: password-03-inbox
|
||||||
|
|
||||||
|
- runFlow: ../subflows/sign-out.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: password-04-signed-out
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- takeScreenshot: password-05-signed-in-again
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# A wrong password and an unreachable server: the app says what went wrong
|
||||||
|
# and stays on the form.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Password errors
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
PASSWORD: wrong-password-123
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That didn't work"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: errors-01-wrong-password
|
||||||
|
- assertVisible:
|
||||||
|
id: login-password-input
|
||||||
|
|
||||||
|
# Back to the server step, then a host that doesn't resolve.
|
||||||
|
- tapOn: "Back"
|
||||||
|
- tapOn: "Back"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
ERASE: "yes"
|
||||||
|
TEXT: mail.does-not-exist.invalid
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-password-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type-secret.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-password-input
|
||||||
|
# The email is filled in, so the password field focuses itself.
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${PASSWORD}
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Can't reach mail.does-not-exist.invalid|No mail server at mail.does-not-exist.invalid|Sign-in failed"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: errors-02-unreachable
|
||||||
|
- assertVisible:
|
||||||
|
id: login-password-input
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# A TOTP account signs in with a password: Stalwart answers 402 for the right
|
||||||
|
# password, and the app asks for the code.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Password with TOTP
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${TOTP_ACCOUNT}@example.org
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: totp-01-code-asked
|
||||||
|
- runFlow: ../subflows/two-factor.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: totp-02-inbox
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# OAuth with Stalwart's own authorization server: the confirm step's
|
||||||
|
# Continue opens an ASWebAuthenticationSession on Stalwart's sign-in page.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: OAuth (Stalwart)
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/stalwart-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: oauth-01-inbox
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Backing out of OAuth: Cancel on the system alert, and closing the sheet
|
||||||
|
# halfway. Both leave the user on the confirm step without an error.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: OAuth cancelled
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn:
|
||||||
|
text: "Cancel"
|
||||||
|
below: ".*Wants to Use.*to Sign In.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- assertNotVisible: "Sign-in failed"
|
||||||
|
- assertNotVisible: "Sign-in was interrupted"
|
||||||
|
- takeScreenshot: oauth-cancel-01-alert-cancelled
|
||||||
|
|
||||||
|
# Accept the alert, then close the sheet on Stalwart's page.
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "you@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- assertNotVisible: "Sign-in failed"
|
||||||
|
- assertNotVisible: "Sign-in was interrupted"
|
||||||
|
- takeScreenshot: oauth-cancel-02-sheet-closed
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Sign in via webmail: the confirm step on a Bulwark webmail address hands
|
||||||
|
# off to the webmail's sign-in page in an auth session.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Webmail hand-off
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: webmail-01-inbox
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Webmail hand-off for a TOTP account: the webmail asks for the code; if it
|
||||||
|
# hands back the password, the app finishes with a code of its own. Uses the
|
||||||
|
# beta webmail: the stable one still has 06's session in the browser, which
|
||||||
|
# strands the hand-off (see 20-webmail-session.yaml).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Webmail hand-off with TOTP
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL_BETA}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${TOTP_ACCOUNT}@example.org
|
||||||
|
TOTP: "yes"
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: webmail-totp-01-inbox
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Sign-in link from another app (simctl openurl), password account. The
|
||||||
|
# helper signs in to the test webmail, creates a fresh "Link mobile app" code
|
||||||
|
# and opens its bulwarkmail://pair link on the simulator.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Sign-in link from another app
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: link-password-01-inbox
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# "Paste sign-in link" with a fresh pairing link on the clipboard (simctl
|
||||||
|
# pbcopy), password account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Paste sign-in link
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT}&deliver=pbcopy
|
||||||
|
- runFlow: ../subflows/paste-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: paste-password-01-inbox
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Sign-in link from another app for a TOTP account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Sign-in link, TOTP account
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: link-totp-01-inbox
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# "Paste sign-in link" for a TOTP account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Paste sign-in link, TOTP account
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=pbcopy
|
||||||
|
- runFlow: ../subflows/paste-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: paste-totp-01-inbox
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# An expired code (codes live 2 minutes): the helper creates one now and
|
||||||
|
# opens it after 130 s. The app says so and stays on the sign-in screen.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Expired sign-in code
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair-later?user=${ACCOUNT}&deliver=openurl&delay=130
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/confirm-link.yaml
|
||||||
|
env:
|
||||||
|
LINK_WAIT: 200000
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That code has expired.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: link-expired-01-notice
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# A code that was already used: sign in with it, sign out, open the same link
|
||||||
|
# again (same app process, then after a restart, which asks the server).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Used sign-in code
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow: ../subflows/sign-out.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That code was already used|That code has expired or was already used"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: link-used-01-same-process
|
||||||
|
|
||||||
|
- stopApp
|
||||||
|
- launchApp
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 60000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That code was already used|That code has expired or was already used"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: link-used-02-after-restart
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# The QR scanner with camera access denied, then allowed (the simulator has
|
||||||
|
# no camera, so nothing can be scanned). Neither may crash the app.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Scanner without camera
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/start.yaml
|
||||||
|
env:
|
||||||
|
CAMERA: deny
|
||||||
|
- tapOn: "Scan a sign-in code.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Camera access is.*|Scanning a sign-in QR code uses the camera.*"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: scanner-01-denied
|
||||||
|
- tapOn: "Close"
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/start.yaml
|
||||||
|
env:
|
||||||
|
CAMERA: allow
|
||||||
|
- tapOn: "Scan a sign-in code.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign-in code"
|
||||||
|
timeout: 15000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: scanner-02-allowed
|
||||||
|
- tapOn: "Close"
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# "Use my email address": example.org has no autodiscovery, so the app moves
|
||||||
|
# on to the server step and names the domain.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Email address discovery
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- tapOn: "Use my email address.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What's your email address."
|
||||||
|
timeout: 10000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${ACCOUNT}@example.org
|
||||||
|
- assertVisible: ${ACCOUNT}@example.org
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "We couldn't find a server for example.org.*"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: discovery-01-not-found
|
||||||
|
- assertVisible:
|
||||||
|
id: login-server-input
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Second account by password via "Another account on <host>", then switch
|
||||||
|
# between the two.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, password
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/open-add-account.yaml
|
||||||
|
- takeScreenshot: add-password-01-choices
|
||||||
|
- tapOn: "Another account on .*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT2}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-password-02-two-accounts
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Second account by OAuth. Adding uses an ephemeral auth session, so the
|
||||||
|
# first account's Stalwart sign-in must not be reused.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, OAuth
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/stalwart-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
|
||||||
|
- runFlow: ../subflows/open-add-account.yaml
|
||||||
|
- tapOn: "Another account on .*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
commands:
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/stalwart-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT2}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-oauth-01-two-accounts
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Second account via the webmail hand-off, on top of a password account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, webmail
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/open-add-account.yaml
|
||||||
|
- tapOn: "An account somewhere else.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What's the other address."
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "I know my server address"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
commands:
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT2}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-webmail-01-two-accounts
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Second account from a sign-in link opened by another app while signed in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, sign-in link
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT2}&deliver=openurl
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*|Sign in with a link."
|
||||||
|
timeout: 60000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Open"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a link."
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: add-link-01-prompt
|
||||||
|
- assertVisible: "This link adds an account.*"
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Sign in with a link."
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-link-02-two-accounts
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Sign in via webmail while the browser still has a webmail session (from
|
||||||
|
# 06-webmail, or from using the webmail in Safari): the hand-off must still
|
||||||
|
# reach the app. Before the fix, the webmail's login page redirected an
|
||||||
|
# existing session to its own inbox inside the sign-in sheet, and the app
|
||||||
|
# never got the sign-in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Webmail hand-off with an existing webmail session
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
# Leaves a webmail session in the shared browser cookies.
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "user@example.com|Hand over to the app|.*Inbox.*"
|
||||||
|
timeout: 60000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "user@example.com"
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Hand over to the app"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Hand over to the app"
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow: ../subflows/sign-out.yaml
|
||||||
|
|
||||||
|
# Again: the webmail now knows the user.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "user@example.com|Hand over to the app|Search mail.*"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: webmail-session-01-second-visit
|
||||||
|
- assertNotVisible: "Search mail.*"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# iOS asks before an app opens a sign-in session in the system browser:
|
||||||
|
# "“Bulwark Mobile” Wants to Use “<domain>” to Sign In". Accept it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: auth-session-alert
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: ".*Wants to Use.*to Sign In.*"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# The app asks for notification permission now and then (calendar
|
||||||
|
# reminders, push). The system alert covers the whole app, so XCTest sees
|
||||||
|
# nothing else until it is answered: allow it when it is up. Taps on system
|
||||||
|
# alerts miss in landscape, so answer it in portrait.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- setOrientation: PORTRAIT
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# The helper checks over JMAP that AS (default userb) received SUBJECT; with
|
||||||
|
# ATTACHMENT set, that the message carries that file.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /find?as=${AS || 'userb'}&subject=${encodeURIComponent(SUBJECT)}&timeout=60
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.found}
|
||||||
|
label: "${SUBJECT} delivered to ${AS || 'userb'}"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${!!ATTACHMENT}
|
||||||
|
commands:
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.attachments.indexOf(ATTACHMENT) >= 0}
|
||||||
|
label: "${SUBJECT} carries ${ATTACHMENT}"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# The Settings account card names the active account ACTIVE, and the Account
|
||||||
|
# page lists another account to switch to. Goes back to Mail afterwards.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${ACTIVE}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: ".*${ACTIVE}.*"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Switch to this account"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: accounts-${ACTIVE}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Empty the field with id FIELD. A tap puts the cursor where it lands and
|
||||||
|
# eraseText only deletes before the cursor, so erase in passes: each tap
|
||||||
|
# lands in the middle of what is left.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
times: 4
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: ${FIELD}
|
||||||
|
- eraseText: 100
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Leave the message view: Back on a phone; the split layout keeps the list.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
# A tap during the transition lands on stale coordinates.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# In the composer: type BODY into the editor. The rich editor is a WebView
|
||||||
|
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
|
||||||
|
# the screen, which is the body area below Subject (above the keyboard).
|
||||||
|
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
|
||||||
|
# to the body (an iPad in landscape shows hardly any body with the keyboard
|
||||||
|
# up, so there is nothing to tap).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${FROM_SUBJECT == 'yes'}
|
||||||
|
commands:
|
||||||
|
- pressKey: Enter
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- inputText: ${BODY}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${FROM_SUBJECT != 'yes'}
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Write your message.*"
|
||||||
|
# A tap on the WebView doesn't always take the focus from the Subject field
|
||||||
|
# (on the iPad the body once went into Subject); a second tap does.
|
||||||
|
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${BODY}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# A sign-in link from another app asks first. Continue.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
# simctl openurl acts like a link from another app: iOS asks first.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
|
||||||
|
timeout: ${LINK_WAIT || 60000}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Open"
|
||||||
|
# Right after a sign-out the app can take a while to get to the link.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a link.|Open the sign-in page of.*"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: link-confirm-prompt
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Sign in with a link."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# iOS shows a one-time slide-to-type tip over the keyboard on a fresh
|
||||||
|
# simulator. The workflow turns it off, but dismiss it if it still shows up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Speed up your typing by sliding.*"
|
||||||
|
commands:
|
||||||
|
# The sign-in screen has its own Continue button; take the tip's.
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Speed up your typing by sliding.*"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Some routes hand the app a password for a TOTP account; the app then asks
|
||||||
|
# for a code itself. Answer it when it shows up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code|Inbox"
|
||||||
|
timeout: 90000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Authentication code"
|
||||||
|
commands:
|
||||||
|
- runFlow: two-factor.yaml
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Open folder FOLDER from the drawer or docked sidebar.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow: open-drawer.yaml
|
||||||
|
# A folder row's label joins its texts: "<name>, <unread>, /, <total>".
|
||||||
|
- tapOn: ${FOLDER}([ ,].*)?
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${FOLDER}
|
||||||
|
timeout: 15000
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Back to the inbox list, from wherever in the Mail tab.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
- runFlow:
|
||||||
|
file: go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Inbox
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
|
||||||
|
# one-time prompts on the way.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: tab-mail
|
||||||
|
timeout: 90000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Not now"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Not now"
|
||||||
|
# Adding an account from Settings returns to Settings.
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
|
# Rows are one element: sender, subject and preview together. Mail the
|
||||||
|
# tests sent earlier can push the seeded row below the fold.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ${INBOX_TEXT || '.*Your verification code.*'}
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# From the sign-in choices: enter a server address by hand and continue to the
|
||||||
|
# confirm step. env HOST: the address as a user types it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: "Enter server details manually"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${HOST}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
# Once in a while the tap doesn't reach the button (it moves as the keyboard
|
||||||
|
# goes away): tap again if the form is still there.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Signed in: Settings → account card → Account page → Add account, up to the
|
||||||
|
# add-mode choices. (On the iPad split layout the Account page is already
|
||||||
|
# open; tapping the card again is harmless.)
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*@example.org, .*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: ".*@example.org, .*"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Add account"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Add account"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Add another account"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Show the folder list: the modal drawer, or nothing to do where the sidebar
|
||||||
|
# is docked (iPad landscape).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Toggle menu"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Toggle menu"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled.*"
|
||||||
|
timeout: 5000
|
||||||
|
optional: true
|
||||||
|
# Once more if the first tap went to a list still settling.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Scheduled.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Toggle menu"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled.*"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# From a message list: open the message whose row mentions SUBJECT.
|
||||||
|
# A row near the bottom can count as visible while the phone's tab bar
|
||||||
|
# covers it (the tap then opens another tab): on the phone, scroll it to the
|
||||||
|
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
|
||||||
|
# screen) doesn't reach; its rows fit on screen.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
visibilityPercentage: 100
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
# A tap while the list still moves lands on another row.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- tapOn: ".*${SUBJECT}.*"
|
||||||
|
# The message toolbar's "more" button: "Reply All" shows on the phone, but
|
||||||
|
# the iPad pane has a quick reply box instead for mail to one recipient.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "more"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# On the password step: fill in LOGIN and PASSWORD and sign in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-username-input
|
||||||
|
timeout: 20000
|
||||||
|
- runFlow:
|
||||||
|
file: type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-username-input
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
- runFlow:
|
||||||
|
file: type-secret.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-password-input
|
||||||
|
TEXT: ${PASSWORD}
|
||||||
|
# Return on the password field signs in.
|
||||||
|
- pressKey: Enter
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# From the sign-in choices to the inbox: manual server, password.
|
||||||
|
# env LOGIN: the account's address.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
file: manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow: password-form.yaml
|
||||||
|
- runFlow: inbox.yaml
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# The helper has put a sign-in link on the simulator's clipboard: open
|
||||||
|
# "Paste sign-in link", paste it and sign in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: "Paste sign-in link.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Paste"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Paste"
|
||||||
|
# iOS asks before an app reads the clipboard.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow Paste"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow Paste"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "bulwarkmail://pair.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: paste-link-filled
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign in"
|
||||||
|
below: "bulwarkmail://pair.*"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
|
||||||
|
# where it appears, at once (it shows for a few seconds, about as long as
|
||||||
|
# Maestro takes to find it). If the tap came too early or too late, push
|
||||||
|
# again: up to three tries until the message is open.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Reply All"
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,9%"
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
retryTapIfNoChange: false
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 30000
|
||||||
|
optional: true
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Settings list: find the row ROW (regex on "Title, summary"), open it,
|
||||||
|
# check PROOF shows, screenshot, and go back on a phone.
|
||||||
|
#
|
||||||
|
# Rows count as visible while the phone's tab bar or header covers them or
|
||||||
|
# while they hang half off the iPad's screen, and a tap then misses; the
|
||||||
|
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
|
||||||
|
# doesn't reach. So search for the page (Q, a word of its title): the row
|
||||||
|
# then sits right under the search box. The account card (no Q) is always
|
||||||
|
# at the top.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${!!Q}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Search settings"
|
||||||
|
- eraseText: 40
|
||||||
|
- inputText: ${Q}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# Now and then a typed character goes missing: type it again.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: ${ROW}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Search settings"
|
||||||
|
- eraseText: 40
|
||||||
|
- inputText: ${Q}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# A tap while the list still coasts from the swipe only stops it.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- tapOn: ${ROW}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
# On the phone the page replaces the list: make sure it opened before
|
||||||
|
# scrolling (else the swipes below scroll the list away).
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: ${ROW}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Back"
|
||||||
|
timeout: 10000
|
||||||
|
# PROOF can be further down the page: scroll the page (the right-hand pane
|
||||||
|
# on the iPad) until it shows.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${PROOF}
|
||||||
|
times: 6
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 75%,80%
|
||||||
|
end: 75%,40%
|
||||||
|
duration: 600
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${PROOF}
|
||||||
|
times: 6
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,80%
|
||||||
|
end: 50%,40%
|
||||||
|
duration: 600
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${PROOF}
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-${NAME}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Signed in: sign out of the active account from the end of Settings.
|
||||||
|
# On the iPad the Settings list is the left column; scroll that one.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Sign out"
|
||||||
|
times: 12
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 30%,85%
|
||||||
|
end: 30%,30%
|
||||||
|
duration: 500
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Sign out"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: "Sign out"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "The account is removed from this device.*"
|
||||||
|
timeout: 10000
|
||||||
|
# The dialog's button, not the Settings row of the same name behind it.
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign out"
|
||||||
|
rightOf: "Cancel"
|
||||||
|
below: "The account is removed from this device.*"
|
||||||
|
- takeScreenshot: sign-out-tapped
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
|
||||||
|
# Keeps the app's data between flows; signs in only when needed.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
permissions:
|
||||||
|
notifications: allow
|
||||||
|
photos: allow
|
||||||
|
camera: allow
|
||||||
|
# Answer the notification prompt (if the app asks) before turning to
|
||||||
|
# landscape: rotated, taps on system alerts miss.
|
||||||
|
- setOrientation: PORTRAIT
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
|
||||||
|
timeout: 120000
|
||||||
|
- runFlow: allow-notifications.yaml
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually|Inbox"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
file: password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: allow-notifications.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- runFlow: go-to-inbox.yaml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any
|
||||||
|
# non-empty value: the account needs a one-time code).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "you@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: stalwart-login-page
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "you@example.com"
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
- tapOn: "••••••••"
|
||||||
|
- inputText: ${PASSWORD}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TOTP == 'yes'}
|
||||||
|
commands:
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- tapOn: "000000"
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign in"
|
||||||
|
index: 1
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Fresh install state, rotated for the landscape leg, on the sign-in choices.
|
||||||
|
# env CAMERA: allow (default) or deny.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
# A system prompt left over from an earlier flow ("Open in …?") stays on
|
||||||
|
# screen across app launches.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
clearKeychain: true
|
||||||
|
permissions:
|
||||||
|
notifications: allow
|
||||||
|
camera: ${CAMERA || 'allow'}
|
||||||
|
photos: allow
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
|
# A cold start of a release build on a CI simulator can be slow.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 120000
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# The two-factor field is showing (it focuses itself): type a fresh code from
|
||||||
|
# the helper and sign in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code"
|
||||||
|
timeout: 30000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
# The number pad has no return key.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Sign in"
|
||||||
|
commands:
|
||||||
|
- hideKeyboard
|
||||||
|
- tapOn: "Sign in"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Type a secret into the field with id FIELD (TAP=no for a field that focuses
|
||||||
|
# itself; see type.yaml for why the first character goes alone). The value
|
||||||
|
# can't be checked (a secure field shows dots) and must never be shown, so a
|
||||||
|
# lost keystroke surfaces as a failed sign-in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- evalScript: ${output.secretHead = TEXT.slice(0, 1); output.secretTail = TEXT.slice(1)}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TAP != 'no'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: ${FIELD}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${output.secretHead}
|
||||||
|
# A short pause: wait for something that never shows.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "zz-no-such-element-zz"
|
||||||
|
timeout: 1000
|
||||||
|
optional: true
|
||||||
|
- inputText: ${output.secretTail}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
|
||||||
|
# pages in the auth session), and check it arrived as typed. Now and then a
|
||||||
|
# stray character lands in front of it on the CI simulator; then erase the
|
||||||
|
# field and type again. The caret is still at the end of what was just
|
||||||
|
# typed, so eraseText (which deletes before the caret) empties it; selecting
|
||||||
|
# the text with a long press didn't work and appended instead. SECRET=yes
|
||||||
|
# skips the check (a password field shows dots).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: ${HINT}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${TEXT}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${SECRET != 'yes'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${TEXT}
|
||||||
|
times: 2
|
||||||
|
commands:
|
||||||
|
- eraseText: 150
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- inputText: ${TEXT}
|
||||||
|
- assertVisible: ${TEXT}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Type TEXT into the field with id FIELD and check it arrived in full.
|
||||||
|
#
|
||||||
|
# iOS drops keystrokes in two situations seen on the CI simulator: while the
|
||||||
|
# edit menu (the "AutoFill" bubble) opens, which happens when a flow taps a
|
||||||
|
# field that already has focus, and right after the first character, while
|
||||||
|
# the field re-renders. So: no tap for a field that focuses itself (TAP=no;
|
||||||
|
# Maestro can't see a React Native field's focus), type the first character,
|
||||||
|
# wait until it shows, then type the rest. Retyped up to twice.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- evalScript: ${output.typeHead = TEXT.slice(0, 1); output.typeTail = TEXT.slice(1)}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TAP != 'no'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: ${FIELD}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# ERASE=yes: the field already holds text.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ERASE == 'yes'}
|
||||||
|
commands:
|
||||||
|
- runFlow: clear-field.yaml
|
||||||
|
- inputText: ${output.typeHead}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: "${output.typeHead}.*"
|
||||||
|
timeout: 5000
|
||||||
|
optional: true
|
||||||
|
- inputText: ${output.typeTail}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: ${TEXT_PATTERN || TEXT}
|
||||||
|
commands:
|
||||||
|
- retry:
|
||||||
|
maxRetries: 2
|
||||||
|
commands:
|
||||||
|
- runFlow: clear-field.yaml
|
||||||
|
- inputText: ${output.typeHead}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: "${output.typeHead}.*"
|
||||||
|
timeout: 5000
|
||||||
|
optional: true
|
||||||
|
- inputText: ${output.typeTail}
|
||||||
|
- assertVisible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: ${TEXT_PATTERN || TEXT}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# The Bulwark webmail's sign-in page inside the auth session (the app's
|
||||||
|
# "Sign in via webmail" hand-off). env LOGIN, TOTP=yes for a TOTP account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "user@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: webmail-login-page
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "user@example.com"
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "Enter your password"
|
||||||
|
TEXT: ${PASSWORD}
|
||||||
|
SECRET: "yes"
|
||||||
|
- tapOn: "Sign in"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TOTP == 'yes'}
|
||||||
|
commands:
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "000000"
|
||||||
|
timeout: 30000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- tapOn: "000000"
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
- tapOn: "Sign in|Verify"
|
||||||
|
|
||||||
|
# Before handing over, the webmail asks whether the app may take the sign-in.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Hand over to the app"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: webmail-handover-prompt
|
||||||
|
- tapOn: "Hand over to the app"
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import 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';
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
startCalendarReminderTapHandling,
|
startCalendarReminderTapHandling,
|
||||||
} from './src/lib/calendar-notifications';
|
} from './src/lib/calendar-notifications';
|
||||||
import { startLivenessMonitor } from './src/lib/connection-liveness';
|
import { startLivenessMonitor } from './src/lib/connection-liveness';
|
||||||
|
import { startDeviceSyncTriggers } from './src/device-sync/app/triggers';
|
||||||
import {
|
import {
|
||||||
setPendingCalendarOpen,
|
setPendingCalendarOpen,
|
||||||
type CalendarReminderTarget,
|
type CalendarReminderTarget,
|
||||||
@@ -38,15 +40,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 +62,13 @@ 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 { 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 { 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';
|
||||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||||
@@ -71,19 +77,35 @@ import { ToastHost } from './src/components/ToastHost';
|
|||||||
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
||||||
import { AppIconBadge } from './src/components/AppIconBadge';
|
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||||
import { getEmails } from './src/api/email';
|
import { getEmails } from './src/api/email';
|
||||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
||||||
|
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||||
|
import {
|
||||||
|
acceptSignInLink,
|
||||||
|
handleDeepLink,
|
||||||
|
parseDeepLink,
|
||||||
|
routeParkedSignInLink,
|
||||||
|
shareToDeepLink,
|
||||||
|
type DeepLink,
|
||||||
|
} from './src/navigation/linking';
|
||||||
|
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
||||||
|
import { MAX_ACCOUNTS } from './src/lib/account-utils';
|
||||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||||
import { useOutboxStore } from './src/stores/outbox-store';
|
import { useOutboxStore } from './src/stores/outbox-store';
|
||||||
import { runOfflineSync } from './src/lib/offline-sync';
|
import { runOfflineSync } from './src/lib/offline-sync';
|
||||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||||
|
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
|
||||||
import { useColors } from './src/theme/colors';
|
import { useColors } from './src/theme/colors';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||||
|
|
||||||
|
// The launch URL is read once per process: Android hands the same intent back
|
||||||
|
// on every getInitialURL(), and a sign-in link must not run twice.
|
||||||
|
let initialSignInLinkRead = false;
|
||||||
|
|
||||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||||
if (!navigationRef.isReady()) return;
|
if (!navigationRef.isReady()) return;
|
||||||
|
|
||||||
@@ -103,9 +125,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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,16 +156,27 @@ async function openCalendarReminder(target: CalendarReminderTarget): Promise<voi
|
|||||||
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
||||||
// Android share-sheet payloads all end up here once the navigator is ready.
|
// Android share-sheet payloads all end up here once the navigator is ready.
|
||||||
async function openDeepLink(link: DeepLink): Promise<void> {
|
async function openDeepLink(link: DeepLink): Promise<void> {
|
||||||
await handleDeepLink(link, {
|
const opened = await handleDeepLink(link, {
|
||||||
navigation: navigationRef,
|
navigation: navigationRef,
|
||||||
resolveThreadId: async (emailId) => {
|
resolveThreadId: async (emailId, jmapAccountId) => {
|
||||||
try {
|
try {
|
||||||
const [email] = await getEmails([emailId]);
|
const [email] = await getEmails([emailId], jmapAccountId);
|
||||||
return email?.threadId ?? null;
|
return email?.threadId ?? null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openDraft: async (emailId, jmapAccountId) => {
|
||||||
|
try {
|
||||||
|
// Cache first, so a draft read before still opens offline.
|
||||||
|
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
|
||||||
|
if (!navigationRef.isReady()) return false;
|
||||||
|
navigationRef.navigate('Compose', { draft });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
switchAccount: async (accountId) => {
|
switchAccount: async (accountId) => {
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
if (auth.activeAccountId === accountId) return true;
|
if (auth.activeAccountId === accountId) return true;
|
||||||
@@ -150,6 +185,9 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
|||||||
return useAuthStore.getState().activeAccountId === accountId;
|
return useAuthStore.getState().activeAccountId === accountId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// A widget or a link elsewhere brought the app up for this; say it failed
|
||||||
|
// rather than leave the user wondering why the app just opened.
|
||||||
|
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
||||||
@@ -211,21 +249,43 @@ 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 />
|
||||||
<AppIconBadge />
|
<AppIconBadge />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
|
screenListeners={perfMarksEnabled() ? ({ route }) => ({
|
||||||
|
tabPress: () => perfStart('tab-switch'),
|
||||||
|
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
|
||||||
|
}) : undefined}
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Tabs out of view stop re-rendering until they are shown again.
|
// Tabs out of view stop re-rendering until they are shown again.
|
||||||
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,
|
||||||
@@ -243,6 +303,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: {
|
||||||
@@ -259,36 +320,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),
|
||||||
@@ -301,9 +340,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),
|
||||||
@@ -319,6 +359,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),
|
||||||
@@ -333,6 +374,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} />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -370,6 +412,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) {
|
||||||
@@ -383,9 +426,20 @@ export default function App() {
|
|||||||
// Calendar reminders are kept scheduled from launch, not only once the
|
// Calendar reminders are kept scheduled from launch, not only once the
|
||||||
// Calendar tab has been opened.
|
// Calendar tab has been opened.
|
||||||
startCalendarNotificationSync();
|
startCalendarNotificationSync();
|
||||||
|
// Device sync (Android, #34): live changes, the app's own edits and the
|
||||||
|
// foreground ask Android to sync; accounts are checked against Android's.
|
||||||
|
startDeviceSyncTriggers();
|
||||||
return useNetworkStore.getState().init();
|
return useNetworkStore.getState().init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Home-screen widgets follow the signed-in data; once the last account is
|
||||||
|
// gone they are wiped so no mail stays visible on the launcher.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAuthenticated) return startWidgetSync();
|
||||||
|
if (hasRestoredSession) void signOutWidgets();
|
||||||
|
return undefined;
|
||||||
|
}, [isAuthenticated, hasRestoredSession]);
|
||||||
|
|
||||||
// When the network flips back on while we're authenticated-but-offline
|
// When the network flips back on while we're authenticated-but-offline
|
||||||
// (no live JMAP session), retry the session so the user lands back on
|
// (no live JMAP session), retry the session so the user lands back on
|
||||||
// live data without needing to relaunch.
|
// live data without needing to relaunch.
|
||||||
@@ -542,6 +596,52 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App,
|
||||||
|
// `bulwarkmail://connect?…`) work signed out too, so unlike the links above
|
||||||
|
// they are listened for from launch. They are parked for the login screen
|
||||||
|
// (the signed-out one takes them as it mounts), marked as coming from
|
||||||
|
// outside the app: any page or app can fire one, so the login screen asks
|
||||||
|
// before it redeems or opens anything.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!initialSignInLinkRead) {
|
||||||
|
initialSignInLinkRead = true;
|
||||||
|
void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); });
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Signed in, a sign-in link adds an account: open Add account, whose login
|
||||||
|
// screen takes the parked link (or says why a refused one won't be used).
|
||||||
|
// On a cold start the navigator mounts right after the auth gate flips, so
|
||||||
|
// wait for it like a reminder tap does.
|
||||||
|
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
|
||||||
|
const addAccountForLink = isAuthenticated && signInLinkPending;
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!addAccountForLink) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
if (cancelled || !navigationRef.isReady()) return;
|
||||||
|
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
|
||||||
|
if (route === 'account-limit') {
|
||||||
|
// Said like the Add account buttons say it, instead of opening a
|
||||||
|
// screen whose sign-in can only fail.
|
||||||
|
const t = useLocaleStore.getState().t;
|
||||||
|
toast.error(
|
||||||
|
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
|
||||||
|
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route === 'add-account') navigationRef.navigate('AddAccount');
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [addAccountForLink]);
|
||||||
|
|
||||||
// Re-register the device with the configured relay once authenticated,
|
// Re-register the device with the configured relay once authenticated,
|
||||||
// and whenever the FCM token rotates. Honours the user's notification
|
// and whenever the FCM token rotates. Honours the user's notification
|
||||||
// preference - flipping it off tears down THIS account's subscription so
|
// preference - flipping it off tears down THIS account's subscription so
|
||||||
@@ -598,6 +698,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.
|
||||||
@@ -749,10 +884,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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -153,7 +153,7 @@ Open split them by tick; the P columns count the priority tags on those items
|
|||||||
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
||||||
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
||||||
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||||
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
|
| #34 Native contacts/calendar sync adapters | done on Android: Settings → Contacts / Calendar → "Sync to this device" syncs both ways with the device's Contacts and Calendar apps ([design](docs/device-sync.md)); iOS has no API for it |
|
||||||
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
||||||
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
||||||
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -22,14 +27,15 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
|||||||
- Calendar (basic)
|
- Calendar (basic)
|
||||||
- Contacts (basic)
|
- Contacts (basic)
|
||||||
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
||||||
|
- Android: contacts and calendars sync both ways with the phone's Contacts and Calendar apps (Settings → Contacts / Calendar → Sync to this device)
|
||||||
- In-app sideload updates from GitHub Releases
|
- In-app sideload updates from GitHub Releases
|
||||||
|
|
||||||
## What's missing or rough
|
## What's missing or rough
|
||||||
|
|
||||||
- iOS builds, but push notifications and client certificates 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
|
||||||
|
|
||||||
@@ -45,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'.
|
||||||
@@ -156,6 +156,37 @@ android {
|
|||||||
|
|
||||||
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')
|
||||||
@@ -240,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,6 +311,11 @@ dependencies {
|
|||||||
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
||||||
implementation("org.unifiedpush.android:connector:3.3.5")
|
implementation("org.unifiedpush.android:connector:3.3.5")
|
||||||
|
|
||||||
|
// JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
|
||||||
|
// android.jar only stubs org.json, so the tests use the real one.
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
|
|
||||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -3,5 +3,8 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
||||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
|
||||||
|
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
|
||||||
|
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
|
||||||
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package com.anonymous.bulwarkmobile.sync
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug builds only: lets adb set up device sync accounts and drive the
|
||||||
|
* provider bridge without the settings UI or JS, for the device tests in
|
||||||
|
* docs/device-sync.md ("Testing"). Protected by android.permission.DUMP,
|
||||||
|
* which only the shell and the system hold. Syncs themselves are triggered
|
||||||
|
* with `adb shell requestsync`.
|
||||||
|
*
|
||||||
|
* adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \
|
||||||
|
* --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \
|
||||||
|
* --es enable contacts,calendar
|
||||||
|
*
|
||||||
|
* Commands: `ensure` (name, registry, optional enable), `enable` (name,
|
||||||
|
* authority, optional --ez on false), `remove` (name), `list`, and the
|
||||||
|
* provider calls `query` and `batch` (name, authority, and the
|
||||||
|
* `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares
|
||||||
|
* the shell's quoting, or pushed to the app's external files directory and
|
||||||
|
* named by `file` when it is too long for a command line), `syncstate`
|
||||||
|
* (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and
|
||||||
|
* the sync-problem notification, `problem` (name, title, text, uri, optional
|
||||||
|
* channel) and `clearproblem` (name). Results go to logcat (tag
|
||||||
|
* BulwarkDeviceSync) and the broadcast's result data.
|
||||||
|
*/
|
||||||
|
class DeviceSyncDebugReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val pending = goAsync()
|
||||||
|
// Provider calls block; keep them off the main thread.
|
||||||
|
thread(name = "DeviceSyncDebugReceiver") {
|
||||||
|
val result = try {
|
||||||
|
run(context, intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"error: ${e.javaClass.simpleName}: ${e.message}"
|
||||||
|
}
|
||||||
|
Log.i(TAG, "debug command: $result")
|
||||||
|
pending.resultData = result
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(context: Context, intent: Intent): String {
|
||||||
|
val name = intent.getStringExtra("name")
|
||||||
|
return when (intent.getStringExtra("cmd")) {
|
||||||
|
"ensure" -> {
|
||||||
|
val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!)
|
||||||
|
for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) {
|
||||||
|
DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true)
|
||||||
|
}
|
||||||
|
"created=$created"
|
||||||
|
}
|
||||||
|
"enable" -> {
|
||||||
|
val on = intent.getBooleanExtra("on", true)
|
||||||
|
DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on)
|
||||||
|
"enabled=$on"
|
||||||
|
}
|
||||||
|
"remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}"
|
||||||
|
"list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" }
|
||||||
|
"query" -> io(context, intent) { it.query(json(context, intent)) }
|
||||||
|
"batch" -> io(context, intent) { it.applyBatch(json(context, intent)) }
|
||||||
|
"syncstate" -> io(context, intent) { it.readSyncState().toString() }
|
||||||
|
"photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use {
|
||||||
|
it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString()
|
||||||
|
}
|
||||||
|
"problem" -> {
|
||||||
|
SyncProblemNotifier.show(
|
||||||
|
context,
|
||||||
|
name!!,
|
||||||
|
intent.getStringExtra("title").orEmpty(),
|
||||||
|
intent.getStringExtra("text").orEmpty(),
|
||||||
|
intent.getStringExtra("uri").orEmpty(),
|
||||||
|
intent.getStringExtra("channel"),
|
||||||
|
)
|
||||||
|
"shown"
|
||||||
|
}
|
||||||
|
"clearproblem" -> {
|
||||||
|
SyncProblemNotifier.clear(context, name!!)
|
||||||
|
"cleared"
|
||||||
|
}
|
||||||
|
else -> "unknown cmd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String =
|
||||||
|
ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call)
|
||||||
|
|
||||||
|
private fun json(context: Context, intent: Intent): String {
|
||||||
|
intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() }
|
||||||
|
return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authority(which: String) = when (which) {
|
||||||
|
"contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||||
|
"calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||||
|
else -> which
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BulwarkDeviceSync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +2,22 @@
|
|||||||
<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. -->
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
@@ -13,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"/>
|
||||||
@@ -28,6 +40,13 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
<data android:scheme="bulwarkmobile"/>
|
<data android:scheme="bulwarkmobile"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Sign-in links from the webmail (bulwarkmail://pair?..., bulwarkmail://connect?...). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="bulwarkmail"/>
|
||||||
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<action android:name="android.intent.action.SENDTO"/>
|
<action android:name="android.intent.action.SENDTO"/>
|
||||||
@@ -59,12 +78,263 @@
|
|||||||
</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"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
||||||
|
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
|
||||||
|
They run in the app's main process on purpose: the adapters start the sync engine as a
|
||||||
|
headless JS task in the same React Native runtime as the UI. -->
|
||||||
|
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
|
||||||
|
</service>
|
||||||
|
<service android:name=".sync.ContactsSyncService" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
|
||||||
|
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
|
||||||
|
</service>
|
||||||
|
<service android:name=".sync.CalendarSyncService" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
|
||||||
|
</service>
|
||||||
|
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
|
||||||
|
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||||
|
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||||
|
</receiver>
|
||||||
|
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||||
|
</receiver>
|
||||||
|
<!-- END WIDGETS -->
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.anonymous.bulwarkmobile
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
@@ -12,6 +13,10 @@ class BulwarkFcmPackage : ReactPackage {
|
|||||||
add(BulwarkFcmModule(reactContext))
|
add(BulwarkFcmModule(reactContext))
|
||||||
add(BulwarkUnifiedPushModule(reactContext))
|
add(BulwarkUnifiedPushModule(reactContext))
|
||||||
add(BulwarkClientCertModule(reactContext))
|
add(BulwarkClientCertModule(reactContext))
|
||||||
|
add(BulwarkDeviceSyncModule(reactContext))
|
||||||
|
add(BulwarkWidgetsModule(reactContext))
|
||||||
|
add(BulwarkPdfModule(reactContext))
|
||||||
|
add(BulwarkBuildModule(reactContext))
|
||||||
// The bridge registers React Native's own module under this name.
|
// The bridge registers React Native's own module under this name.
|
||||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.os.Bundle
|
|||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
@@ -26,12 +27,16 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
|
|
||||||
val data = message.data
|
val data = message.data
|
||||||
|
|
||||||
|
// Contact and calendar changes become syncs of the Android accounts
|
||||||
|
// they feed (device sync, #34); only mail needs the push task.
|
||||||
|
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||||
|
|
||||||
// Hand off to JS via a headless task only when the app isn't already
|
// Hand off to JS via a headless task only when the app isn't already
|
||||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||||
// while foreground. When the app is open, the main JS instance already
|
// while foreground. When the app is open, the main JS instance already
|
||||||
// receives JMAP push directly, so the fcm:message event below is
|
// receives JMAP push directly, so the fcm:message event below is
|
||||||
// sufficient for it to refresh state.
|
// sufficient for it to refresh state.
|
||||||
if (!isAppInForeground()) {
|
if (mail && !isAppInForeground()) {
|
||||||
startHeadlessTask(applicationContext, data)
|
startHeadlessTask(applicationContext, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||||
|
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||||
|
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||||
|
|
||||||
|
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||||
|
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||||
|
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||||
|
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||||
|
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||||
|
|
||||||
|
// Dismiss the notification immediately from the tray
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (notificationId != null) {
|
||||||
|
manager.cancel(notificationId, notificationId.hashCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||||
|
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||||
|
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||||
|
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
val app = context.applicationContext as Application
|
||||||
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
// Safety timeout to ensure pendingResult.finish() is always called
|
||||||
|
val timeoutRunnable = Runnable {
|
||||||
|
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||||
|
try {
|
||||||
|
pendingResult.finish()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||||
|
|
||||||
|
try {
|
||||||
|
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||||
|
if (!reactContext.hasActiveReactInstance()) {
|
||||||
|
Log.w(TAG, "React context does not have active instance")
|
||||||
|
mainHandler.removeCallbacks(timeoutRunnable)
|
||||||
|
try {
|
||||||
|
pendingResult.finish()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
return@withReadyReactContext
|
||||||
|
}
|
||||||
|
|
||||||
|
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||||
|
val taskBundle = Bundle().apply {
|
||||||
|
putString("action", action)
|
||||||
|
putString("emailId", emailId)
|
||||||
|
putString("accountId", accountId)
|
||||||
|
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||||
|
// Keep raw extras as well
|
||||||
|
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||||
|
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||||
|
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||||
|
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = HeadlessJsTaskConfig(
|
||||||
|
"BulwarkNotificationAction",
|
||||||
|
Arguments.fromBundle(taskBundle),
|
||||||
|
15_000L,
|
||||||
|
true // allowExecutionInForeground
|
||||||
|
)
|
||||||
|
|
||||||
|
val listener = object : HeadlessJsTaskEventListener {
|
||||||
|
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||||
|
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||||
|
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||||
|
tasks.removeTaskEventListener(this)
|
||||||
|
mainHandler.removeCallbacks(timeoutRunnable)
|
||||||
|
try {
|
||||||
|
pendingResult.finish()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tasks.addTaskEventListener(listener)
|
||||||
|
|
||||||
|
HeadlessJs.startTask(
|
||||||
|
tasks,
|
||||||
|
config,
|
||||||
|
onStarted = { taskId ->
|
||||||
|
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||||
|
},
|
||||||
|
onRefused = { e ->
|
||||||
|
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||||
|
tasks.removeTaskEventListener(listener)
|
||||||
|
mainHandler.removeCallbacks(timeoutRunnable)
|
||||||
|
try {
|
||||||
|
pendingResult.finish()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||||
|
mainHandler.removeCallbacks(timeoutRunnable)
|
||||||
|
try {
|
||||||
|
pendingResult.finish()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BulwarkNotifAction"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||||
|
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||||
|
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||||
|
* its own so they zoom, asking for each page as it scrolls into view.
|
||||||
|
*
|
||||||
|
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||||
|
* every call runs on one background thread, and the open document is kept
|
||||||
|
* between calls for the file the preview shows.
|
||||||
|
*/
|
||||||
|
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||||
|
private var openPath: String? = null
|
||||||
|
private var descriptor: ParcelFileDescriptor? = null
|
||||||
|
private var renderer: PdfRenderer? = null
|
||||||
|
|
||||||
|
override fun getName(): String = "BulwarkPdf"
|
||||||
|
|
||||||
|
override fun invalidate() {
|
||||||
|
worker.execute { closeDocument() }
|
||||||
|
worker.shutdown()
|
||||||
|
super.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||||
|
@ReactMethod
|
||||||
|
fun open(fileUri: String, promise: Promise) {
|
||||||
|
worker.execute {
|
||||||
|
try {
|
||||||
|
val doc = document(fileUri)
|
||||||
|
val pages = Arguments.createArray()
|
||||||
|
for (i in 0 until doc.pageCount) {
|
||||||
|
doc.openPage(i).use { page ->
|
||||||
|
pages.pushMap(Arguments.createMap().apply {
|
||||||
|
putInt("width", page.width)
|
||||||
|
putInt("height", page.height)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Password-protected; PdfRenderer can't open those.
|
||||||
|
closeDocument()
|
||||||
|
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
closeDocument()
|
||||||
|
promise.reject("E_PDF_OPEN", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||||
|
@ReactMethod
|
||||||
|
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||||
|
worker.execute {
|
||||||
|
try {
|
||||||
|
val doc = document(fileUri)
|
||||||
|
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||||
|
doc.openPage(index).use { page ->
|
||||||
|
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||||
|
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||||
|
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
try {
|
||||||
|
// Pages are transparent where nothing is drawn.
|
||||||
|
bitmap.eraseColor(Color.WHITE)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
val out = fileOf(outUri)
|
||||||
|
// The preview's folder is gone once it closed; a page
|
||||||
|
// still queued then must not bring it back.
|
||||||
|
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
|
||||||
|
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(outUri)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
promise.reject("E_PDF_RENDER", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the document the preview showed. */
|
||||||
|
@ReactMethod
|
||||||
|
fun close(fileUri: String, promise: Promise) {
|
||||||
|
worker.execute {
|
||||||
|
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||||
|
promise.resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun document(fileUri: String): PdfRenderer {
|
||||||
|
val path = fileOf(fileUri).absolutePath
|
||||||
|
renderer?.let { if (openPath == path) return it }
|
||||||
|
closeDocument()
|
||||||
|
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
try {
|
||||||
|
val doc = PdfRenderer(fd)
|
||||||
|
descriptor = fd
|
||||||
|
renderer = doc
|
||||||
|
openPath = path
|
||||||
|
return doc
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
fd.close()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeDocument() {
|
||||||
|
try { renderer?.close() } catch (_: Throwable) {}
|
||||||
|
try { descriptor?.close() } catch (_: Throwable) {}
|
||||||
|
renderer = null
|
||||||
|
descriptor = null
|
||||||
|
openPath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileOf(uri: String): File {
|
||||||
|
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||||
|
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||||
|
private const val MAX_PAGE_PX = 2480
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,16 +11,11 @@ import android.os.Bundle
|
|||||||
import android.os.PersistableBundle
|
import android.os.PersistableBundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.facebook.react.ReactApplication
|
|
||||||
import com.facebook.react.ReactInstanceEventListener
|
|
||||||
import com.facebook.react.bridge.ReactContext
|
import com.facebook.react.bridge.ReactContext
|
||||||
import com.facebook.react.bridge.UiThreadUtil
|
|
||||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
|
||||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
|
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
|
||||||
@@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
|||||||
|
|
||||||
override fun onStartJob(params: JobParameters): Boolean {
|
override fun onStartJob(params: JobParameters): Boolean {
|
||||||
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
|
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
|
||||||
withReadyReactContext { startTask(it, config, params) }
|
// JobService callbacks run on the main thread, where HeadlessJs wants to be called.
|
||||||
|
HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
|||||||
tasks.addTaskEventListener(this)
|
tasks.addTaskEventListener(this)
|
||||||
taskContext = tasks
|
taskContext = tasks
|
||||||
}
|
}
|
||||||
UiThreadUtil.runOnUiThread {
|
HeadlessJs.startTask(
|
||||||
try {
|
tasks,
|
||||||
val taskId = tasks.startTask(config)
|
config,
|
||||||
running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
|
onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) },
|
||||||
} catch (e: IllegalStateException) {
|
onRefused = { e ->
|
||||||
// The app came to the foreground since the push arrived and
|
// The app came to the foreground since the push arrived and
|
||||||
// syncs on its own there.
|
// syncs on its own there.
|
||||||
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
|
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
|
||||||
jobFinished(params, false)
|
jobFinished(params, false)
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls [onReady] once with a React context whose JS instance is up,
|
|
||||||
* starting React Native if the process was woken for this job.
|
|
||||||
*
|
|
||||||
* A context exists before its instance is ready, and a task started on
|
|
||||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
|
||||||
* not available" and waits for the timeout), which happened when several
|
|
||||||
* pushes arrived while the app was starting. So the listener goes in
|
|
||||||
* first and the ready check after it; whichever sees the ready instance
|
|
||||||
* first runs the task.
|
|
||||||
*/
|
|
||||||
private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
|
|
||||||
val done = AtomicBoolean(false)
|
|
||||||
val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
|
|
||||||
val app = application as ReactApplication
|
|
||||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
|
||||||
val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
|
|
||||||
val listener = object : ReactInstanceEventListener {
|
|
||||||
override fun onReactContextInitialized(context: ReactContext) {
|
|
||||||
host.removeReactInstanceEventListener(this)
|
|
||||||
runOnce(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
host.addReactInstanceEventListener(listener)
|
|
||||||
val current = host.currentReactContext
|
|
||||||
if (current != null && current.hasActiveReactInstance()) {
|
|
||||||
host.removeReactInstanceEventListener(listener)
|
|
||||||
runOnce(current)
|
|
||||||
} else {
|
|
||||||
host.start()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val manager = app.reactNativeHost.reactInstanceManager
|
|
||||||
val listener = object : ReactInstanceEventListener {
|
|
||||||
override fun onReactContextInitialized(context: ReactContext) {
|
|
||||||
manager.removeReactInstanceEventListener(this)
|
|
||||||
runOnce(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
manager.addReactInstanceEventListener(listener)
|
|
||||||
val current = manager.currentReactContext
|
|
||||||
if (current != null && current.hasActiveReactInstance()) {
|
|
||||||
manager.removeReactInstanceEventListener(listener)
|
|
||||||
runOnce(current)
|
|
||||||
} else if (!manager.hasStartedCreatingInitialContext()) {
|
|
||||||
manager.createReactContextInBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.anonymous.bulwarkmobile
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -31,7 +32,10 @@ class BulwarkUnifiedPushService : PushService() {
|
|||||||
BulwarkMessagingService.ensureChannel(this)
|
BulwarkMessagingService.ensureChannel(this)
|
||||||
val data = parsePayload(message.content) ?: return
|
val data = parsePayload(message.content) ?: return
|
||||||
|
|
||||||
if (!BulwarkMessagingService.isAppInForeground()) {
|
// Same routing as FCM: contact and calendar changes request device
|
||||||
|
// syncs, and only mail starts the push task.
|
||||||
|
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||||
|
if (mail && !BulwarkMessagingService.isAppInForeground()) {
|
||||||
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import com.facebook.react.ReactApplication
|
||||||
|
import com.facebook.react.ReactInstanceEventListener
|
||||||
|
import com.facebook.react.bridge.ReactContext
|
||||||
|
import com.facebook.react.bridge.UiThreadUtil
|
||||||
|
import com.facebook.react.interfaces.TaskInterface
|
||||||
|
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||||
|
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||||
|
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Running a headless JS task without HeadlessJsTaskService: starting React
|
||||||
|
* Native when the process was woken without any UI, and starting the task on
|
||||||
|
* its context. Shared by the push job (BulwarkPushJobService) and the device
|
||||||
|
* sync adapters.
|
||||||
|
*/
|
||||||
|
object HeadlessJs {
|
||||||
|
private const val TAG = "HeadlessJs"
|
||||||
|
|
||||||
|
/** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */
|
||||||
|
private const val MAX_WAIT_MS = 600_000L
|
||||||
|
private const val WATCH_MS = 1_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls [onReady] once with a React context whose JS instance is up,
|
||||||
|
* starting React Native if the process was woken for this work.
|
||||||
|
*
|
||||||
|
* A context exists before its instance is ready, and a task started on
|
||||||
|
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||||
|
* not available" and waits for the timeout), which happened when several
|
||||||
|
* pushes arrived while the app was starting. So the listener goes in
|
||||||
|
* first and the ready check after it; whichever sees the ready instance
|
||||||
|
* first runs the task.
|
||||||
|
*
|
||||||
|
* The wait ends once, and its listener goes with it: with a ready context,
|
||||||
|
* or without one when React fails to start (the start task faults) or
|
||||||
|
* after [MAX_WAIT_MS], when no caller can still be waiting. A context that
|
||||||
|
* comes up later runs nothing of it, so waits given up on don't pile up on
|
||||||
|
* the host with everything their [onReady] holds. (A caller that gives up
|
||||||
|
* sooner, like the sync adapter at its deadline, checks that in [onReady].)
|
||||||
|
*
|
||||||
|
* Call it on the main thread; [onReady] runs there too. MainApplication
|
||||||
|
* creates the ReactHost on first use without a lock (ExpoReactHostFactory),
|
||||||
|
* so a background caller racing MainActivity could create a second one,
|
||||||
|
* with a second JS runtime.
|
||||||
|
*
|
||||||
|
* Returns React Native's start task when it had to be started, so a caller
|
||||||
|
* can notice a start that fails (a debug build with no Metro to load the
|
||||||
|
* bundle from), or null when the context was already up.
|
||||||
|
*/
|
||||||
|
@MainThread
|
||||||
|
fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface<Void>? {
|
||||||
|
val reactApp = app as ReactApplication
|
||||||
|
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||||
|
val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||||
|
val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) }
|
||||||
|
host.addReactInstanceEventListener(wait)
|
||||||
|
val current = host.currentReactContext
|
||||||
|
if (current != null && current.hasActiveReactInstance()) {
|
||||||
|
wait.onReactContextInitialized(current)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return host.start().also { wait.watch(it) }
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val manager = reactApp.reactNativeHost.reactInstanceManager
|
||||||
|
val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) }
|
||||||
|
manager.addReactInstanceEventListener(wait)
|
||||||
|
val current = manager.currentReactContext
|
||||||
|
if (current != null && current.hasActiveReactInstance()) {
|
||||||
|
wait.onReactContextInitialized(current)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground()
|
||||||
|
wait.watch(null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */
|
||||||
|
private class ContextWait(
|
||||||
|
private val onReady: (ReactContext) -> Unit,
|
||||||
|
private val removeListener: (ReactInstanceEventListener) -> Unit,
|
||||||
|
) : ReactInstanceEventListener {
|
||||||
|
private val ended = AtomicBoolean(false)
|
||||||
|
|
||||||
|
override fun onReactContextInitialized(context: ReactContext) {
|
||||||
|
if (end()) onReady(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */
|
||||||
|
fun watch(boot: TaskInterface<Void>?) {
|
||||||
|
val main = Handler(Looper.getMainLooper())
|
||||||
|
val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS
|
||||||
|
main.postDelayed(object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (ended.get()) return
|
||||||
|
val faulted = boot?.isFaulted() == true
|
||||||
|
if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) {
|
||||||
|
main.postDelayed(this, WATCH_MS)
|
||||||
|
} else if (end()) {
|
||||||
|
val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms"
|
||||||
|
Log.w(TAG, "$why; no longer waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, WATCH_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun end(): Boolean {
|
||||||
|
if (!ended.compareAndSet(false, true)) return false
|
||||||
|
removeListener(this)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts [config] on the UI thread, where HeadlessJsTaskContext wants it.
|
||||||
|
* [onStarted] gets the task id in that same UI-thread turn, so before any
|
||||||
|
* finish callback (HeadlessJsTaskContext posts those to the UI thread).
|
||||||
|
* [onRefused] gets the refusal when the context is resumed and [config]
|
||||||
|
* isn't allowed in the foreground.
|
||||||
|
*/
|
||||||
|
fun startTask(
|
||||||
|
tasks: HeadlessJsTaskContext,
|
||||||
|
config: HeadlessJsTaskConfig,
|
||||||
|
onStarted: (taskId: Int) -> Unit,
|
||||||
|
onRefused: (IllegalStateException) -> Unit,
|
||||||
|
) {
|
||||||
|
UiThreadUtil.runOnUiThread {
|
||||||
|
val taskId = try {
|
||||||
|
tasks.startTask(config)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
onRefused(e)
|
||||||
|
return@runOnUiThread
|
||||||
|
}
|
||||||
|
onStarted(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user