mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d3f4aec5a | ||
|
|
8303ff769f | ||
|
|
7078dfe806 | ||
|
|
f62849d1fe | ||
|
|
4bb846b726 | ||
|
|
2618680b21 | ||
|
|
533f176e88 | ||
|
|
da72415ef3 | ||
|
|
0109273037 | ||
|
|
33eb8f99e5 | ||
|
|
721c2e34a1 | ||
|
|
e06d92d88f | ||
|
|
42e89122bd | ||
|
|
33fc33910a | ||
|
|
dde504f825 | ||
|
|
f5f3637d01 | ||
|
|
bdade8503d | ||
|
|
57ca1c8083 | ||
|
|
32a41f42d3 | ||
|
|
ad9db5cbff | ||
|
|
99e4bd9614 | ||
|
|
239bf618b5 | ||
|
|
58e52421e9 | ||
|
|
96ffeaa0e2 | ||
|
|
81a0723abd | ||
|
|
6103df86a5 | ||
|
|
b97a877fce | ||
|
|
663f6f0007 | ||
|
|
5477cc039a | ||
|
|
ab8e8d7e1e | ||
|
|
49e57482cc | ||
|
|
5f06c3918c | ||
|
|
96471cf13b | ||
|
|
3575ed9450 | ||
|
|
f181c73a56 | ||
|
|
c85e32c9f4 | ||
|
|
f2b98c6dba | ||
|
|
653d5965ed | ||
|
|
01d15fd24f | ||
|
|
35e44318b9 | ||
|
|
3bdafefb65 | ||
|
|
982a23cafd |
@@ -44,7 +44,8 @@ on:
|
||||
types: [published]
|
||||
|
||||
concurrency:
|
||||
group: e2e-ios-${{ github.ref }}
|
||||
# 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:
|
||||
@@ -121,6 +122,11 @@ jobs:
|
||||
- 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)
|
||||
@@ -155,7 +161,13 @@ jobs:
|
||||
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
|
||||
# Packed as a tar: artifacts drop the executable bit, which would
|
||||
# leave a reused app unable to launch.
|
||||
tar -C build/Build/Products/Release-iphonesimulator -czf "$RUNNER_TEMP/simulator-app.tar.gz" BulwarkMobile.app
|
||||
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
|
||||
@@ -325,7 +337,7 @@ jobs:
|
||||
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
|
||||
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
|
||||
fi
|
||||
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 3 \
|
||||
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
|
||||
|
||||
@@ -59,9 +59,6 @@ jobs:
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
@@ -69,14 +66,69 @@ jobs:
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
if [ -z "$NSE_PROFILE" ]; then
|
||||
# The notification service extension (plugins/ios-push) is its
|
||||
# own App ID and needs its own App Store profile.
|
||||
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
|
||||
exit 1
|
||||
fi
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Install the provisioning profiles
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profiles
|
||||
env:
|
||||
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
|
||||
# One App Store profile per target: the app and its notification
|
||||
# service extension (plugins/ios-push).
|
||||
for side in app nse; do
|
||||
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
|
||||
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
|
||||
echo "$DATA" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
|
||||
# ID from the profile keeps ExportOptions honest if app.config.js
|
||||
# changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
done
|
||||
|
||||
- name: Generate the iOS project
|
||||
env:
|
||||
# plugins/ios-push names each target's profile: the command line
|
||||
# can't, it would give every target the same one.
|
||||
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
|
||||
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
@@ -141,38 +193,6 @@ jobs:
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Install the provisioning profile
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
||||
|
||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||
# before anything inside it can be read.
|
||||
PLIST=$(security cms -D -i "$PROFILE")
|
||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||
BUNDLE_ID="${APP_ID#*.}"
|
||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||
|
||||
# Xcode moved the profile directory in 16; write both so this does not
|
||||
# silently stop working on a runner image bump.
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||
|
||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
@@ -192,8 +212,7 @@ jobs:
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
CODE_SIGN_IDENTITY="Apple Distribution"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
@@ -215,8 +234,10 @@ jobs:
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
|
||||
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
|
||||
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
|
||||
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -230,6 +251,7 @@ jobs:
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
|
||||
@@ -44,7 +44,9 @@ tags:
|
||||
visible: ".*q3-report.pdf.*"
|
||||
timeout: 20000
|
||||
- takeScreenshot: read-04-attachment
|
||||
- tapOn: ".*q3-report.pdf.*"
|
||||
# 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
|
||||
@@ -53,9 +55,11 @@ tags:
|
||||
- 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:
|
||||
visible: "Reply All"
|
||||
notVisible: "Share"
|
||||
timeout: 10000
|
||||
- assertVisible: ".*q3-report.pdf.*"
|
||||
- runFlow: ../subflows/close-message.yaml
|
||||
|
||||
# A thread (Projects/Alpha)
|
||||
|
||||
@@ -21,13 +21,12 @@ tags:
|
||||
- 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). The system photo and file pickers run in another
|
||||
# process that XCTest can't drive, so a picked attachment is covered by the
|
||||
# forward in 04-incoming-reply-forward instead.
|
||||
# accessibility element, and picking one opened no picker on iOS).
|
||||
- tapOn: "Attach"
|
||||
- extendedWaitUntil:
|
||||
visible: "Photos & Videos"
|
||||
@@ -35,28 +34,54 @@ tags:
|
||||
- assertVisible: "Camera"
|
||||
- assertVisible: "Files"
|
||||
- takeScreenshot: compose-02-attach-sheet
|
||||
- tapOn: "Photos & Videos"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: compose-03-photo-picker
|
||||
# Dismiss the picker sheet.
|
||||
- swipe:
|
||||
start: 50%,12%
|
||||
end: 50%,95%
|
||||
duration: 400
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- 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:
|
||||
notVisible: "New Message"
|
||||
true: ${!output.pickPhoto}
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%,12%
|
||||
end: 50%,95%
|
||||
duration: 400
|
||||
- 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: 10000
|
||||
timeout: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- takeScreenshot: compose-04-with-photo
|
||||
|
||||
- tapOn: "Send"
|
||||
- extendedWaitUntil:
|
||||
@@ -67,3 +92,10 @@ tags:
|
||||
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"
|
||||
|
||||
@@ -6,16 +6,21 @@ 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
|
||||
- extendedWaitUntil:
|
||||
visible: "Email archived"
|
||||
timeout: 10000
|
||||
- takeScreenshot: swipe-01-archived
|
||||
- assertNotVisible: ".*Grüße aus München.*"
|
||||
- tapOn: "UNDO"
|
||||
# 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
|
||||
|
||||
@@ -21,12 +21,16 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Move to..."
|
||||
timeout: 10000
|
||||
- tapOn: "Projects"
|
||||
- extendedWaitUntil:
|
||||
visible: "Moved to Projects"
|
||||
timeout: 15000
|
||||
- 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
|
||||
- tapOn: "UNDO"
|
||||
# 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.*"
|
||||
@@ -38,27 +42,34 @@ tags:
|
||||
env:
|
||||
SUBJECT: Weekly digest
|
||||
- takeScreenshot: actions-03a-before-delete
|
||||
- tapOn: "Delete"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- takeScreenshot: actions-03b-after-delete
|
||||
- extendedWaitUntil:
|
||||
visible: "Email moved to Trash"
|
||||
timeout: 15000
|
||||
- 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: "UNDO"
|
||||
- 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
|
||||
# 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
|
||||
- tapOn: "Star"
|
||||
# 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:
|
||||
|
||||
@@ -21,6 +21,7 @@ tags:
|
||||
- runFlow:
|
||||
file: ../subflows/compose-body.yaml
|
||||
env:
|
||||
FROM_SUBJECT: "yes"
|
||||
BODY: "Scheduled from the iOS simulator test."
|
||||
- tapOn: "Schedule send"
|
||||
- extendedWaitUntil:
|
||||
|
||||
@@ -12,13 +12,7 @@ tags:
|
||||
- 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
|
||||
- runFlow: ../subflows/allow-notifications.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Go to today"
|
||||
timeout: 30000
|
||||
@@ -47,16 +41,33 @@ tags:
|
||||
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
|
||||
- tapOn: "Add reminder"
|
||||
- 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: "New event"
|
||||
notVisible: "Save"
|
||||
timeout: 20000
|
||||
|
||||
# Find it in the schedule and open it
|
||||
|
||||
@@ -44,7 +44,14 @@ tags:
|
||||
timeout: 20000
|
||||
- takeScreenshot: files-04-folder-created
|
||||
- longPressOn: ".*${output.folder}.*"
|
||||
- tapOn: "Delete"
|
||||
- repeat:
|
||||
while:
|
||||
notVisible: "Delete this item.*"
|
||||
times: 3
|
||||
commands:
|
||||
- tapOn: "Delete"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "Delete this item.*"
|
||||
timeout: 10000
|
||||
|
||||
@@ -12,27 +12,27 @@ tags:
|
||||
timeout: 20000
|
||||
- takeScreenshot: settings-00-list
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", PROOF: "Date Format"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", PROOF: "Email Notifications"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", PROOF: "Choose your preferred color scheme"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", PROOF: "Tune the email list interactions for mobile."}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", PROOF: "Mark as Read"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", PROOF: "Composer"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", PROOF: "Sending Identities"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", PROOF: "Reply automatically"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", PROOF: "Email Filters"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", PROOF: "Email Templates"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", PROOF: ".*Inbox.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", PROOF: "Email Tags"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", PROOF: "Email .*eml.* filename"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", PROOF: "Account Security|Detecting server capabilities.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", PROOF: "External Content"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", PROOF: "Default view"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", PROOF: "Group by first letter"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", PROOF: "Default View"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", PROOF: "Keep Apps Loaded"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", PROOF: "The default light and dark palettes."}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", PROOF: "Offline mail.*"}}
|
||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", PROOF: "This device"}}
|
||||
- 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
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# Push on iOS: the relay's payload carries only ids; the notification service
|
||||
# extension (plugins/ios-push) fetches sender and subject before the banner
|
||||
# shows. A tap opens the message, with the app running and from a cold start.
|
||||
# The helper sends the message as userb and pushes with simctl, so this needs
|
||||
# no APNs key.
|
||||
# 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:
|
||||
@@ -30,38 +35,43 @@ tags:
|
||||
timeout: 3000
|
||||
- pressKey: Home
|
||||
|
||||
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
|
||||
# 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.subject)}
|
||||
# The banner shows for a few seconds: catch it with either text, then check.
|
||||
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.subject}.*|.*New message.*"
|
||||
visible: ".*${output.subject0}.*|.*New message.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: push-01-banner
|
||||
# Sender and subject come from the extension, not the relay.
|
||||
- assertVisible: ".*User B.*"
|
||||
- assertNotVisible: ".*New message.*"
|
||||
- tapOn: ".*${output.subject}.*"
|
||||
# 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
|
||||
|
||||
# Cold start: the app isn't running when the notification is tapped.
|
||||
# 3. The same from a cold start: the app isn't running.
|
||||
- stopApp
|
||||
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
||||
- runScript:
|
||||
file: ../scripts/helper.js
|
||||
- runFlow:
|
||||
file: ../subflows/push-and-tap.yaml
|
||||
env:
|
||||
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject2)}
|
||||
- extendedWaitUntil:
|
||||
visible: ".*${output.subject2}.*"
|
||||
timeout: 30000
|
||||
- takeScreenshot: push-03-banner-cold
|
||||
- tapOn: ".*${output.subject2}.*"
|
||||
SUBJECT: ${output.subject2}
|
||||
- extendedWaitUntil:
|
||||
visible: "Reply All"
|
||||
timeout: 90000
|
||||
|
||||
@@ -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
|
||||
@@ -1,19 +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:
|
||||
visible: "Write your message.*"
|
||||
true: ${FROM_SUBJECT == 'yes'}
|
||||
commands:
|
||||
- tapOn: "Write your message.*"
|
||||
- pressKey: Enter
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: ${BODY}
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Write your message.*"
|
||||
true: ${FROM_SUBJECT != 'yes'}
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "50%,50%"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- inputText: ${BODY}
|
||||
- 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}
|
||||
|
||||
@@ -10,9 +10,10 @@ appId: org.bulwarkmail.mobile
|
||||
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: 20000
|
||||
timeout: 60000
|
||||
- takeScreenshot: link-confirm-prompt
|
||||
- tapOn:
|
||||
text: "Continue"
|
||||
|
||||
@@ -18,5 +18,9 @@ appId: org.bulwarkmail.mobile
|
||||
visible:
|
||||
id: email-row
|
||||
timeout: 60000
|
||||
# Rows are one element: sender, subject and preview together.
|
||||
- assertVisible: ${INBOX_TEXT || '.*Your verification code.*'}
|
||||
# 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
|
||||
|
||||
@@ -14,6 +14,18 @@ appId: org.bulwarkmail.mobile
|
||||
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
|
||||
|
||||
@@ -1,13 +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
|
||||
---
|
||||
- scrollUntilVisible:
|
||||
element: ".*${SUBJECT}.*"
|
||||
direction: DOWN
|
||||
timeout: 30000
|
||||
- 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: "Reply All"
|
||||
visible: "more"
|
||||
timeout: 20000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
|
||||
@@ -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
|
||||
@@ -1,18 +1,81 @@
|
||||
# Settings list: scroll to the row ROW (regex on "Title, summary"), open it,
|
||||
# check PROOF shows, screenshot, and go back on a phone. On the iPad the list
|
||||
# is a left column, so scroll there instead of the middle of the screen.
|
||||
# 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
|
||||
---
|
||||
- repeat:
|
||||
while:
|
||||
notVisible: ${ROW}
|
||||
times: 10
|
||||
- runFlow:
|
||||
when:
|
||||
true: ${!!Q}
|
||||
commands:
|
||||
- swipe:
|
||||
start: "${DEVICE == 'tablet' ? '18%,80%' : '50%,80%'}"
|
||||
end: "${DEVICE == 'tablet' ? '18%,40%' : '50%,40%'}"
|
||||
duration: 600
|
||||
- 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
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
# 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
|
||||
- scrollUntilVisible:
|
||||
element: "Sign out"
|
||||
direction: DOWN
|
||||
timeout: 30000
|
||||
- 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.*"
|
||||
|
||||
@@ -7,6 +7,13 @@ appId: org.bulwarkmail.mobile
|
||||
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'}
|
||||
@@ -14,7 +21,7 @@ appId: org.bulwarkmail.mobile
|
||||
- setOrientation: LANDSCAPE_LEFT
|
||||
- extendedWaitUntil:
|
||||
visible: "Enter server details manually|Inbox"
|
||||
timeout: 120000
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Enter server details manually"
|
||||
@@ -23,6 +30,7 @@ appId: org.bulwarkmail.mobile
|
||||
file: password-sign-in.yaml
|
||||
env:
|
||||
LOGIN: ${ACCOUNT}@example.org
|
||||
- runFlow: allow-notifications.yaml
|
||||
- tapOn:
|
||||
id: tab-mail
|
||||
- runFlow: go-to-inbox.yaml
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
|
||||
# pages in the auth session), and check it arrived as typed. Now and then a
|
||||
# stray character lands in front of it on the CI simulator; then select the
|
||||
# field's text and type over it. SECRET=yes skips the check (a password
|
||||
# field shows dots).
|
||||
# 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}
|
||||
@@ -13,13 +15,13 @@ appId: org.bulwarkmail.mobile
|
||||
when:
|
||||
true: ${SECRET != 'yes'}
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
- repeat:
|
||||
while:
|
||||
notVisible: ${TEXT}
|
||||
times: 2
|
||||
commands:
|
||||
- longPressOn: ".*${TEXT}.*"
|
||||
- tapOn:
|
||||
text: "Select All"
|
||||
optional: true
|
||||
- eraseText: 150
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 2000
|
||||
- inputText: ${TEXT}
|
||||
- assertVisible: ${TEXT}
|
||||
- assertVisible: ${TEXT}
|
||||
|
||||
@@ -94,6 +94,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { useOutboxStore } from './src/stores/outbox-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
@@ -261,6 +262,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
<PushOnboardingPrompt />
|
||||
<AppIconBadge />
|
||||
<Tab.Navigator
|
||||
screenListeners={perfMarksEnabled() ? ({ route }) => ({
|
||||
tabPress: () => perfStart('tab-switch'),
|
||||
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
|
||||
}) : undefined}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Tabs out of view stop re-rendering until they are shown again.
|
||||
|
||||
@@ -22,6 +22,13 @@ COMMIT = COMMIT.slice(0, 7);
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
// Apple team: prefixes the keychain groups the app shares with its
|
||||
// notification service extension (plugins/ios-push). Not a secret.
|
||||
const APPLE_TEAM_ID = process.env.APPLE_TEAM_ID || '8ZANF9LQ39';
|
||||
// Which APNs host the device tokens belong to: TestFlight and App Store builds
|
||||
// use production, simulators and development builds the sandbox.
|
||||
const APS_ENVIRONMENT = process.env.APS_ENVIRONMENT === 'development' ? 'development' : 'production';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -96,12 +103,17 @@ module.exports = {
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
|
||||
],
|
||||
extra: {
|
||||
commit: COMMIT,
|
||||
// Settings → About → Source code. A modified build sets SOURCE_CODE_URL
|
||||
// to where its own source is (AGPL §13); the default is upstream.
|
||||
sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native',
|
||||
// src/lib/ios-push-shared.ts
|
||||
iosSharedKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.shared`,
|
||||
iosAppKeychainGroup: `${APPLE_TEAM_ID}.org.bulwarkmail.mobile`,
|
||||
apnsEnvironment: APS_ENVIRONMENT === 'development' ? 'sandbox' : 'production',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
|
||||
import { pushBackgroundTask } from './src/lib/push-background-task';
|
||||
import { widgetTaskHandler } from './src/widgets/task-handler';
|
||||
import { refreshWidgetsInBackground } from './src/widgets/sync';
|
||||
import { perfMark } from './src/lib/perf-marks';
|
||||
|
||||
// Every import above has run: the bundle is loaded (performance harness).
|
||||
perfMark('bundle-loaded');
|
||||
import { markUiStarted } from './src/widgets/ui-presence';
|
||||
|
||||
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
|
||||
|
||||
@@ -639,6 +639,12 @@
|
||||
"up_to_date": "Already up to date"
|
||||
}
|
||||
},
|
||||
"push": {
|
||||
"ios": {
|
||||
"more_messages": "and {count} more",
|
||||
"no_subject": "(no subject)"
|
||||
}
|
||||
},
|
||||
"push_prompt": {
|
||||
"body": "Turn on background notifications so new messages reach you while the app is closed.",
|
||||
"dismiss": "Not now",
|
||||
@@ -1008,6 +1014,8 @@
|
||||
"confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.",
|
||||
"description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.",
|
||||
"err_android_only": "Push notifications are only available on Android.",
|
||||
"err_apns_empty": "iOS returned an empty push token.",
|
||||
"err_apns_token": "iOS could not issue a push token ({detail}).",
|
||||
"err_fcm_empty": "Firebase returned an empty device token.",
|
||||
"err_fcm_token": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device.",
|
||||
"err_fcm_token_up": "Firebase could not issue a device token ({detail}). Push over FCM needs Google Play services on this device. This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.",
|
||||
@@ -1015,6 +1023,7 @@
|
||||
"err_no_account": "No account loaded - cannot set up push.",
|
||||
"err_permission": "Notification permission was not granted.",
|
||||
"err_relay_https": "The relay URL must use https://.",
|
||||
"err_relay_no_apns": "The push relay at {url} does not deliver to iPhones and iPads yet.",
|
||||
"err_relay_rejected": "The push relay rejected the registration: {detail}",
|
||||
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
|
||||
"err_up_android_only": "UnifiedPush is only available on Android.",
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Performance harness (the iOS simulator tests, .github/workflows/e2e-ios.yml):
|
||||
// with BULWARK_MODULE_TIMINGS set, the bundle times each module's
|
||||
// evaluation (scripts/perf/module-timings.js), and BULWARK_MODULE_MAP names
|
||||
// a file that receives the module ids with their source paths, so the
|
||||
// harness can tell which module is which. Release builds set neither.
|
||||
if (process.env.BULWARK_MODULE_TIMINGS) {
|
||||
const basePolyfills = config.serializer.getPolyfills;
|
||||
config.serializer.getPolyfills = (ctx) => [
|
||||
...basePolyfills(ctx),
|
||||
path.join(__dirname, 'scripts/perf/module-timings.js'),
|
||||
];
|
||||
const mapFile = process.env.BULWARK_MODULE_MAP;
|
||||
if (mapFile) {
|
||||
const baseFactory = config.serializer.createModuleIdFactory;
|
||||
config.serializer.createModuleIdFactory = () => {
|
||||
const idOf = baseFactory();
|
||||
const names = {};
|
||||
process.on('exit', () => {
|
||||
try { fs.writeFileSync(mapFile, JSON.stringify(names)); } catch { /* best effort */ }
|
||||
});
|
||||
return (modulePath) => {
|
||||
const id = idOf(modulePath);
|
||||
names[id] = path.relative(__dirname, modulePath).split(path.sep).join('/');
|
||||
return id;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
|
||||
/// The little JMAP the extension needs: who sent the new messages and what
|
||||
/// they are about, plus the inbox's unread count for the badge.
|
||||
struct PushedEmail {
|
||||
let id: String
|
||||
let threadId: String
|
||||
let sender: String
|
||||
let subject: String
|
||||
let preview: String
|
||||
}
|
||||
|
||||
struct JmapFetchResult {
|
||||
let emails: [PushedEmail]
|
||||
let inboxUnread: Int?
|
||||
}
|
||||
|
||||
enum JmapError: Error {
|
||||
case noCredentials
|
||||
case http(Int)
|
||||
case badResponse
|
||||
}
|
||||
|
||||
/// Credentials as the app stores them (src/api/jmap-client.ts StoredCredentials).
|
||||
struct StoredCredentials {
|
||||
var json: [String: Any]
|
||||
|
||||
var username: String { json["username"] as? String ?? "" }
|
||||
var password: String { json["password"] as? String ?? "" }
|
||||
var accessToken: String? { json["accessToken"] as? String }
|
||||
var refreshToken: String? { json["refreshToken"] as? String }
|
||||
var tokenEndpoint: String? { json["tokenEndpoint"] as? String }
|
||||
var clientId: String? { json["clientId"] as? String }
|
||||
/// Milliseconds since 1970, like Date.now() in the app.
|
||||
var expiresAt: Double? { (json["expiresAt"] as? NSNumber)?.doubleValue }
|
||||
}
|
||||
|
||||
enum Jmap {
|
||||
static let session: URLSession = {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 12
|
||||
config.timeoutIntervalForResource = 20
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// The Authorization header for these credentials, refreshing an OAuth
|
||||
/// access token that is about to expire. A refreshed bundle is written back
|
||||
/// to the shared keychain item so the app picks it up (and doesn't refresh
|
||||
/// again with a refresh token the server has already replaced).
|
||||
static func authorization(credentialsKey: String) async throws -> String {
|
||||
guard let json = SharedKeychain.readJSON(credentialsKey) else { throw JmapError.noCredentials }
|
||||
var creds = StoredCredentials(json: json)
|
||||
if let token = creds.accessToken {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000
|
||||
if let expiresAt = creds.expiresAt, expiresAt - nowMs < 60_000,
|
||||
let refresh = creds.refreshToken, let endpoint = creds.tokenEndpoint, let clientId = creds.clientId,
|
||||
let url = URL(string: endpoint) {
|
||||
if let fresh = try? await refreshToken(url: url, refreshToken: refresh, clientId: clientId) {
|
||||
creds.json["accessToken"] = fresh.accessToken
|
||||
if let newRefresh = fresh.refreshToken { creds.json["refreshToken"] = newRefresh }
|
||||
if let expiresIn = fresh.expiresIn { creds.json["expiresAt"] = nowMs + expiresIn * 1000 }
|
||||
SharedKeychain.update(credentialsKey, json: creds.json)
|
||||
return "Bearer \(fresh.accessToken)"
|
||||
}
|
||||
}
|
||||
return "Bearer \(token)"
|
||||
}
|
||||
let basic = Data("\(creds.username):\(creds.password)".utf8).base64EncodedString()
|
||||
return "Basic \(basic)"
|
||||
}
|
||||
|
||||
struct FreshTokens {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Double?
|
||||
}
|
||||
|
||||
static func refreshToken(url: URL, refreshToken: String, clientId: String) async throws -> FreshTokens {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var form = URLComponents()
|
||||
form.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: clientId),
|
||||
]
|
||||
request.httpBody = form.percentEncodedQuery?.data(using: .utf8)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let access = body["access_token"] as? String else { throw JmapError.badResponse }
|
||||
return FreshTokens(
|
||||
accessToken: access,
|
||||
refreshToken: body["refresh_token"] as? String,
|
||||
expiresIn: (body["expires_in"] as? NSNumber)?.doubleValue
|
||||
)
|
||||
}
|
||||
|
||||
static func fetch(apiUrl: URL, accountId: String, emailIds: [String], authorization: String) async throws -> JmapFetchResult {
|
||||
var request = URLRequest(url: apiUrl)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(authorization, forHTTPHeaderField: "Authorization")
|
||||
let body: [String: Any] = [
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
"methodCalls": [
|
||||
["Email/get", [
|
||||
"accountId": accountId,
|
||||
"ids": Array(emailIds.prefix(5)),
|
||||
"properties": ["threadId", "from", "subject", "preview"],
|
||||
], "0"],
|
||||
["Mailbox/query", ["accountId": accountId, "filter": ["role": "inbox"]], "1"],
|
||||
["Mailbox/get", [
|
||||
"accountId": accountId,
|
||||
"#ids": ["resultOf": "1", "name": "Mailbox/query", "path": "/ids"],
|
||||
"properties": ["unreadEmails"],
|
||||
], "2"],
|
||||
],
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw JmapError.badResponse }
|
||||
guard http.statusCode == 200 else { throw JmapError.http(http.statusCode) }
|
||||
guard let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let responses = root["methodResponses"] as? [[Any]] else { throw JmapError.badResponse }
|
||||
|
||||
var emails: [PushedEmail] = []
|
||||
var inboxUnread: Int?
|
||||
for response in responses where response.count >= 2 {
|
||||
guard let name = response[0] as? String, let args = response[1] as? [String: Any] else { continue }
|
||||
if name == "Email/get", let list = args["list"] as? [[String: Any]] {
|
||||
// Keep the order the push gave (newest first from the server's filter).
|
||||
// Stalwart can list an id twice; keep the first.
|
||||
let byId = Dictionary(list.compactMap { item -> (String, [String: Any])? in
|
||||
guard let id = item["id"] as? String else { return nil }
|
||||
return (id, item)
|
||||
}, uniquingKeysWith: { first, _ in first })
|
||||
for id in emailIds {
|
||||
guard let item = byId[id] else { continue }
|
||||
let from = (item["from"] as? [[String: Any]])?.first
|
||||
let name = (from?["name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let sender = name ?? (from?["email"] as? String) ?? ""
|
||||
emails.append(PushedEmail(
|
||||
id: id,
|
||||
threadId: item["threadId"] as? String ?? "",
|
||||
sender: sender,
|
||||
subject: item["subject"] as? String ?? "",
|
||||
preview: item["preview"] as? String ?? ""
|
||||
))
|
||||
}
|
||||
} else if name == "Mailbox/get", let list = args["list"] as? [[String: Any]] {
|
||||
inboxUnread = (list.first?["unreadEmails"] as? NSNumber)?.intValue
|
||||
}
|
||||
}
|
||||
return JmapFetchResult(emails: emails, inboxUnread: inboxUnread)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import os
|
||||
import UserNotifications
|
||||
|
||||
/// Ids and steps only, never credentials or message content.
|
||||
let pushLog = Logger(subsystem: "org.bulwarkmail.mobile.NotificationService", category: "push")
|
||||
|
||||
/// Turns the relay's content-blind push ("New message" plus ids) into a real
|
||||
/// notification: sender, subject and preview, fetched over JMAP on this
|
||||
/// device with the account's own credentials. Nothing about the message ever
|
||||
/// passes through the relay or Apple.
|
||||
///
|
||||
/// What the app shares through the keychain group (src/lib/ios-push-shared.ts):
|
||||
/// bulwark_push_account__<JMAP account id> {credentialsKey, apiUrl}
|
||||
/// bulwark_push_settings {badge, activeAccountId, strings{...}}
|
||||
/// <credentialsKey> the account's stored credentials
|
||||
/// If anything is missing or the server can't be reached, the relay's
|
||||
/// placeholder text is shown, so the user still learns there is new mail.
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
private var placeholder: UNMutableNotificationContent?
|
||||
private var work: Task<Void, Never>?
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
placeholder = content
|
||||
let push = request.content.userInfo["body"] as? [String: Any]
|
||||
work = Task { [weak self] in
|
||||
let enriched = await NotificationService.enrich(content, push: push)
|
||||
self?.finish(enriched)
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
work?.cancel()
|
||||
finish(placeholder)
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent?) {
|
||||
guard let handler = contentHandler, let content else { return }
|
||||
contentHandler = nil
|
||||
handler(content)
|
||||
}
|
||||
|
||||
static func sanitize(_ id: String) -> String {
|
||||
String(id.map { ch in (ch.isASCII && (ch.isLetter || ch.isNumber)) || ch == "." || ch == "-" || ch == "_" ? ch : "_" })
|
||||
}
|
||||
|
||||
static func enrich(_ content: UNMutableNotificationContent, push: [String: Any]?) async -> UNMutableNotificationContent {
|
||||
guard let push, let accountId = push["accountId"] as? String, !accountId.isEmpty else {
|
||||
pushLog.error("push without an account id")
|
||||
return content
|
||||
}
|
||||
pushLog.info("push for account \(accountId, privacy: .public), group \(SharedKeychain.group ?? "none", privacy: .public)")
|
||||
guard let account = SharedKeychain.readJSON("bulwark_push_account__\(sanitize(accountId))"),
|
||||
let credentialsKey = account["credentialsKey"] as? String,
|
||||
let apiString = account["apiUrl"] as? String,
|
||||
let apiUrl = URL(string: apiString) else {
|
||||
pushLog.error("no shared account entry for \(accountId, privacy: .public)")
|
||||
return content
|
||||
}
|
||||
let emailIds = push["emailIds"] as? [String] ?? []
|
||||
let settings = SharedKeychain.readJSON("bulwark_push_settings") ?? [:]
|
||||
let strings = settings["strings"] as? [String: String] ?? [:]
|
||||
|
||||
content.threadIdentifier = accountId
|
||||
// The app reads userInfo.body when the notification is tapped.
|
||||
var body = push
|
||||
|
||||
do {
|
||||
let authorization = try await Jmap.authorization(credentialsKey: credentialsKey)
|
||||
let result = try await Jmap.fetch(apiUrl: apiUrl, accountId: accountId, emailIds: emailIds, authorization: authorization)
|
||||
pushLog.info("fetched \(result.emails.count, privacy: .public) of \(emailIds.count, privacy: .public) messages")
|
||||
|
||||
if let first = result.emails.first {
|
||||
content.title = first.sender
|
||||
content.subtitle = first.subject.isEmpty ? (strings["noSubject"] ?? "(no subject)") : first.subject
|
||||
content.body = first.preview
|
||||
if result.emails.count > 1 {
|
||||
let more = (strings["more"] ?? "and {count} more").replacingOccurrences(of: "{count}", with: String(result.emails.count - 1))
|
||||
content.body = first.preview.isEmpty ? more : "\(first.preview)\n\(more)"
|
||||
}
|
||||
body["emailId"] = first.id
|
||||
body["threadId"] = first.threadId
|
||||
}
|
||||
|
||||
// Badge: like the app, the active account's inbox unread count. A push
|
||||
// for another account leaves the badge as it is.
|
||||
if settings["badge"] as? Bool == true,
|
||||
settings["activeAccountId"] as? String == accountId,
|
||||
let unread = result.inboxUnread {
|
||||
content.badge = NSNumber(value: unread)
|
||||
}
|
||||
} catch {
|
||||
// Keep the placeholder.
|
||||
pushLog.error("fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
var userInfo = content.userInfo
|
||||
userInfo["body"] = body
|
||||
content.userInfo = userInfo
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Items the app shares with this extension through a keychain access group
|
||||
/// (BulwarkKeychainGroup in Info.plist). The app writes them with
|
||||
/// expo-secure-store: generic passwords whose account and generic attributes
|
||||
/// are the UTF-8 key and whose service is "app:no-auth".
|
||||
enum SharedKeychain {
|
||||
static let service = "app:no-auth"
|
||||
|
||||
static var group: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "BulwarkKeychainGroup") as? String
|
||||
}
|
||||
|
||||
static func read(_ key: String) -> Data? {
|
||||
guard let group else { return nil }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess else {
|
||||
pushLog.error("keychain read of \(key, privacy: .public) failed: \(status, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
return item as? Data
|
||||
}
|
||||
|
||||
static func readJSON(_ key: String) -> [String: Any]? {
|
||||
guard let data = read(key) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
/// Replaces the value of an existing item (refreshed OAuth tokens). Never
|
||||
/// creates one: the app owns which items exist.
|
||||
@discardableResult
|
||||
static func update(_ key: String, json: [String: Any]) -> Bool {
|
||||
guard let group, let data = try? JSONSerialization.data(withJSONObject: json) else { return false }
|
||||
let account = Data(key.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: group,
|
||||
]
|
||||
let attributes: [String: Any] = [kSecValueData as String: data]
|
||||
return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Config plugin: push notifications on iOS.
|
||||
//
|
||||
// - The app's entitlements: aps-environment, and a keychain group shared with
|
||||
// the notification service extension (the app's own group stays first, so
|
||||
// items saved before keep their group).
|
||||
// - A Notification Service Extension target ("NotificationService",
|
||||
// org.bulwarkmail.mobile.NotificationService). The relay's push carries
|
||||
// only ids; the extension fetches sender and subject over JMAP on the device
|
||||
// before iOS shows the notification. Its sources live next to this file and
|
||||
// are copied into ios/ at prebuild, like everything else in ios/.
|
||||
//
|
||||
// Options (app.config.js):
|
||||
// apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins)
|
||||
// teamId: Apple team id, for the shared keychain group
|
||||
// extensionProfile / appProfile: provisioning profile specifiers for the
|
||||
// Release configuration of the extension and the app (IOS_NSE_PROFILE and
|
||||
// IOS_APP_PROFILE env win). Set per target because xcodebuild's command
|
||||
// line applies a specifier to every target. Empty = leave unset.
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withXcodeProject,
|
||||
withDangerousMod,
|
||||
createRunOncePlugin,
|
||||
} = require('@expo/config-plugins');
|
||||
|
||||
const TARGET = 'NotificationService';
|
||||
const DEPLOYMENT_TARGET = '15.1';
|
||||
const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift'];
|
||||
|
||||
function sharedGroupSuffix() {
|
||||
return 'org.bulwarkmail.shared';
|
||||
}
|
||||
|
||||
function withAppEntitlements(config, { apsEnvironment }) {
|
||||
return withEntitlementsPlist(config, (cfg) => {
|
||||
const bundleId = cfg.ios?.bundleIdentifier;
|
||||
cfg.modResults['aps-environment'] = apsEnvironment;
|
||||
const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []);
|
||||
groups.add(`$(AppIdentifierPrefix)${bundleId}`);
|
||||
groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`);
|
||||
cfg.modResults['keychain-access-groups'] = [...groups];
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function infoPlist({ version, buildNumber }) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Bulwark Mail notifications</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${version}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${buildNumber}</string>
|
||||
<key>BulwarkKeychainGroup</key>
|
||||
<string>$(DEVELOPMENT_TEAM).${sharedGroupSuffix()}</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function entitlementsPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)${sharedGroupSuffix()}</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
function withExtensionFiles(config) {
|
||||
return withDangerousMod(config, [
|
||||
'ios',
|
||||
async (cfg) => {
|
||||
const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const file of SOURCE_FILES) {
|
||||
fs.copyFileSync(path.join(__dirname, file), path.join(dir, file));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${TARGET}-Info.plist`),
|
||||
infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }),
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist());
|
||||
return cfg;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function setReleaseProfiles(cfg, { appProfile, extensionProfile }) {
|
||||
if (!appProfile && !extensionProfile) return;
|
||||
const project = cfg.modResults;
|
||||
const appName = `"${cfg.modRequest.projectName}"`;
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.name !== 'Release') continue;
|
||||
const name = entry.buildSettings.PRODUCT_NAME;
|
||||
const profile = name === `"${TARGET}"` ? extensionProfile
|
||||
: (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile
|
||||
: '';
|
||||
if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// One mod for creating the target and naming the profiles: separate
|
||||
// withXcodeProject mods don't run in the order they are registered.
|
||||
function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) {
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
addExtensionTarget(cfg, { teamId });
|
||||
setReleaseProfiles(cfg, { appProfile, extensionProfile });
|
||||
return cfg;
|
||||
});
|
||||
}
|
||||
|
||||
function addExtensionTarget(cfg, { teamId }) {
|
||||
const project = cfg.modResults;
|
||||
if (project.pbxTargetByName(TARGET)) return;
|
||||
|
||||
// addTarget expects these sections; a fresh Expo project may not have them.
|
||||
const objects = project.hash.project.objects;
|
||||
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
||||
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
||||
|
||||
const group = project.addPbxGroup(
|
||||
[...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`],
|
||||
TARGET,
|
||||
TARGET,
|
||||
);
|
||||
// Hang the group off the project's main group.
|
||||
const mainGroupId = project.getFirstProject().firstProject.mainGroup;
|
||||
project.addToPbxGroup(group.uuid, mainGroupId);
|
||||
|
||||
const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`;
|
||||
const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId);
|
||||
project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
||||
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
||||
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
||||
|
||||
const configs = project.pbxXCBuildConfigurationSection();
|
||||
for (const key of Object.keys(configs)) {
|
||||
const entry = configs[key];
|
||||
if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue;
|
||||
const s = entry.buildSettings;
|
||||
s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`;
|
||||
s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET;
|
||||
s.TARGETED_DEVICE_FAMILY = '"1,2"';
|
||||
s.SWIFT_VERSION = '5.0';
|
||||
s.CLANG_ENABLE_MODULES = 'YES';
|
||||
s.GENERATE_INFOPLIST_FILE = 'NO';
|
||||
if (teamId) s.DEVELOPMENT_TEAM = teamId;
|
||||
}
|
||||
}
|
||||
|
||||
function withIosPush(config, options = {}) {
|
||||
const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production';
|
||||
const teamId = process.env.APPLE_TEAM_ID || options.teamId || '';
|
||||
const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || '';
|
||||
const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || '';
|
||||
config = withAppEntitlements(config, { apsEnvironment });
|
||||
config = withExtensionFiles(config);
|
||||
config = withExtensionTarget(config, { teamId, appProfile, extensionProfile });
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0');
|
||||
+17
-2
@@ -105,9 +105,24 @@ function simctl(args, input) {
|
||||
});
|
||||
}
|
||||
|
||||
// simctl pbcopy times out now and then (seen on the iPad simulator); a
|
||||
// second or third try goes through.
|
||||
async function pbcopy(text) {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
await simctl(['pbcopy', UDID], text);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (attempt >= 3) throw e;
|
||||
log(`pbcopy attempt ${attempt} failed, trying again`);
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deliver(link, how) {
|
||||
if (how === 'openurl') await simctl(['openurl', UDID, link]);
|
||||
else if (how === 'pbcopy') await simctl(['pbcopy', UDID], link);
|
||||
else if (how === 'pbcopy') await pbcopy(link);
|
||||
else if (how !== 'none') throw new Error(`unknown deliver=${how}`);
|
||||
}
|
||||
|
||||
@@ -398,7 +413,7 @@ const routes = {
|
||||
return { ok: true };
|
||||
},
|
||||
async '/clipboard'(q) {
|
||||
await simctl(['pbcopy', UDID], q.get('text') || '');
|
||||
await pbcopy(q.get('text') || '');
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
+57
-6
@@ -11,7 +11,10 @@
|
||||
// stays signed in),
|
||||
// 2. cold-starts it `--cold` times: terminate, launch, wait for
|
||||
// `inbox-rendered`; cold start = that mark's time minus the launch time
|
||||
// taken on this machine (the simulator shares the host clock),
|
||||
// taken on this machine (the simulator shares the host clock). The
|
||||
// first launch after the install is much slower and swings a lot (iOS
|
||||
// does its first-launch work for the new binary), so it is reported on
|
||||
// its own as `first-start-*`; `cold-start-*` are the launches after it,
|
||||
// 3. runs the Maestro interaction flows once each (open messages, switch
|
||||
// tabs, open compose, scroll), which produce the other marks. Each flow
|
||||
// is its own run, so one that fails costs only its own metric.
|
||||
@@ -101,12 +104,14 @@ function waitForMark(name, timeoutMs) {
|
||||
|
||||
// ── steps ───────────────────────────────────────────────────────────────────
|
||||
const samples = []; // { label, metric, ms, round }
|
||||
const moduleRuns = {}; // label -> [{ total, top: { id: ms } }] (module-times marks)
|
||||
|
||||
async function coldStart(label, round) {
|
||||
async function coldStart(label, round, first) {
|
||||
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
|
||||
await sleep(1500);
|
||||
const pending = waitForMark('inbox-rendered', 120_000);
|
||||
const bundlePending = waitForMark('bundle-loaded', 120_000);
|
||||
const modulesPending = waitForMark('module-times', 125_000);
|
||||
const t0 = Date.now();
|
||||
simctl('launch', UDID, BUNDLE, '-perfMarks', 'YES');
|
||||
const [mark, bundle] = await Promise.all([pending, bundlePending]);
|
||||
@@ -114,8 +119,33 @@ async function coldStart(label, round) {
|
||||
console.log(` ${label} cold start: no inbox-rendered mark within 120 s`);
|
||||
return;
|
||||
}
|
||||
samples.push({ label, round, metric: 'cold-start-to-inbox', ms: mark.t - t0 });
|
||||
if (bundle) samples.push({ label, round, metric: 'cold-start-to-bundle', ms: bundle.t - t0 });
|
||||
const kind = first ? 'first-start' : 'cold-start';
|
||||
// Builds made with BULWARK_MODULE_TIMINGS log how long each module took
|
||||
// to evaluate, right after the first inbox (src/lib/perf-marks.ts).
|
||||
const modules = await Promise.race([modulesPending, sleep(3000).then(() => null)]);
|
||||
if (modules && !first) {
|
||||
const top = {};
|
||||
for (const pair of String(modules.top || '').split(',')) {
|
||||
const [id, ms] = pair.split(':');
|
||||
if (id) top[id] = Number(ms);
|
||||
}
|
||||
(moduleRuns[label] ||= []).push({ total: modules.total, top });
|
||||
}
|
||||
samples.push({ label, round, metric: `${kind}-to-inbox`, ms: mark.t - t0 });
|
||||
if (bundle) samples.push({ label, round, metric: `${kind}-to-bundle`, ms: bundle.t - t0 });
|
||||
// The start-up timeline: every mark between launch and the inbox, as time
|
||||
// since launch, plus how long each persisted cache took to read and parse.
|
||||
if (!first) {
|
||||
for (const m of marks) {
|
||||
if (m.t < t0 || m.t > mark.t || m.n === 'inbox-rendered' || m.n === 'bundle-loaded') continue;
|
||||
const key = m.store ? `${m.n}:${m.store}` : m.n;
|
||||
samples.push({ label, round, metric: `start:${key}`, ms: m.t - t0 });
|
||||
if (m.n === 'hydrate') {
|
||||
samples.push({ label, round, metric: `hydrate-read:${m.store}`, ms: m.readMs });
|
||||
samples.push({ label, round, metric: `hydrate-parse:${m.store}`, ms: m.parseMs, kb: m.kb });
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ${label} cold start ${mark.t - t0} ms (bundle ${bundle ? bundle.t - t0 : '?'} ms, rows ${mark.rows})`);
|
||||
await sleep(3000);
|
||||
}
|
||||
@@ -168,7 +198,28 @@ const scroll = labels.map((l) => {
|
||||
if (!xs.length) return null;
|
||||
return `${l}: median ${median(xs.map((s) => s.long))} long frames of ${median(xs.map((s) => s.frames))}, worst ${median(xs.map((s) => s.worst))} ms`;
|
||||
}).filter(Boolean);
|
||||
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), ''].join('\n');
|
||||
// The slowest modules at start-up per app, by median over the launches.
|
||||
const moduleSections = [];
|
||||
for (const app of apps) {
|
||||
const runs = moduleRuns[app.label];
|
||||
if (!runs?.length) continue;
|
||||
let names = {};
|
||||
try { names = JSON.parse(fs.readFileSync(path.join(path.dirname(app.path), 'module-map.json'), 'utf8')); } catch { /* no map */ }
|
||||
const ids = new Set(runs.flatMap((r) => Object.keys(r.top)));
|
||||
const rows = [...ids]
|
||||
.map((id) => ({ id, ms: median(runs.map((r) => r.top[id] ?? 0)) }))
|
||||
.sort((a, b) => b.ms - a.ms)
|
||||
.slice(0, 30);
|
||||
moduleSections.push(
|
||||
`### Slowest modules at start-up: ${app.label} (own evaluation time, median of ${runs.length} launches; all modules ${median(runs.map((r) => r.total))} ms)`,
|
||||
'',
|
||||
'| module | ms |',
|
||||
'|---|---|',
|
||||
...rows.map((r) => `| ${names[r.id] ?? `#${r.id}`} | ${r.ms} |`),
|
||||
'',
|
||||
);
|
||||
}
|
||||
const md = [...lines, '', ...scroll.map((s) => `- list-scroll ${s}`), '', ...moduleSections].join('\n');
|
||||
fs.writeFileSync(path.join(out, 'perf.json'), JSON.stringify({ apps, rounds, coldRuns, samples }, null, 1));
|
||||
fs.writeFileSync(path.join(out, 'perf.md'), md);
|
||||
console.log(md);
|
||||
@@ -185,7 +236,7 @@ if (final && process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITH
|
||||
currentLabel = app.label;
|
||||
try { simctl('terminate', UDID, BUNDLE); } catch { /* not running */ }
|
||||
simctl('install', UDID, app.path);
|
||||
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round);
|
||||
for (let i = 0; i < coldRuns; i++) await coldStart(app.label, round, i === 0);
|
||||
const got = await runFlows(app.label);
|
||||
for (const m of got) {
|
||||
const metric = m.n === 'tab-switch' && m.to ? `tab-switch:${m.to}` : m.n;
|
||||
|
||||
+13
-10
@@ -34,17 +34,20 @@ maestro --version
|
||||
xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/run.mp4" &
|
||||
RECORDER=$!
|
||||
|
||||
# Warm-up: the XCTest driver takes minutes to start on a cold runner.
|
||||
WARMUP_START=$(date +%s)
|
||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||
--debug-output "$RUNNER_TEMP/maestro-warmup" "$ROOT/.maestro/launch.yaml")
|
||||
STATUS=$?
|
||||
echo "warm-up took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
|
||||
if [ $STATUS -ne 0 ]; then
|
||||
echo "::group::Maestro warm-up log"
|
||||
find "$RUNNER_TEMP/maestro-warmup" -name 'maestro.log' -exec tail -n 300 {} \;
|
||||
# Warm-up: the XCTest driver takes minutes to start on a cold runner. When
|
||||
# it dies on the way (it does, now and then), a second try starts a new one
|
||||
# instead of losing the whole leg.
|
||||
for attempt in 1 2; do
|
||||
WARMUP_START=$(date +%s)
|
||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||
--debug-output "$RUNNER_TEMP/maestro-warmup-$attempt" "$ROOT/.maestro/launch.yaml")
|
||||
STATUS=$?
|
||||
echo "warm-up $attempt took $(( $(date +%s) - WARMUP_START )) s (status $STATUS)"
|
||||
[ $STATUS -eq 0 ] && break
|
||||
echo "::group::Maestro warm-up log $attempt"
|
||||
find "$RUNNER_TEMP/maestro-warmup-$attempt" -name 'maestro.log' -exec tail -n 300 {} \;
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $STATUS -eq 0 ]; then
|
||||
ARGS=(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Metro polyfill for the performance harness (metro.config.js adds it only
|
||||
// when BULWARK_MODULE_TIMINGS is set at bundle time; release builds don't
|
||||
// have it). It wraps every module factory and adds up how long each module's
|
||||
// own code ran when it was first evaluated, without the modules it required
|
||||
// meanwhile. src/lib/perf-marks.ts logs the slowest ones at first inbox.
|
||||
(function (g) {
|
||||
var define = g.__d;
|
||||
if (typeof define !== 'function') return;
|
||||
var now = typeof g.nativePerformanceNow === 'function'
|
||||
? g.nativePerformanceNow
|
||||
: function () { return Date.now(); };
|
||||
var times = {};
|
||||
var stack = [];
|
||||
g.__bulwarkModuleTimes = times;
|
||||
g.__d = function (factory, moduleId, dependencyMap, verboseName) {
|
||||
var timed = function () {
|
||||
var start = now();
|
||||
stack.push(0);
|
||||
try {
|
||||
return factory.apply(this, arguments);
|
||||
} finally {
|
||||
var total = now() - start;
|
||||
var nested = stack.pop();
|
||||
times[moduleId] = (times[moduleId] || 0) + (total - nested);
|
||||
if (stack.length > 0) stack[stack.length - 1] += total;
|
||||
}
|
||||
};
|
||||
return define(timed, moduleId, dependencyMap, verboseName);
|
||||
};
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -418,6 +418,40 @@ describe('JMAPClient hardening', () => {
|
||||
expect(client.authHeader).toBe('Bearer new');
|
||||
});
|
||||
|
||||
// On iOS the notification extension refreshes OAuth tokens too and writes
|
||||
// the new bundle back. Refreshing again with the refresh token in memory
|
||||
// would spend a token the server has already replaced.
|
||||
it('retries a 401 with a newer stored token instead of refreshing again', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = new JMAPClient();
|
||||
mockFetch([{ status: 200, json: SESSION }]);
|
||||
await client.connectWithOAuth('https://mail.example.com', {
|
||||
accessToken: 'old',
|
||||
refreshToken: 'r1',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
});
|
||||
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify({
|
||||
serverUrl: 'https://mail.example.com',
|
||||
username: 'user@example.com',
|
||||
password: '',
|
||||
accessToken: 'from-extension',
|
||||
refreshToken: 'r2',
|
||||
expiresAt: Date.now() + 7_200_000,
|
||||
tokenEndpoint: 'https://mail.example.com/auth/token',
|
||||
clientId: 'c',
|
||||
}));
|
||||
const fetch = mockFetch([
|
||||
{ status: 401 },
|
||||
{ status: 200, json: { methodResponses: [['Mailbox/get', {}, '0']] } },
|
||||
]);
|
||||
await client.request([['Mailbox/get', {}, '0']]);
|
||||
expect(mockRefresh).not.toHaveBeenCalled();
|
||||
expect((fetch.mock.calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer from-extension' });
|
||||
(SecureStore.getItemAsync as ReturnType<typeof vi.fn>).mockReset();
|
||||
});
|
||||
|
||||
it('updatePassword rewrites the stored credential', async () => {
|
||||
const SecureStore = await import('expo-secure-store');
|
||||
const client = await connected();
|
||||
|
||||
+47
-20
@@ -15,6 +15,7 @@ import {
|
||||
type OAuthTokenSource,
|
||||
} from '../lib/oauth';
|
||||
import { FirstTouchGate } from './first-touch-gate';
|
||||
import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared';
|
||||
import { beginOwnWrite, recordOwnEmailWrites } from './own-writes';
|
||||
|
||||
// Refresh OAuth access tokens this many ms before they actually expire so
|
||||
@@ -40,7 +41,7 @@ const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only - no "@" or "/".
|
||||
function credentialsKey(accountId: string): string {
|
||||
export function credentialsKey(accountId: string): string {
|
||||
return CREDENTIALS_PREFIX + accountId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
@@ -260,10 +261,7 @@ export class JMAPClient {
|
||||
this.firstTouchGate.reset();
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
|
||||
return this.session;
|
||||
}
|
||||
@@ -342,10 +340,7 @@ export class JMAPClient {
|
||||
this.credentials.username = username;
|
||||
|
||||
const accountId = generateAccountId(username, baseUrl);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
|
||||
return { session: this.session, username, accountId };
|
||||
}
|
||||
@@ -367,15 +362,36 @@ export class JMAPClient {
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the stored OAuth tokens when they are newer than the ones in memory:
|
||||
* on iOS the notification extension refreshes them too and writes the new
|
||||
* bundle back. True when the in-memory tokens changed.
|
||||
*/
|
||||
private async adoptStoredTokens(): Promise<boolean> {
|
||||
const current = this.credentials;
|
||||
if (!current?.accessToken || !current.refreshToken) return false;
|
||||
const accountId = generateAccountId(current.username, current.serverUrl);
|
||||
const stored = await this.getStoredCredentials(accountId).catch(() => null);
|
||||
if (!stored?.accessToken || stored.accessToken === current.accessToken) return false;
|
||||
if ((stored.expiresAt ?? 0) <= (current.expiresAt ?? 0)) return false;
|
||||
this.credentials = {
|
||||
...current,
|
||||
accessToken: stored.accessToken,
|
||||
refreshToken: stored.refreshToken ?? current.refreshToken,
|
||||
expiresAt: stored.expiresAt,
|
||||
};
|
||||
for (const l of this.tokenRefreshListeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private currentOAuthTokens(): OAuthTokens | null {
|
||||
if (
|
||||
!this.credentials?.accessToken ||
|
||||
@@ -414,10 +430,17 @@ export class JMAPClient {
|
||||
// consumers of `authHeader` (SSE stream, file downloads) can make sure the
|
||||
// header they capture is not about to expire.
|
||||
async ensureFreshToken(): Promise<void> {
|
||||
const tokens = this.currentOAuthTokens();
|
||||
let tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return;
|
||||
if (tokens.expiresAt == null) return;
|
||||
if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS) return;
|
||||
// Another process may have refreshed already (the iOS notification
|
||||
// extension); use its tokens rather than spend a refresh token the
|
||||
// server has replaced.
|
||||
if (await this.adoptStoredTokens()) {
|
||||
tokens = this.currentOAuthTokens();
|
||||
if (!tokens || (tokens.expiresAt != null && tokens.expiresAt - Date.now() > TOKEN_REFRESH_LEEWAY_MS)) return;
|
||||
}
|
||||
try {
|
||||
const next = await refreshOAuthAccessToken(tokens);
|
||||
await this.persistRefreshedTokens(next);
|
||||
@@ -433,6 +456,10 @@ export class JMAPClient {
|
||||
// the account instead of evicting it (webmail 1.7.6 "keep the session when
|
||||
// the auth server is briefly unreachable").
|
||||
async forceRefreshToken(): Promise<boolean> {
|
||||
if (!this.currentOAuthTokens()) return false;
|
||||
// A newer token in storage (refreshed by the iOS notification extension)
|
||||
// is worth a retry before refreshing again.
|
||||
if (await this.adoptStoredTokens()) return true;
|
||||
const tokens = this.currentOAuthTokens();
|
||||
if (!tokens) return false;
|
||||
try {
|
||||
@@ -459,10 +486,7 @@ export class JMAPClient {
|
||||
this.credentials.username,
|
||||
this.credentials.serverUrl,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
credentialsKey(accountId),
|
||||
JSON.stringify(this.credentials),
|
||||
);
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
|
||||
}
|
||||
|
||||
// Legacy single-slot restore - kept for backward-compat tests. New code
|
||||
@@ -490,6 +514,9 @@ export class JMAPClient {
|
||||
async loadAccount(accountId: string): Promise<boolean> {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return false;
|
||||
// iOS: credentials saved before the notification extension existed move
|
||||
// to the keychain group it can read (a no-op elsewhere and afterwards).
|
||||
void moveToSharedGroup(credentialsKey(accountId)).catch(() => undefined);
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
@@ -566,7 +593,7 @@ export class JMAPClient {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
// Re-save under the per-account key before dropping the legacy entry
|
||||
const accountId = generateAccountId(creds.username, creds.serverUrl);
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), stored);
|
||||
await writeSharedSecret(credentialsKey(accountId), stored);
|
||||
await SecureStore.deleteItemAsync(LEGACY_CREDENTIALS_KEY);
|
||||
return creds;
|
||||
} catch {
|
||||
@@ -1159,7 +1186,7 @@ export class JMAPClient {
|
||||
}
|
||||
|
||||
async setStoredCredentials(accountId: string, creds: StoredCredentials): Promise<void> {
|
||||
await SecureStore.setItemAsync(credentialsKey(accountId), JSON.stringify(creds));
|
||||
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(creds));
|
||||
}
|
||||
|
||||
// ── Blob Download ─────────────────────────────────────
|
||||
|
||||
@@ -1,21 +1,57 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { appBadgeCount, setAppBadge } from '../lib/app-badge';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { writeIosPushSettings } from '../lib/ios-push-shared';
|
||||
import { shareAccountWithExtension } from '../lib/push-notifications';
|
||||
|
||||
/**
|
||||
* Keeps the app icon badge on the active account's inbox unread count (see
|
||||
* `lib/app-badge`). Mounted with the signed-in UI; unmounting means the last
|
||||
* account signed out, so the badge is cleared then. Renders nothing.
|
||||
*
|
||||
* On iOS it also tells the notification extension (plugins/ios-push) which
|
||||
* account the badge follows and the few texts it writes itself, so a push
|
||||
* that arrives while the app is closed updates the badge the same way.
|
||||
*/
|
||||
export function AppIconBadge() {
|
||||
const enabled = useSettingsStore((s) => s.appIconUnreadBadge);
|
||||
const count = useEmailStore((s) => appBadgeCount(s.mailboxes, enabled));
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
// The JMAP session comes up after the account is known (offline start).
|
||||
const haveSession = useAuthStore((s) => s.session != null);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
|
||||
useEffect(() => {
|
||||
void setAppBadge(count);
|
||||
}, [count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'ios') return;
|
||||
let jmapAccountId: string | null = null;
|
||||
try {
|
||||
jmapAccountId = jmapClient.accountId;
|
||||
} catch {
|
||||
jmapAccountId = null;
|
||||
}
|
||||
// The extension learns how to reach the active account as soon as it is
|
||||
// signed in, so a push for it shows sender and subject even before (or
|
||||
// without) this device's own push setup, e.g. after a reinstall.
|
||||
if (activeAccountId) void shareAccountWithExtension(activeAccountId, jmapAccountId);
|
||||
void writeIosPushSettings({
|
||||
badge: enabled,
|
||||
activeAccountId: jmapAccountId,
|
||||
strings: {
|
||||
noSubject: t('push.ios.no_subject', '(no subject)'),
|
||||
more: t('push.ios.more_messages', 'and {count} more'),
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
}, [enabled, activeAccountId, haveSession, t]);
|
||||
|
||||
useEffect(() => () => { void setAppBadge(0); }, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
@@ -683,8 +684,13 @@ export default function EmailBodyView({
|
||||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
||||
);
|
||||
|
||||
// Performance harness: how much of opening a message is the WebView.
|
||||
React.useEffect(() => { perfLap('open-message', 'body-mounted'); }, []);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
const data = e.nativeEvent.data;
|
||||
// The body reported its size: it is laid out (performance harness).
|
||||
perfEndAfterPaint('open-message');
|
||||
// Swipe/zoom messages arrive as JSON; the height reporter posts a bare number.
|
||||
if (data.charCodeAt(0) === 123 /* '{' */) {
|
||||
try {
|
||||
@@ -788,6 +794,7 @@ export default function EmailBodyView({
|
||||
injectedJavaScript={injectedJs}
|
||||
onMessage={onMessage}
|
||||
onLoadEnd={() => {
|
||||
perfLap('open-message', 'webview-loaded');
|
||||
// A (re)load resets the page's zoom state to 1 — drop any host-side
|
||||
// scroll locks so they can't go stale (e.g. cid images arriving
|
||||
// reload the source mid-zoom).
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
Platform,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
@@ -19,6 +18,7 @@ import Button from './Button';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { insecurePairingLinkError, parsePastedSignInLink, type QrLoginPayload } from '../lib/oauth';
|
||||
import { describeLoginError } from '../lib/login-errors';
|
||||
import { SafeAreaModal } from './SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
|
||||
interface PasteSignInLinkModalProps {
|
||||
@@ -81,7 +81,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
||||
const title = t('login.mobile.paste_title', 'Paste sign-in link');
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.flex}>
|
||||
<View style={styles.header}>
|
||||
@@ -145,7 +145,7 @@ export function PasteSignInLinkModal({ visible, onClose, onSubmit }: PasteSignIn
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
|
||||
import { View, Text, Pressable, StyleSheet, Platform, Linking } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from './icons';
|
||||
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { SafeAreaModal } from './SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
|
||||
interface QrScanModalProps {
|
||||
@@ -43,7 +44,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<View style={styles.container}>
|
||||
{permission?.granted ? (
|
||||
<CameraView
|
||||
@@ -105,7 +106,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
|
||||
) : null}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useColors } from '../theme/colors';
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
import { perfEndAfterPaint } from '../lib/perf-marks';
|
||||
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
||||
|
||||
export type RichTextCommand =
|
||||
@@ -65,6 +66,8 @@ export interface RichTextEditorHandle {
|
||||
*/
|
||||
getHtml(timeoutMs?: number): Promise<string>;
|
||||
focus(): void;
|
||||
/** Focus with the caret where new text goes (before signature / quote). */
|
||||
focusEnd(): void;
|
||||
/** Drop the caret, which closes the keyboard. */
|
||||
blur(): void;
|
||||
}
|
||||
@@ -185,6 +188,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
pendingSnapshots.current.set(id, { resolve, reject, timer });
|
||||
call(`window.__rne && window.__rne.getHtml(${id})`);
|
||||
}),
|
||||
focusEnd: () => {
|
||||
call(`window.__rne && window.__rne.focusEnd()`);
|
||||
},
|
||||
focus: () => {
|
||||
call(`window.__rne && window.__rne.focus()`);
|
||||
},
|
||||
@@ -202,6 +208,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
}
|
||||
if (!readyRef.current) {
|
||||
readyRef.current = true;
|
||||
perfEndAfterPaint('compose-open');
|
||||
const queued = queuedRef.current;
|
||||
queuedRef.current = [];
|
||||
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Modal, type ModalProps } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
/**
|
||||
* A Modal with its own safe-area provider. A Modal is a separate native
|
||||
* window, and a SafeAreaView inside a slide-up Modal measured no insets: the
|
||||
* view starts off screen when the slide begins, so on iPhones with a notch or
|
||||
* Dynamic Island the header (and its close button) ended up under the status
|
||||
* bar, where taps don't reach it. Use this for full-screen modals with a
|
||||
* SafeAreaView header.
|
||||
*/
|
||||
export function SafeAreaModal({ children, ...props }: ModalProps) {
|
||||
return (
|
||||
<Modal {...props}>
|
||||
<SafeAreaProvider>{children}</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { undoSnackbarColors } from './undo-snackbar-colors';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -158,6 +159,7 @@ export function UndoSnackbar() {
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
const colors = undoSnackbarColors(c);
|
||||
return StyleSheet.create({
|
||||
wrap: {
|
||||
position: 'absolute',
|
||||
@@ -170,7 +172,7 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingLeft: spacing.lg,
|
||||
paddingRight: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.text,
|
||||
backgroundColor: colors.bar,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
@@ -179,7 +181,7 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
label: {
|
||||
...typography.body,
|
||||
color: c.background,
|
||||
color: colors.label,
|
||||
flex: 1,
|
||||
},
|
||||
undoBtn: {
|
||||
@@ -192,7 +194,7 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
undoText: {
|
||||
...typography.bodySemibold,
|
||||
color: c.primary,
|
||||
color: colors.action,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
sendNowText: {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// A SafeAreaView inside a slide-up react-native Modal measured no insets on
|
||||
// iOS: the Modal is its own window and its content starts off screen, so on
|
||||
// iPhones with a notch or Dynamic Island the header and its close button sat
|
||||
// under the status bar, out of reach (attachment preview, QR scanner, paste
|
||||
// sign-in link, filter and event editors). Such modals use SafeAreaModal,
|
||||
// which gives the window its own SafeAreaProvider.
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||
return e.name.endsWith('.tsx') ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Opening tags of plain <Modal> elements, attributes included. */
|
||||
function modalTags(source: string): string[] {
|
||||
const tags: string[] = [];
|
||||
let at = source.indexOf('<Modal');
|
||||
while (at >= 0) {
|
||||
if (/[\s>]/.test(source[at + 6] ?? '')) {
|
||||
let depth = 0;
|
||||
let i = at;
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
|
||||
}
|
||||
tags.push(source.slice(at, i + 1));
|
||||
}
|
||||
at = source.indexOf('<Modal', at + 6);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
describe('slide-up modals with a SafeAreaView', () => {
|
||||
it('use SafeAreaModal so their header clears the status bar', () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
if (!source.includes('SafeAreaView')) return false;
|
||||
return modalTags(source).some((tag) => /animationType="slide"/.test(tag));
|
||||
})
|
||||
.map((file) => path.relative(SRC, file));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds the tags it is looking for', () => {
|
||||
const tags = modalTags('<View><Modal visible={x} onRequestClose={() => close()} animationType="slide">\n</Modal></View>');
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).toContain('animationType="slide"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Sheets and menus were built as a tappable backdrop (Pressable or
|
||||
// TouchableWithoutFeedback) wrapping a card that swallows taps. Both are
|
||||
// accessible elements by default, and an accessible element hides its
|
||||
// children from VoiceOver and TalkBack: the compose "Attach" and "Schedule
|
||||
// send" sheets read as one element ("Attach, Photos & Videos, Camera, Files,
|
||||
// Cancel") whose options couldn't be picked one by one. The wrappers that
|
||||
// only catch taps are marked accessible={false}, so the options inside stay
|
||||
// reachable.
|
||||
|
||||
const SRC = path.resolve(__dirname, '../..');
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return e.name === '__tests__' ? [] : sourceFiles(p);
|
||||
return e.name.endsWith('.tsx') ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function openingTags(source: string, name: string): { tag: string; line: number }[] {
|
||||
const out: { tag: string; line: number }[] = [];
|
||||
const re = new RegExp(`<${name}\\b`, 'g');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(source))) {
|
||||
let depth = 0;
|
||||
let i = m.index;
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
else if (ch === '>' && depth === 0 && source[i - 1] !== '=') break;
|
||||
}
|
||||
out.push({ tag: source.slice(m.index, i + 1), line: source.slice(0, m.index).split('\n').length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A wrapper that only catches taps and still hides what it wraps. */
|
||||
export function isHidingWrapper(tag: string): boolean {
|
||||
if (/accessible=\{false\}/.test(tag)) return false;
|
||||
if (/\/\s*>$/.test(tag)) return false; // self-closing: wraps nothing
|
||||
if (tag.startsWith('<TouchableWithoutFeedback')) return true;
|
||||
if (/onPress=\{\(\) => \{\}\}/.test(tag)) return true;
|
||||
return /style=\{styles\.\w*(Backdrop|backdrop|Overlay|overlay)\}/.test(tag);
|
||||
}
|
||||
|
||||
describe('sheet backdrops', () => {
|
||||
it('do not hide the options they wrap from screen readers', () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of sourceFiles(SRC)) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
for (const name of ['Pressable', 'TouchableWithoutFeedback']) {
|
||||
for (const { tag, line } of openingTags(source, name)) {
|
||||
if (isHidingWrapper(tag)) offenders.push(`${path.relative(SRC, file)}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('recognises the wrappers it looks for', () => {
|
||||
expect(isHidingWrapper('<Pressable style={styles.modalBackdrop} onPress={onClose}>')).toBe(true);
|
||||
expect(isHidingWrapper('<Pressable style={styles.card} onPress={() => {}}>')).toBe(true);
|
||||
expect(isHidingWrapper('<TouchableWithoutFeedback onPress={onClose}>')).toBe(true);
|
||||
expect(isHidingWrapper('<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>')).toBe(false);
|
||||
expect(isHidingWrapper('<Pressable style={styles.pickerOverlay} onPress={close} />')).toBe(false);
|
||||
expect(isHidingWrapper('<Pressable style={styles.row} onPress={open}>')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DARK_COLORS, LIGHT_COLORS, type ThemePalette } from '../../theme/tokens';
|
||||
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
|
||||
import { undoSnackbarColors } from '../undo-snackbar-colors';
|
||||
|
||||
// The undo bar (UndoSnackbar) is drawn in the text colour. Its UNDO button
|
||||
// used the primary colour, which is white in the default dark theme: white
|
||||
// on white, so in dark mode the bar showed "Email archived" and no visible
|
||||
// way to undo. The bound is 3:1, not 4.5:1, because Solarized's own text
|
||||
// and background are a little under 4.5:1.
|
||||
|
||||
function luminance(hex: string): number {
|
||||
const v = hex.replace('#', '');
|
||||
const full = v.length === 3 ? v.split('').map((ch) => ch + ch).join('') : v.slice(0, 6);
|
||||
const [r, g, b] = [0, 2, 4].map((i) => {
|
||||
const c = parseInt(full.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
const palettes: Array<[string, ThemePalette]> = [
|
||||
['default light', LIGHT_COLORS],
|
||||
['default dark', DARK_COLORS],
|
||||
...BUILTIN_THEMES.flatMap((theme): Array<[string, ThemePalette]> => [
|
||||
[`${theme.name} light`, { ...LIGHT_COLORS, ...theme.light } as ThemePalette],
|
||||
[`${theme.name} dark`, { ...DARK_COLORS, ...theme.dark } as ThemePalette],
|
||||
]),
|
||||
];
|
||||
|
||||
describe('undo bar', () => {
|
||||
it.each(palettes)('UNDO is readable on the bar (%s)', (_name, palette) => {
|
||||
const { bar, action } = undoSnackbarColors(palette);
|
||||
expect(contrast(action, bar)).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -74,9 +74,9 @@ export function CalendarEditSheet({ visible, calendar, onSave, onClose }: Calend
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.title}>
|
||||
{calendar
|
||||
|
||||
@@ -160,9 +160,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Users size={18} color={c.textMuted} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
@@ -69,6 +68,7 @@ import { ParticipantInput } from './ParticipantInput';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
@@ -419,7 +419,7 @@ export function EventModal({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="fullScreen"
|
||||
@@ -838,7 +838,7 @@ export function EventModal({
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||
View, Text, StyleSheet, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -21,6 +21,7 @@ import { formatFullDateTime } from '../../lib/email-date';
|
||||
import type { EmlPreview } from './use-body-override';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
export interface PreviewItem {
|
||||
@@ -432,9 +433,9 @@ export function AttachmentPreviewModal({
|
||||
);
|
||||
if (docked) return item || loading ? content : null;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
{content}
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -160,9 +160,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.titleRow}>
|
||||
<Icon size={18} color={c.textMuted} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Alert,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Alert,
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
FilterComparator,
|
||||
FilterActionType,
|
||||
} from '../../lib/sieve/types';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
|
||||
@@ -227,7 +228,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
@@ -490,7 +491,7 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
</Button>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, TextInput,
|
||||
View, Text, StyleSheet, ScrollView, Pressable, TextInput,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
@@ -9,6 +9,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
||||
import { useColors } from '../../theme/colors';
|
||||
import Button from '../Button';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
interface SieveEditorSheetProps {
|
||||
@@ -63,7 +64,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
||||
}, [script, showSaveWarning, onSave]);
|
||||
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
@@ -174,7 +175,7 @@ export function SieveEditorSheet({ visible, content, onSave, onClose, onValidate
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -196,8 +196,8 @@ export function ContentSendersSettings() {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setModalOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setModalOpen(false)}>
|
||||
<Pressable accessible={false} style={styles.modalCard} onPress={() => {}}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{t('settings.email_behavior.trusted_senders.modal_title', 'Trusted Senders')}</Text>
|
||||
<Pressable
|
||||
|
||||
@@ -298,7 +298,7 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setOpen(false)}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setOpen(false)}>
|
||||
<View style={styles.modalContent}>
|
||||
<ScrollView>
|
||||
{options.map((opt) => {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ThemePalette } from '../theme/tokens';
|
||||
|
||||
/**
|
||||
* Colours of the undo bar. The bar is drawn in the text colour, so what is
|
||||
* on it takes the background colour. The primary colour can't be used for
|
||||
* UNDO: the default dark theme's primary is white, like the bar.
|
||||
*/
|
||||
export function undoSnackbarColors(c: ThemePalette): { bar: string; label: string; action: string } {
|
||||
return { bar: c.text, label: c.background, action: c.background };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createAfterDismissQueue } from '../after-modal-dismiss';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createAfterDismissQueue', () => {
|
||||
it('holds the action on iOS until the modal is dismissed', () => {
|
||||
const queue = createAfterDismissQueue('ios');
|
||||
const action = vi.fn();
|
||||
queue.run(action);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
queue.flush();
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
queue.flush();
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs the action after the fallback delay when onDismiss never comes', () => {
|
||||
vi.useFakeTimers();
|
||||
const queue = createAfterDismissQueue('ios', 600);
|
||||
const action = vi.fn();
|
||||
queue.run(action);
|
||||
vi.advanceTimersByTime(599);
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not run the action twice when onDismiss and the timer both fire', () => {
|
||||
vi.useFakeTimers();
|
||||
const queue = createAfterDismissQueue('ios', 600);
|
||||
const action = vi.fn();
|
||||
queue.run(action);
|
||||
queue.flush();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('runs the action right away on Android', () => {
|
||||
const queue = createAfterDismissQueue('android');
|
||||
const action = vi.fn();
|
||||
queue.run(action);
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The compose "Attach" sheet closed its modal and opened the photo picker,
|
||||
// camera or file picker in the same tick. On iOS the picker never showed.
|
||||
describe('compose options sheet', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||
|
||||
it('opens the chosen option only after the sheet is dismissed', () => {
|
||||
expect(source).not.toMatch(/onClose\(\);\s*opt\.onPress\(\)/);
|
||||
expect(source).toMatch(/onDismiss=\{afterDismiss\.flush\}/);
|
||||
expect(source).toMatch(/afterDismiss\.run\(opt\.onPress\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildEditorHtml, FOCUS_END_SOURCE } from '../editor-html';
|
||||
import { LIGHT_COLORS } from '../../theme/tokens';
|
||||
|
||||
// On the iPad most of the composer's body area is empty space under the
|
||||
// editor; a tap there did nothing and the keyboard kept typing into Subject.
|
||||
// That space now calls the page's focusEnd(), which must put the caret where
|
||||
// the user's own text goes: in front of the signature or the quoted message.
|
||||
|
||||
interface FakeNode { name: string; previousElementSibling?: FakeNode | null }
|
||||
|
||||
function run(stop: FakeNode | null) {
|
||||
const calls: string[] = [];
|
||||
const editor = {
|
||||
name: 'editor',
|
||||
focus: () => calls.push('focus'),
|
||||
querySelector: () => stop,
|
||||
};
|
||||
const range = {
|
||||
selectNodeContents: (n: FakeNode) => calls.push(`select ${n.name}`),
|
||||
setStartBefore: (n: FakeNode) => calls.push(`before ${n.name}`),
|
||||
collapse: (toStart: boolean) => calls.push(toStart ? 'collapse start' : 'collapse end'),
|
||||
};
|
||||
const selection = {
|
||||
removeAllRanges: () => calls.push('clear'),
|
||||
addRange: (r: unknown) => calls.push(r === range ? 'add range' : 'add other'),
|
||||
};
|
||||
const fakeDocument = { createRange: () => range };
|
||||
const fakeWindow = { getSelection: () => selection };
|
||||
const focusEnd = new Function('document', 'window', `${FOCUS_END_SOURCE}; return focusEnd;`)(fakeDocument, fakeWindow);
|
||||
focusEnd(editor);
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe('focusEnd', () => {
|
||||
it('puts the caret at the end of the paragraph in front of the signature', () => {
|
||||
const mine = { name: 'mine' };
|
||||
expect(run({ name: 'signature', previousElementSibling: mine })).toEqual([
|
||||
'focus', 'select mine', 'collapse end', 'clear', 'add range',
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts the caret in front of a signature or quote that comes first', () => {
|
||||
expect(run({ name: 'quote', previousElementSibling: null })).toEqual([
|
||||
'focus', 'before quote', 'collapse start', 'clear', 'add range',
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts the caret at the end without signature or quote', () => {
|
||||
expect(run(null)).toEqual(['focus', 'select editor', 'collapse end', 'clear', 'add range']);
|
||||
});
|
||||
|
||||
it('is part of the page script and of its bridge', () => {
|
||||
const html = buildEditorHtml({ initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS });
|
||||
expect(html).toContain('function focusEnd(editor)');
|
||||
expect(html).toContain('focusEnd: function () { focusEnd(editor); }');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { iosTapPayload } from '../push-notifications';
|
||||
|
||||
// A tapped iOS mail notification: the relay's ids arrive as the
|
||||
// notification's data (APNs `body`), the notification extension adds the
|
||||
// message it showed, and the app opens that message under the account whose
|
||||
// push setup recorded the JMAP account id.
|
||||
|
||||
describe('iosTapPayload', () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ 'ada@mail.example.org': 'c' }));
|
||||
});
|
||||
|
||||
it('opens the message the extension showed, in the right account', async () => {
|
||||
const payload = await iosTapPayload({
|
||||
kind: 'jmap-email-push',
|
||||
accountId: 'c',
|
||||
emailIds: ['e1', 'e2'],
|
||||
emailId: 'e2',
|
||||
threadId: 't2',
|
||||
});
|
||||
expect(payload).toEqual({ emailId: 'e2', threadId: 't2', accountId: 'ada@mail.example.org', jmapAccountId: 'c' });
|
||||
});
|
||||
|
||||
it('falls back to the first pushed id when the extension could not fetch', async () => {
|
||||
const payload = await iosTapPayload({ kind: 'jmap-email-push', accountId: 'c', emailIds: ['e1'] });
|
||||
expect(payload?.emailId).toBe('e1');
|
||||
expect(payload?.threadId).toBe('');
|
||||
});
|
||||
|
||||
it('leaves calendar reminders and other local notifications alone', async () => {
|
||||
expect(await iosTapPayload({ eventId: 'ev1', calendarId: 'cal' })).toBeNull();
|
||||
expect(await iosTapPayload(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing for a push without a message id', async () => {
|
||||
expect(await iosTapPayload({ kind: 'jmap-state-change', accountId: 'c', emailIds: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// scripts/perf/module-timings.js wraps Metro's module definitions and adds
|
||||
// up each module's own evaluation time, without the time of the modules it
|
||||
// required while it ran.
|
||||
|
||||
function install(clock: { t: number }) {
|
||||
const g: Record<string, unknown> = { nativePerformanceNow: () => clock.t };
|
||||
const factories = new Map<number, () => void>();
|
||||
g.__d = (factory: () => void, id: number) => { factories.set(id, factory); };
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '../../../scripts/perf/module-timings.js'), 'utf8');
|
||||
new Function('globalThis', src)(g);
|
||||
(g.__d as (f: () => void, id: number) => void)(() => { clock.t += 5; }, 2);
|
||||
(g.__d as (f: () => void, id: number) => void)(() => {
|
||||
clock.t += 10;
|
||||
factories.get(2)!();
|
||||
clock.t += 3;
|
||||
}, 1);
|
||||
return { run: (id: number) => factories.get(id)!(), times: () => g.__bulwarkModuleTimes as Record<number, number> };
|
||||
}
|
||||
|
||||
describe('module timings polyfill', () => {
|
||||
it('counts a module without the modules it required', () => {
|
||||
const clock = { t: 0 };
|
||||
const m = install(clock);
|
||||
m.run(1);
|
||||
expect(m.times()).toEqual({ 1: 13, 2: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The module reads its switch once at load, so each case loads it fresh
|
||||
// with its own react-native mock.
|
||||
async function load(os: 'ios' | 'android', setting: unknown) {
|
||||
vi.resetModules();
|
||||
vi.doMock('react-native', () => ({
|
||||
Platform: { OS: os },
|
||||
Settings: { get: vi.fn(() => setting) },
|
||||
}));
|
||||
return import('../perf-marks');
|
||||
}
|
||||
|
||||
describe('perf marks', () => {
|
||||
let log: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
log.mockRestore();
|
||||
vi.doUnmock('react-native');
|
||||
});
|
||||
|
||||
it('logs nothing without the launch argument', async () => {
|
||||
const m = await load('ios', undefined);
|
||||
m.perfMark('bundle-loaded');
|
||||
m.perfStart('open-message');
|
||||
m.perfEnd('open-message');
|
||||
m.perfOnce('inbox-rendered');
|
||||
expect(m.perfMarksEnabled()).toBe(false);
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays off on Android even when the setting is there', async () => {
|
||||
const m = await load('android', 'YES');
|
||||
m.perfMark('bundle-loaded');
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs marks and span durations with -perfMarks YES', async () => {
|
||||
const m = await load('ios', 'YES');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
m.perfStart('open-message');
|
||||
vi.setSystemTime(1_250);
|
||||
m.perfEnd('open-message', { id: 'x' });
|
||||
vi.useRealTimers();
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
const line = String(log.mock.calls[0][0]);
|
||||
expect(line.startsWith('[perf] ')).toBe(true);
|
||||
expect(JSON.parse(line.slice(7))).toEqual({ n: 'open-message', t: 1_250, ms: 250, id: 'x' });
|
||||
});
|
||||
|
||||
it('logs a once-mark only once and ignores an end without a start', async () => {
|
||||
const m = await load('ios', true);
|
||||
m.perfOnce('inbox-rendered', { rows: 3 });
|
||||
m.perfOnce('inbox-rendered', { rows: 9 });
|
||||
m.perfEnd('never-started');
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
expect(String(log.mock.calls[0][0])).toContain('"rows":3');
|
||||
});
|
||||
it('logs the slowest modules once, with the total', async () => {
|
||||
const m = await load('ios', true);
|
||||
(globalThis as { __bulwarkModuleTimes?: Record<string, number> }).__bulwarkModuleTimes = { 7: 12.34, 3: 50, 9: 1 };
|
||||
try {
|
||||
m.logModuleTimes();
|
||||
m.logModuleTimes();
|
||||
const lines = log.mock.calls.map((c: unknown[]) => String(c[0])).filter((l: string) => l.includes('module-times'));
|
||||
expect(lines).toHaveLength(1);
|
||||
const mark = JSON.parse(lines[0].slice('[perf] '.length));
|
||||
expect(mark.total).toBe(63);
|
||||
expect(mark.count).toBe(3);
|
||||
expect(mark.top).toBe('3:50,7:12.3,9:1');
|
||||
} finally {
|
||||
delete (globalThis as { __bulwarkModuleTimes?: unknown }).__bulwarkModuleTimes;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// iOS can't present a view controller (the photo picker, the camera, the
|
||||
// document picker, a share sheet) while a React Native Modal is still
|
||||
// dismissing: UIKit drops the request without an error and the picker never
|
||||
// shows. A sheet whose option opens a picker has to wait until it is gone.
|
||||
//
|
||||
// run() keeps the action until flush(), which the Modal's onDismiss calls.
|
||||
// A timer flushes too, in case onDismiss doesn't fire. Android has no such
|
||||
// limit, so the action runs right away there.
|
||||
|
||||
export interface AfterDismissQueue {
|
||||
run(action: () => void): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
export function createAfterDismissQueue(
|
||||
platform: string,
|
||||
fallbackMs = 600,
|
||||
): AfterDismissQueue {
|
||||
let pending: (() => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
const action = pending;
|
||||
pending = null;
|
||||
action?.();
|
||||
};
|
||||
|
||||
return {
|
||||
run(action) {
|
||||
if (platform !== 'ios') {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
if (timer) clearTimeout(timer);
|
||||
pending = action;
|
||||
timer = setTimeout(flush, fallbackMs);
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,33 @@ export function shouldBlockEditorRemoteImages(opts: {
|
||||
return !senderTrusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-script function for a tap on the empty space under the editor: focus
|
||||
* it with the caret where the user's own text goes, in front of the
|
||||
* signature or the quoted message, else at the end. A string, embedded in
|
||||
* the page script and run by the unit test against stand-in DOM objects.
|
||||
*/
|
||||
export const FOCUS_END_SOURCE = `function focusEnd(editor) {
|
||||
editor.focus();
|
||||
var sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
var stop = editor.querySelector('[data-signature-block="start"], [data-signature-block="separator"], [data-quoted-html]');
|
||||
var range = document.createRange();
|
||||
var prev = stop ? stop.previousElementSibling : null;
|
||||
if (prev) {
|
||||
range.selectNodeContents(prev);
|
||||
range.collapse(false);
|
||||
} else if (stop) {
|
||||
range.setStartBefore(stop);
|
||||
range.collapse(true);
|
||||
} else {
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}`;
|
||||
|
||||
export function buildEditorHtml(opts: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
@@ -261,6 +288,8 @@ export function buildEditorHtml(opts: {
|
||||
});
|
||||
|
||||
// ── Bridge: receive commands from RN ───────────────────────────────
|
||||
${FOCUS_END_SOURCE}
|
||||
|
||||
window.__rne = {
|
||||
exec: function (command, value) {
|
||||
editor.focus();
|
||||
@@ -363,6 +392,7 @@ export function buildEditorHtml(opts: {
|
||||
post('htmlSnapshot', { id: id, html: editor.innerHTML });
|
||||
},
|
||||
focus: function () { editor.focus(); },
|
||||
focusEnd: function () { focusEnd(editor); },
|
||||
};
|
||||
|
||||
reportHeight();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// What the app shares with the iOS notification service extension
|
||||
// (plugins/ios-push): account credentials, where to reach each pushed account,
|
||||
// and the badge counts. They live in a keychain access group both targets
|
||||
// belong to; nothing else of the app is visible to the extension.
|
||||
//
|
||||
// Readable after the first unlock: a push usually arrives while the phone is
|
||||
// locked, and a notification that has to wait for the user to unlock would
|
||||
// show only "New message".
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type Extra = { iosSharedKeychainGroup?: unknown; iosAppKeychainGroup?: unknown; apnsEnvironment?: unknown };
|
||||
|
||||
// Loaded on first use, on iOS only: the module pulls in native code that
|
||||
// Android's headless tasks and the unit tests can do without.
|
||||
function extra(): Extra {
|
||||
const { default: Constants } = require('expo-constants') as typeof import('expo-constants');
|
||||
return (Constants.expoConfig?.extra ?? {}) as Extra;
|
||||
}
|
||||
|
||||
/** The APNs host this build's device tokens belong to. */
|
||||
export function apnsEnvironment(): 'production' | 'sandbox' {
|
||||
return extra().apnsEnvironment === 'sandbox' ? 'sandbox' : 'production';
|
||||
}
|
||||
|
||||
/** The access group shared with the extension, or null off iOS. */
|
||||
export function sharedKeychainGroup(): string | null {
|
||||
if (Platform.OS !== 'ios') return null;
|
||||
const group = extra().iosSharedKeychainGroup;
|
||||
return typeof group === 'string' && group ? group : null;
|
||||
}
|
||||
|
||||
/** Options for an item the extension must read. Undefined off iOS. */
|
||||
export function sharedStoreOptions(): SecureStore.SecureStoreOptions | undefined {
|
||||
const group = sharedKeychainGroup();
|
||||
if (!group) return undefined;
|
||||
return { accessGroup: group, keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK };
|
||||
}
|
||||
|
||||
/** Options naming the app's own access group, where items used to live. */
|
||||
export function appStoreOptions(): SecureStore.SecureStoreOptions | undefined {
|
||||
if (Platform.OS !== 'ios') return undefined;
|
||||
const group = extra().iosAppKeychainGroup;
|
||||
return typeof group === 'string' && group ? { accessGroup: group } : undefined;
|
||||
}
|
||||
|
||||
// SecureStore keys: letters, digits, ".", "-", "_" only. The extension
|
||||
// builds the same key from the push's account id (NotificationService.swift).
|
||||
function keyPart(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
}
|
||||
|
||||
export function pushAccountKey(jmapAccountId: string): string {
|
||||
return `bulwark_push_account__${keyPart(jmapAccountId)}`;
|
||||
}
|
||||
|
||||
export const PUSH_SETTINGS_KEY = 'bulwark_push_settings';
|
||||
|
||||
export interface IosPushAccount {
|
||||
/** SecureStore key of the account's credentials. */
|
||||
credentialsKey: string;
|
||||
/** The JMAP API URL as the app uses it (rewritten to the server's host). */
|
||||
apiUrl: string;
|
||||
}
|
||||
|
||||
export async function writeIosPushAccount(jmapAccountId: string, account: IosPushAccount): Promise<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.setItemAsync(pushAccountKey(jmapAccountId), JSON.stringify(account), options);
|
||||
}
|
||||
|
||||
export async function removeIosPushAccount(jmapAccountId: string): Promise<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.deleteItemAsync(pushAccountKey(jmapAccountId), options).catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface IosPushSettings {
|
||||
/** Show the unread count on the app icon. */
|
||||
badge: boolean;
|
||||
/** JMAP id of the active account: the badge is its inbox unread count. */
|
||||
activeAccountId: string | null;
|
||||
/** The few texts the extension writes itself, in the app's language. */
|
||||
strings: { noSubject: string; more: string };
|
||||
}
|
||||
|
||||
export async function writeIosPushSettings(settings: IosPushSettings): Promise<void> {
|
||||
const options = sharedStoreOptions();
|
||||
if (!options) return;
|
||||
await SecureStore.setItemAsync(PUSH_SETTINGS_KEY, JSON.stringify(settings), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credentials item where the extension can read it. Written to the
|
||||
* shared group first, then removed from the app's own group, so a crash in
|
||||
* between leaves two copies rather than none. Off iOS a plain write.
|
||||
*/
|
||||
export async function writeSharedSecret(key: string, value: string): Promise<void> {
|
||||
const shared = sharedStoreOptions();
|
||||
if (!shared) {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
return;
|
||||
}
|
||||
await SecureStore.setItemAsync(key, value, shared);
|
||||
const own = appStoreOptions();
|
||||
if (own) await SecureStore.deleteItemAsync(key, own).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Move an item saved before the shared group existed. True if it moved. */
|
||||
export async function moveToSharedGroup(key: string): Promise<boolean> {
|
||||
const own = appStoreOptions();
|
||||
if (!own || !sharedStoreOptions()) return false;
|
||||
const value = await SecureStore.getItemAsync(key, own).catch(() => null);
|
||||
if (value === null) return false;
|
||||
await writeSharedSecret(key, value);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Timing marks for the performance harness (scripts/e2e/perf.mjs).
|
||||
//
|
||||
// Off unless the app was launched with the `-perfMarks YES` launch argument
|
||||
// (iOS puts launch arguments into NSUserDefaults, which React Native's
|
||||
// Settings module reads). When on, every mark is one `[perf]` line on the
|
||||
// console; on iOS that lands in the system log, where the harness reads it
|
||||
// with `log show --info`. Nothing is stored or sent anywhere.
|
||||
|
||||
import { Platform, Settings } from 'react-native';
|
||||
|
||||
function readEnabled(): boolean {
|
||||
try {
|
||||
if (Platform.OS !== 'ios') return false;
|
||||
const value = Settings.get('perfMarks') as unknown;
|
||||
return value === true || value === 'YES' || value === 'true' || value === 1 || value === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = readEnabled();
|
||||
|
||||
export function perfMarksEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Log a mark with the wall-clock time (the harness compares across processes). */
|
||||
export function perfMark(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||
if (!enabled) return;
|
||||
console.log(`[perf] ${JSON.stringify({ n: name, t: Date.now(), ...detail })}`);
|
||||
}
|
||||
|
||||
const started = new Map<string, number>();
|
||||
|
||||
/** Start a timed span; `perfEnd` with the same name logs its duration. */
|
||||
export function perfStart(name: string): void {
|
||||
if (!enabled) return;
|
||||
started.set(name, Date.now());
|
||||
}
|
||||
|
||||
export function perfEnd(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||
if (!enabled) return;
|
||||
const t0 = started.get(name);
|
||||
if (t0 === undefined) return;
|
||||
started.delete(name);
|
||||
perfMark(name, { ms: Date.now() - t0, ...detail });
|
||||
}
|
||||
|
||||
/** Log how far into a running span a step got, without ending it. */
|
||||
export function perfLap(name: string, lap: string): void {
|
||||
if (!enabled) return;
|
||||
const t0 = started.get(name);
|
||||
if (t0 === undefined) return;
|
||||
perfMark(`${name}:${lap}`, { ms: Date.now() - t0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* End a span once the next frames are drawn: two animation frames after the
|
||||
* state change that rendered the result, so the time includes commit and
|
||||
* paint, not just the JS render.
|
||||
*/
|
||||
export function perfEndAfterPaint(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||
if (!enabled || !started.has(name)) return;
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => perfEnd(name, detail)));
|
||||
}
|
||||
|
||||
let onceDone = new Set<string>();
|
||||
/** A mark that is logged once per process (first inbox render, ...). */
|
||||
export function perfOnce(name: string, detail?: Record<string, string | number | boolean>): void {
|
||||
if (!enabled || onceDone.has(name)) return;
|
||||
onceDone.add(name);
|
||||
perfMark(name, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules whose own evaluation took longest so far, when the bundle was
|
||||
* built with BULWARK_MODULE_TIMINGS (scripts/perf/module-timings.js): the
|
||||
* 60 slowest as "id:ms" pairs, plus the total. Logged once, at first inbox.
|
||||
*/
|
||||
export function logModuleTimes(): void {
|
||||
if (!enabled || onceDone.has('module-times')) return;
|
||||
const times = (globalThis as { __bulwarkModuleTimes?: Record<string, number> }).__bulwarkModuleTimes;
|
||||
if (!times) return;
|
||||
onceDone.add('module-times');
|
||||
const entries = Object.entries(times);
|
||||
const total = entries.reduce((sum, [, ms]) => sum + ms, 0);
|
||||
const top = entries
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 60)
|
||||
.map(([id, ms]) => `${id}:${Math.round(ms * 10) / 10}`)
|
||||
.join(',');
|
||||
perfMark('module-times', { total: Math.round(total), count: entries.length, top });
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
export function resetPerfMarks(): void {
|
||||
started.clear();
|
||||
onceDone = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame monitor for scrolling: counts animation frames between `start` and
|
||||
* `stop` and how many of them took longer than two vsyncs (a visible hitch
|
||||
* at 60 Hz). JS-thread frames, so it shows JS work blocking during a scroll.
|
||||
*/
|
||||
export function createFrameMonitor(name: string): { start: () => void; stop: () => void } {
|
||||
let raf: number | null = null;
|
||||
let last = 0;
|
||||
let frames = 0;
|
||||
let long = 0;
|
||||
let worst = 0;
|
||||
let t0 = 0;
|
||||
const tick = (now: number) => {
|
||||
if (last) {
|
||||
const dt = now - last;
|
||||
frames += 1;
|
||||
if (dt > 34) long += 1;
|
||||
if (dt > worst) worst = dt;
|
||||
}
|
||||
last = now;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (!enabled || raf !== null) return;
|
||||
frames = 0;
|
||||
long = 0;
|
||||
worst = 0;
|
||||
last = 0;
|
||||
t0 = Date.now();
|
||||
raf = requestAnimationFrame(tick);
|
||||
},
|
||||
stop() {
|
||||
if (raf === null) return;
|
||||
cancelAnimationFrame(raf);
|
||||
raf = null;
|
||||
perfMark(name, { ms: Date.now() - t0, frames, long, worst: Math.round(worst) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
persistedAccounts,
|
||||
} from '../device-sync/app/prefs';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../device-sync/types';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { credentialsKey } from '../api/jmap-client';
|
||||
import { apnsEnvironment, removeIosPushAccount, writeIosPushAccount } from './ios-push-shared';
|
||||
import {
|
||||
getUnifiedPushDistributors,
|
||||
isUnifiedPushSupported,
|
||||
@@ -524,12 +527,12 @@ function getNative(): BulwarkFcmNative | null {
|
||||
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
|
||||
}
|
||||
|
||||
/** True on platforms that have a push transport wired up (Android only). */
|
||||
/** True on platforms that have a push transport wired up: FCM or UnifiedPush on Android, APNs on iOS. */
|
||||
export function isPushSupported(): boolean {
|
||||
return getNative() !== null || isUnifiedPushSupported();
|
||||
return Platform.OS === 'ios' || getNative() !== null || isUnifiedPushSupported();
|
||||
}
|
||||
|
||||
export type PushTransport = 'fcm' | 'unifiedpush';
|
||||
export type PushTransport = 'fcm' | 'unifiedpush' | 'apns';
|
||||
|
||||
export async function getStoredPushTransport(): Promise<PushTransport | null> {
|
||||
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
|
||||
@@ -545,6 +548,8 @@ export async function setStoredPushTransport(transport: PushTransport | null): P
|
||||
}
|
||||
|
||||
export async function getEffectivePushTransport(): Promise<PushTransport> {
|
||||
// iOS has one way in: APNs, through the relay.
|
||||
if (Platform.OS === 'ios') return 'apns';
|
||||
return (await getStoredPushTransport()) ?? 'fcm';
|
||||
}
|
||||
|
||||
@@ -637,6 +642,14 @@ export async function dismissPushPrompt(accountId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission(): Promise<boolean> {
|
||||
if (Platform.OS === 'ios') {
|
||||
const current = await Notifications.getPermissionsAsync();
|
||||
if (current.granted) return true;
|
||||
const asked = await Notifications.requestPermissionsAsync({
|
||||
ios: { allowAlert: true, allowBadge: true, allowSound: true },
|
||||
});
|
||||
return asked.granted;
|
||||
}
|
||||
if (Platform.OS !== 'android') return true;
|
||||
if (Platform.Version < 33) return true;
|
||||
const status = await PermissionsAndroid.request(
|
||||
@@ -688,6 +701,26 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
// The APNs device token (hex). iOS hands it out once notifications are
|
||||
// allowed; expo-notifications asks the OS for it.
|
||||
async function getApnsTokenOrThrow(): Promise<string> {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
const result = await Notifications.getDevicePushTokenAsync();
|
||||
token = typeof result.data === 'string' ? result.data : null;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'token',
|
||||
t('settings.notifications.push.err_apns_token', 'iOS could not issue a push token ({detail}).', { detail }),
|
||||
);
|
||||
}
|
||||
if (!token) {
|
||||
throw new PushSetupError('token', t('settings.notifications.push.err_apns_empty', 'iOS returned an empty push token.'));
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay's VAPID public key, when it has one. Passed to the UnifiedPush
|
||||
* distributor at registration so distributors that pin the application server
|
||||
@@ -778,6 +811,64 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerWithRelayApns(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
deviceToken: string;
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/apns'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subscriptionId: params.subscriptionId,
|
||||
deviceToken: params.deviceToken,
|
||||
environment: apnsEnvironment(),
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new PushSetupError(
|
||||
'relay',
|
||||
t('settings.notifications.push.err_relay_unreachable', 'Could not reach the push relay at {url} ({detail}).', {
|
||||
url: params.relayBaseUrl,
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (res.status === 503) {
|
||||
throw new PushSetupError(
|
||||
'relay',
|
||||
t('settings.notifications.push.err_relay_no_apns', 'The push relay at {url} does not deliver to iPhones and iPads yet.', {
|
||||
url: params.relayBaseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new PushSetupError(
|
||||
'relay',
|
||||
t('settings.notifications.push.err_relay_rejected', 'The push relay rejected the registration: {detail}', {
|
||||
detail: await readRelayError(res),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the iOS notification extension how to reach an account: which stored
|
||||
* credentials to use and the JMAP API URL. Without it the extension shows the
|
||||
* relay's placeholder text.
|
||||
*/
|
||||
export async function shareAccountWithExtension(accountId: string, jmapAccountId: string | null): Promise<void> {
|
||||
if (Platform.OS !== 'ios' || !jmapAccountId) return;
|
||||
const apiUrl = jmapClient.currentSession?.apiUrl;
|
||||
if (!apiUrl) return;
|
||||
await writeIosPushAccount(jmapAccountId, { credentialsKey: credentialsKey(accountId), apiUrl }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function registerWithRelayUnifiedPush(params: {
|
||||
relayBaseUrl: string;
|
||||
subscriptionId: string;
|
||||
@@ -948,6 +1039,9 @@ async function setupPushNotificationsInner(
|
||||
if (transport === 'fcm' && !native) {
|
||||
throw new PushSetupError('platform', t('settings.notifications.push.err_android_only', 'Push notifications are only available on Android.'));
|
||||
}
|
||||
if (transport === 'apns' && Platform.OS !== 'ios') {
|
||||
throw new PushSetupError('platform', 'APNs is only available on iOS.');
|
||||
}
|
||||
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
|
||||
throw new PushSetupError('platform', t('settings.notifications.push.err_up_android_only', 'UnifiedPush is only available on Android.'));
|
||||
}
|
||||
@@ -967,8 +1061,11 @@ async function setupPushNotificationsInner(
|
||||
logPhase('token', transport);
|
||||
let fcmToken: string | null = null;
|
||||
let upEndpoint: UnifiedPushEndpoint | null = null;
|
||||
let apnsToken: string | null = null;
|
||||
if (transport === 'unifiedpush') {
|
||||
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
|
||||
} else if (transport === 'apns') {
|
||||
apnsToken = await getApnsTokenOrThrow();
|
||||
} else {
|
||||
fcmToken = await getFcmTokenOrThrow(native!);
|
||||
}
|
||||
@@ -993,7 +1090,14 @@ async function setupPushNotificationsInner(
|
||||
// individually and tags it with the JMAP account id so the headless task
|
||||
// can route it.
|
||||
logPhase('relay', relayBaseUrl);
|
||||
if (upEndpoint) {
|
||||
if (apnsToken) {
|
||||
await registerWithRelayApns({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
deviceToken: apnsToken,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
} else if (upEndpoint) {
|
||||
await registerWithRelayUnifiedPush({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
@@ -1043,6 +1147,7 @@ async function setupPushNotificationsInner(
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
await addPushAccountId(accountId);
|
||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||
await shareAccountWithExtension(accountId, jmapAccountId);
|
||||
await AsyncStorage.removeItem(optedOutKey(accountId));
|
||||
logPhase('done', 'reused existing subscription');
|
||||
return { subscriptionId: storedServerId, verified: true };
|
||||
@@ -1125,6 +1230,7 @@ async function setupPushNotificationsInner(
|
||||
}
|
||||
await addPushAccountId(accountId);
|
||||
await writePushJmapAccountId(accountId, jmapAccountId);
|
||||
await shareAccountWithExtension(accountId, jmapAccountId);
|
||||
await AsyncStorage.removeItem(optedOutKey(accountId));
|
||||
logPhase('done', 'subscription verified');
|
||||
|
||||
@@ -1196,6 +1302,10 @@ async function deregisterFromRelay(
|
||||
}
|
||||
|
||||
async function clearAccountPushKeys(accountId: string): Promise<void> {
|
||||
if (Platform.OS === 'ios') {
|
||||
const jmapAccountId = (await readPushJmapAccountIds())[accountId];
|
||||
if (jmapAccountId) await removeIosPushAccount(jmapAccountId);
|
||||
}
|
||||
await AsyncStorage.multiRemove([
|
||||
subscriptionIdKey(accountId),
|
||||
deviceClientIdKey(accountId),
|
||||
@@ -1509,6 +1619,17 @@ export function notificationTapJmapAccountId(payload: NotificationTapPayload): s
|
||||
// before JS was ready to handle it. Subsequent taps while running are delivered
|
||||
// via addNotificationTapListener.
|
||||
export async function getInitialNotificationTap(): Promise<NotificationTapPayload | null> {
|
||||
if (Platform.OS === 'ios') {
|
||||
const response = await Notifications.getLastNotificationResponseAsync().catch(() => null);
|
||||
if (!response || response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return null;
|
||||
const handledKey = 'push:lastHandledResponse:v1';
|
||||
const id = response.notification.request.identifier;
|
||||
// The last response survives app restarts; open it once.
|
||||
if ((await AsyncStorage.getItem(handledKey)) === id) return null;
|
||||
const payload = await iosTapPayload(response.notification.request.content.data);
|
||||
if (payload) await AsyncStorage.setItem(handledKey, id);
|
||||
return payload;
|
||||
}
|
||||
if (Platform.OS !== 'android') return null;
|
||||
const native = (NativeModules as Record<string, unknown>).BulwarkFcm as
|
||||
| { getInitialNotification?: () => Promise<NotificationTapPayload | null> }
|
||||
@@ -1524,8 +1645,47 @@ export async function getInitialNotificationTap(): Promise<NotificationTapPayloa
|
||||
export function addNotificationTapListener(
|
||||
listener: (payload: NotificationTapPayload) => void,
|
||||
): () => void {
|
||||
if (Platform.OS === 'ios') {
|
||||
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) return;
|
||||
const id = response.notification.request.identifier;
|
||||
void iosTapPayload(response.notification.request.content.data).then((payload) => {
|
||||
if (!payload) return;
|
||||
void AsyncStorage.setItem('push:lastHandledResponse:v1', id);
|
||||
listener(payload);
|
||||
});
|
||||
});
|
||||
return () => sub.remove();
|
||||
}
|
||||
if (Platform.OS !== 'android') return () => undefined;
|
||||
const emitter = new NativeEventEmitter(NativeModules.BulwarkFcm);
|
||||
const sub = emitter.addListener('fcm:notificationTap', listener);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* A tapped iOS mail notification as the app opens it. The relay puts the
|
||||
* JMAP account and message ids into the payload's `body`, which
|
||||
* expo-notifications hands over as `data`; the notification extension adds
|
||||
* the message (and thread) it showed. Calendar reminders and other local
|
||||
* notifications carry no `kind` and are left to their own handlers.
|
||||
*/
|
||||
export async function iosTapPayload(data: unknown): Promise<NotificationTapPayload | null> {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const d = data as Record<string, unknown>;
|
||||
if (d.kind !== 'jmap-email-push' && d.kind !== 'jmap-state-change') return null;
|
||||
const jmapAccountId = typeof d.accountId === 'string' ? d.accountId : '';
|
||||
const ids = Array.isArray(d.emailIds) ? d.emailIds.filter((v): v is string => typeof v === 'string') : [];
|
||||
const emailId = typeof d.emailId === 'string' && d.emailId ? d.emailId : ids[0];
|
||||
if (!emailId) return null;
|
||||
// The local account the subscription belongs to: the one whose push
|
||||
// setup recorded this JMAP account id.
|
||||
const byLocal = await readPushJmapAccountIds();
|
||||
const accountId = Object.keys(byLocal).find((local) => byLocal[local] === jmapAccountId);
|
||||
return {
|
||||
emailId,
|
||||
threadId: typeof d.threadId === 'string' ? d.threadId : '',
|
||||
accountId,
|
||||
jmapAccountId: jmapAccountId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { getEmailInitials } from '../lib/avatar-utils';
|
||||
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
|
||||
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
|
||||
import { useLayout } from '../lib/use-layout';
|
||||
import { createAfterDismissQueue } from '../lib/after-modal-dismiss';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
@@ -375,16 +376,26 @@ function OptionsSheet({
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
// An option may open a system picker, which iOS only shows once this
|
||||
// modal is gone.
|
||||
const afterDismiss = React.useRef(createAfterDismissQueue(Platform.OS)).current;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={onClose}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Modal
|
||||
supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
onDismiss={afterDismiss.flush}
|
||||
>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={onClose}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
{!!title && <Text style={styles.modalTitle} numberOfLines={2}>{title}</Text>}
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.label}
|
||||
style={styles.scheduleRow}
|
||||
onPress={() => { onClose(); opt.onPress(); }}
|
||||
onPress={() => { onClose(); afterDismiss.run(opt.onPress); }}
|
||||
>
|
||||
<Text style={[styles.scheduleRowLabel, opt.destructive && { color: c.error }]}>{opt.label}</Text>
|
||||
</Pressable>
|
||||
@@ -2484,6 +2495,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
onViewportShift={onEditorViewportShift}
|
||||
/>
|
||||
)}
|
||||
{/* The empty space under the body looks like part of it (on a
|
||||
tablet, most of the panel): a tap there starts typing in it. */}
|
||||
<Pressable
|
||||
style={styles.bodyFiller}
|
||||
accessible={false}
|
||||
onPress={() => {
|
||||
if (plainTextMode) plainBodyRef.current?.focus();
|
||||
else editorRef.current?.focusEnd();
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* A phone in landscape with the keyboard up has room for a few lines
|
||||
@@ -2647,8 +2668,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
{/* Text / background colour palette */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{colorTarget === 'background'
|
||||
? t('email_composer.toolbar.background_color', 'Background color')
|
||||
@@ -2716,8 +2737,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
animationType="fade"
|
||||
onRequestClose={() => setScheduleSheetOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setScheduleSheetOpen(false)}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{t('email_composer.schedule_send', 'Schedule send')}
|
||||
</Text>
|
||||
@@ -2750,8 +2771,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
{customStage !== null && Platform.OS === 'ios' && (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.scheduleCard} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable accessible={false} style={styles.scheduleCard} onPress={() => {}}>
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode="datetime"
|
||||
@@ -2980,6 +3001,7 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
scrollContent: { flexGrow: 1 },
|
||||
bodyFiller: { flexGrow: 1, minHeight: 48 },
|
||||
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1254,6 +1254,7 @@ export default function ContactFormScreen() {
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.given_name', 'First name')}
|
||||
ref={givenRef}
|
||||
testID="contact-first-name"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => middleRef.current?.focus()}
|
||||
@@ -1280,6 +1281,7 @@ export default function ContactFormScreen() {
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.surname', 'Last name')}
|
||||
ref={surnameRef}
|
||||
testID="contact-last-name"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => fullRef.current?.focus()}
|
||||
@@ -1347,6 +1349,7 @@ export default function ContactFormScreen() {
|
||||
style={styles.input}
|
||||
placeholder={t('contacts.form.email_placeholder', 'email@example.com')}
|
||||
ref={i === 0 ? firstEmailRef : undefined}
|
||||
testID={`contact-email-${i}`}
|
||||
placeholderTextColor={c.textMuted}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -56,6 +56,7 @@ import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import { usePendingMailSearch } from '../navigation/pending-mail-search';
|
||||
import type { Attachment, Email } from '../api/types';
|
||||
import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
|
||||
function getSenderName(email: Email, unknownLabel: string): string {
|
||||
@@ -473,6 +474,17 @@ export default function EmailListScreen({
|
||||
() => collapseThreads(emails, disableThreading),
|
||||
[emails, disableThreading],
|
||||
);
|
||||
// Performance harness (off unless launched with -perfMarks YES).
|
||||
const scrollMonitor = React.useMemo(() => createFrameMonitor('list-scroll'), []);
|
||||
React.useEffect(() => { perfOnce('list-mounted'); }, []);
|
||||
React.useEffect(() => {
|
||||
if (visibleEmails.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
perfOnce('inbox-rendered', { rows: visibleEmails.length });
|
||||
logModuleTimes();
|
||||
});
|
||||
}
|
||||
}, [visibleEmails.length]);
|
||||
const threadGroups = React.useMemo(
|
||||
() => groupByThread(emails, disableThreading),
|
||||
[emails, disableThreading],
|
||||
@@ -645,7 +657,10 @@ export default function EmailListScreen({
|
||||
const email = emailsRef.current.find((e) => rowKeyOf(e) === id);
|
||||
if (!email) return;
|
||||
if (isDraftEmail(email, currentRoleRef.current)) void openDraftRef.current(email);
|
||||
else onEmailPressRef.current?.(email);
|
||||
else {
|
||||
perfStart('open-message');
|
||||
onEmailPressRef.current?.(email);
|
||||
}
|
||||
}
|
||||
}, [toggleSelect]);
|
||||
|
||||
@@ -1563,6 +1578,8 @@ export default function EmailListScreen({
|
||||
contentContainerStyle={styles.listContent}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.3}
|
||||
onScrollBeginDrag={scrollMonitor.start}
|
||||
onMomentumScrollEnd={scrollMonitor.stop}
|
||||
refreshing={loading}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
@@ -1570,7 +1587,10 @@ export default function EmailListScreen({
|
||||
|
||||
{/* Compose FAB - matches webmail mobile: PenSquare, h-14 w-14, rounded-full, shadow-lg */}
|
||||
<Pressable
|
||||
onPress={onComposePress}
|
||||
onPress={() => {
|
||||
perfStart('compose-open');
|
||||
onComposePress?.();
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.fab,
|
||||
// Lifted over the phone's undo bar, which used to cover it.
|
||||
@@ -1593,9 +1613,9 @@ export default function EmailListScreen({
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFilterMenuOpen(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setFilterMenuOpen(false)}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
|
||||
<View style={styles.filterBackdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.filterMenu}>
|
||||
<View style={styles.filterMenuHeader}>
|
||||
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
||||
|
||||
@@ -1179,9 +1179,9 @@ function PromptModal(props: PromptModalProps) {
|
||||
animationType="fade"
|
||||
onRequestClose={props.onCancel}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={props.onCancel}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={props.onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.dialog}>
|
||||
<Text style={styles.title}>{props.title}</Text>
|
||||
<TextInput
|
||||
@@ -1246,9 +1246,9 @@ function ActionsSheet({
|
||||
const modified = modifiedOf(target);
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{target.displayName}
|
||||
@@ -1370,9 +1370,9 @@ function FolderPickerSheet({ target, allNodes, rootLabel, t, onClose, onPick }:
|
||||
const currentParent = target.parentId ?? null;
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle} numberOfLines={1}>
|
||||
{t('files.move_to', 'Move to…')} · {target.displayName}
|
||||
@@ -1427,9 +1427,9 @@ function SortSheet({ visible, sortKey, sortDir, t, onClose, onChangeKey, onChang
|
||||
const keys: FilesSortKey[] = ['name', 'size', 'modified'];
|
||||
return (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="slide" onRequestClose={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={onClose}>
|
||||
<View style={[styles.backdrop, { paddingLeft: sheetInsets.left, paddingRight: sheetInsets.right }]}>
|
||||
<TouchableWithoutFeedback>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.sheetTitle}>{t('files.settings_default_sort', 'Sort')}</Text>
|
||||
{keys.map((k) => (
|
||||
|
||||
@@ -326,8 +326,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
{/* Per-message actions */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setActionsFor(null)}>
|
||||
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||
{actionsFor?.subject || t('email_composer.no_subject', '(No Subject)')}
|
||||
</Text>
|
||||
@@ -349,8 +349,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
{/* Reschedule presets */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!rescheduleFor && customStage === null} transparent animationType="fade" onRequestClose={() => setRescheduleFor(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setRescheduleFor(null)}>
|
||||
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.cardTitle}>{t('email_list.reschedule_send', 'Reschedule')}</Text>
|
||||
{reschedulePresets.map((preset) => (
|
||||
<Pressable
|
||||
@@ -379,8 +379,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
{customStage !== null && Platform.OS === 'ios' && (
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} transparent animationType="fade" onRequestClose={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => setCustomStage(null)}>
|
||||
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||
<DateTimePicker
|
||||
value={customDraftRef.current}
|
||||
mode="datetime"
|
||||
|
||||
@@ -129,6 +129,31 @@ describe('auth-store', () => {
|
||||
expect(state.serverUrl).toBeNull();
|
||||
expect(state.username).toBeNull();
|
||||
});
|
||||
|
||||
// logout used to read the account list from a snapshot taken before it
|
||||
// removed the account: the removed account still looked like one to
|
||||
// switch to, the "switch" to the active account returned at once, and
|
||||
// signing out of the only account left the app signed in.
|
||||
it('signs out of the only account', async () => {
|
||||
const id = 'me@mail.example.com';
|
||||
useAccountStore.setState({
|
||||
accounts: [{
|
||||
id, serverUrl: 'https://mail.example.com', username: 'me',
|
||||
displayName: 'me', email: 'me', avatarColor: '#000', lastLoginAt: 0,
|
||||
isConnected: true, hasError: false, isDefault: true,
|
||||
}],
|
||||
activeAccountId: id,
|
||||
defaultAccountId: id,
|
||||
});
|
||||
useAuthStore.setState({ isAuthenticated: true, activeAccountId: id, serverUrl: 'x', username: 'y' });
|
||||
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
expect(useAccountStore.getState().accounts).toEqual([]);
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.activeAccountId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mail the viewer held in memory', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
teardownPushNotificationsForAccount,
|
||||
} from '../lib/push-notifications';
|
||||
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
|
||||
import { perfMark } from '../lib/perf-marks';
|
||||
|
||||
// Persist middleware hydrates asynchronously on cold start. Without this
|
||||
// guard, restoreSession() can read the account-store before AsyncStorage has
|
||||
@@ -602,10 +603,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
jmapClient.reset();
|
||||
clearAccountFeatureStores(currentId);
|
||||
|
||||
// Switch to next remaining account, if any
|
||||
const remaining = accountStore.accounts;
|
||||
// Switch to next remaining account, if any. Read the store again:
|
||||
// `accountStore` is the state from before removeAccount.
|
||||
const after = useAccountStore.getState();
|
||||
const remaining = after.accounts.filter((a) => a.id !== currentId);
|
||||
if (remaining.length > 0) {
|
||||
const next = accountStore.getDefaultAccount() ?? remaining[0];
|
||||
const next = after.getDefaultAccount() ?? remaining[0];
|
||||
try {
|
||||
await get().switchAccount(next.id);
|
||||
return;
|
||||
@@ -761,6 +764,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
restoreSession: async () => {
|
||||
set({ isLoading: true });
|
||||
perfMark('restore-start');
|
||||
try {
|
||||
// Wait for persisted caches to finish hydrating from AsyncStorage.
|
||||
// Otherwise we read empty defaults and bounce the user back to the
|
||||
@@ -773,6 +777,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
waitForHydration(useCalendarStore),
|
||||
waitForHydration(useContactsStore),
|
||||
]);
|
||||
perfMark('restore-hydrated');
|
||||
const accountStore = useAccountStore.getState();
|
||||
|
||||
// Legacy migration: if there are no registered accounts but the old
|
||||
@@ -808,6 +813,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
perfMark('restore-session');
|
||||
if (!ok) {
|
||||
// No stored credentials (or corrupt) — genuine logout.
|
||||
accountStore.removeAccount(target.id);
|
||||
@@ -861,6 +867,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
const session = jmapClient.currentSession!;
|
||||
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
||||
perfMark('restore-done');
|
||||
// Refresh the cached mailbox list + current folder now that the session
|
||||
// is live. Feature stores show persisted data immediately; this swaps
|
||||
// in fresh data once the network round-trip completes.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { AppState } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { perfMark } from '../lib/perf-marks';
|
||||
import type { PersistStorage, StorageValue } from 'zustand/middleware';
|
||||
|
||||
/** How long a store's write waits to take in further changes. */
|
||||
@@ -93,9 +94,12 @@ export function createPersistStorage<S>(
|
||||
return {
|
||||
getItem: async (name) => {
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const raw = await AsyncStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
const t1 = Date.now();
|
||||
const value = JSON.parse(raw) as StorageValue<S>;
|
||||
perfMark('hydrate', { store: name, readMs: t1 - t0, parseMs: Date.now() - t1, kb: Math.round(raw.length / 1024) });
|
||||
slotFor(name).stored = raw;
|
||||
return value;
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user