Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ec09942d | ||
|
|
c165fef588 | ||
|
|
4052523ba9 | ||
|
|
e30749a50e | ||
|
|
53c0bc0fc2 | ||
|
|
a8e74c8b6d | ||
|
|
949bb10ac5 | ||
|
|
e5659ed5e8 | ||
|
|
a764a07c65 | ||
|
|
5ddc2df64b | ||
|
|
fef23e4c33 | ||
|
|
4a3bce154c | ||
|
|
13a3e95a0a | ||
|
|
fd55e16492 | ||
|
|
5e557266d1 | ||
|
|
b178ee8d07 | ||
|
|
1cbf763a36 | ||
|
|
84fef142b9 | ||
|
|
b3a82f1241 | ||
|
|
13cba64854 | ||
|
|
e3501f939d | ||
|
|
5e3cbf2419 | ||
|
|
9c031c8761 | ||
|
|
c81dc5179f | ||
|
|
d72aab2519 | ||
|
|
d4ac1e1df4 | ||
|
|
06ce86e31a | ||
|
|
6fe907b4bc | ||
|
|
c1e5cd6c8b | ||
|
|
455608f9f0 | ||
|
|
5801d6c97a | ||
|
|
771558d8c7 | ||
|
|
9c0c35c7ba | ||
|
|
ccdc4dee56 | ||
|
|
aab29da148 | ||
|
|
d59c571d93 | ||
|
|
cb5c152cb3 | ||
|
|
6e6fd95e5e | ||
|
|
8af163a6a3 | ||
|
|
b7f711050e | ||
|
|
9282ee6d72 | ||
|
|
a8e6f6e8c4 | ||
|
|
b7a5953255 | ||
|
|
0cee422373 | ||
|
|
644443193c | ||
|
|
d2066dad07 | ||
|
|
dd0969d48b | ||
|
|
96bffa51e8 | ||
|
|
5434656e03 | ||
|
|
de3d697aa9 | ||
|
|
caf61ece79 | ||
|
|
71522b9b92 | ||
|
|
67935d2edc | ||
|
|
d7c890869b | ||
|
|
4f3940f1d3 | ||
|
|
9c2f819eac | ||
|
|
9fa931c589 | ||
|
|
1196ad56c2 | ||
|
|
3bf2c30c99 | ||
|
|
018accd865 | ||
|
|
1c7c2a3be8 | ||
|
|
49ed48791d | ||
|
|
ef143a4dbb | ||
|
|
2a055ddfe2 | ||
|
|
cd911ada79 | ||
|
|
e0b72883ac | ||
|
|
54b169c6c7 | ||
|
|
6ebadf738f | ||
|
|
01fc6fbba5 | ||
|
|
7cdebc465a | ||
|
|
ad1f053f72 | ||
|
|
1348e7d20a | ||
|
|
ac8e3fb2e9 | ||
|
|
33e303e248 | ||
|
|
96c5a3f046 | ||
|
|
f397ee3e1b | ||
|
|
84548898c1 | ||
|
|
1a651a9c25 | ||
|
|
83db70ac86 | ||
|
|
a4ac6cad5c | ||
|
|
a7e4e84f5d | ||
|
|
e0a79ca5c6 | ||
|
|
edab181e60 | ||
|
|
d097ae9ec3 | ||
|
|
8d5bced7f3 | ||
|
|
ee50418bac | ||
|
|
ddff2f7b66 | ||
|
|
d8db4a5597 | ||
|
|
1fb60a5c61 | ||
|
|
115e162b86 | ||
|
|
a3193ebaf7 | ||
|
|
40ace883b7 | ||
|
|
9135deed80 | ||
|
|
3c7b96e11a | ||
|
|
2e78c9870c | ||
|
|
46e285b3c9 | ||
|
|
4c9efbc44d | ||
|
|
7964e646c4 | ||
|
|
008bff29f8 | ||
|
|
af6290fd4c | ||
|
|
4e6426171a | ||
|
|
b1fb2e9b22 | ||
|
|
25c088383f | ||
|
|
9f75e70218 | ||
|
|
30aea63a66 | ||
|
|
85f23dfdf4 | ||
|
|
4a5d837cd7 | ||
|
|
f2dd3a8b4b | ||
|
|
b9db3764ff | ||
|
|
82ca359df7 | ||
|
|
5bcfe1fe4d | ||
|
|
063432bf28 | ||
|
|
604eb39340 | ||
|
|
6c31c8d0c6 | ||
|
|
3f88868aad | ||
|
|
d53bfec68a | ||
|
|
4fdf39aa08 | ||
|
|
409cdb139e | ||
|
|
1d755fc066 | ||
|
|
a6ef0abc2a | ||
|
|
c0582ab950 | ||
|
|
15717f137a | ||
|
|
4b903ed179 | ||
|
|
6a975b822c | ||
|
|
1b64c14280 | ||
|
|
dcdda5ed9f | ||
|
|
548b647f15 | ||
|
|
fc00781b47 | ||
|
|
3faac98627 | ||
|
|
472c4d8f26 | ||
|
|
0ffdcacea9 | ||
|
|
2d907ffab8 | ||
|
|
a0c2bd7f4a | ||
|
|
235f4ea77f | ||
|
|
92b42e4a4f | ||
|
|
191f4f32cd | ||
|
|
7e598c5bc9 | ||
|
|
ebd8dacdfd | ||
|
|
aa250759e5 | ||
|
|
cb9a741244 | ||
|
|
695aa23563 | ||
|
|
8618abf1d7 | ||
|
|
a3d3af797c | ||
|
|
ddfe85a5b2 | ||
|
|
ccaa24ecd3 | ||
|
|
d8046a8aad | ||
|
|
9e95273c14 | ||
|
|
33d26744b6 | ||
|
|
5e42c6737e | ||
|
|
9c31de2e80 | ||
|
|
5c7301b3f1 | ||
|
|
d987930034 | ||
|
|
35e0792558 | ||
|
|
0a8686a008 | ||
|
|
668e01023c | ||
|
|
c888e95c5a | ||
|
|
e6be8922b7 | ||
|
|
182774884e | ||
|
|
a734d0ade1 | ||
|
|
95c24a4214 | ||
|
|
eca3871545 | ||
|
|
d89e33110a | ||
|
|
ac58b36a77 | ||
|
|
e6549d31da | ||
|
|
2511df1059 | ||
|
|
a86cd1d0eb | ||
|
|
f63d739fb3 | ||
|
|
72f48db4e5 | ||
|
|
4e7ae2fa30 | ||
|
|
48825e919a | ||
|
|
24849baca5 | ||
|
|
e37621be5e | ||
|
|
29169c3426 | ||
|
|
5237848312 | ||
|
|
746572c784 | ||
|
|
6f0abb4384 | ||
|
|
1d1d6a5bfc | ||
|
|
d45c7996d2 | ||
|
|
476780553c | ||
|
|
910c503f4a | ||
|
|
7531118727 | ||
|
|
55f1ec431f | ||
|
|
cb2f03bc09 | ||
|
|
50418971f4 | ||
|
|
859770b814 | ||
|
|
c51a848dfb | ||
|
|
e67947aa93 | ||
|
|
802b19916c | ||
|
|
e28e8b4d9c | ||
|
|
ee652d39df | ||
|
|
06e89422bd | ||
|
|
cd4ce9d08b | ||
|
|
8d7fbfc99c | ||
|
|
caea3d0c60 | ||
|
|
00ad2f49fb | ||
|
|
4eb8c47670 | ||
|
|
bd42206686 | ||
|
|
ab845783ab | ||
|
|
5f3f584ca8 | ||
|
|
cb6ddd6fcf | ||
|
|
ee04e95d4f | ||
|
|
5b8c4353d8 | ||
|
|
c2584c2bd5 | ||
|
|
6c82130267 | ||
|
|
f228257ec9 | ||
|
|
a02e7f8a49 | ||
|
|
ea1f53f550 | ||
|
|
4e035f3011 | ||
|
|
e04bea451a | ||
|
|
5cb28f5d5d | ||
|
|
94c9a2e7a2 | ||
|
|
3f2dfe83ee | ||
|
|
99a20777b7 | ||
|
|
96272b6e17 | ||
|
|
8e33808ded | ||
|
|
2abea9742e | ||
|
|
c705227611 | ||
|
|
5fe8e407c7 | ||
|
|
0159d6acc8 | ||
|
|
8fd709d308 | ||
|
|
0205aa0e1c | ||
|
|
09c24fa805 | ||
|
|
86c3ab9f28 | ||
|
|
9398b46795 | ||
|
|
f9ae4423e9 | ||
|
|
8e1d03c746 | ||
|
|
a222e89611 | ||
|
|
26786ca3e4 | ||
|
|
6ae21d5690 | ||
|
|
d6670c2b60 | ||
|
|
d2ed27fd3b | ||
|
|
ee3b3ffcc3 | ||
|
|
0f80e98fbc | ||
|
|
2b44936a17 | ||
|
|
bf92a00199 | ||
|
|
00fd0696b2 | ||
|
|
c659b5489c | ||
|
|
3a8264c7f7 | ||
|
|
dc3ac4c7ed | ||
|
|
ae31a71e01 | ||
|
|
0e63d45599 | ||
|
|
22697fd111 | ||
|
|
fe45a444d3 | ||
|
|
bea3fc4c42 | ||
|
|
5b72c4d314 | ||
|
|
3194b5ece1 | ||
|
|
f07e039ef0 | ||
|
|
8206893860 | ||
|
|
65c73d82a1 | ||
|
|
1d1ff35d1b | ||
|
|
18e56163de | ||
|
|
3ae9bbe702 | ||
|
|
5802caeea9 | ||
|
|
06742ef7d5 | ||
|
|
dae5700688 | ||
|
|
42ab246cb9 | ||
|
|
2bf2e89813 | ||
|
|
ddae1e63f2 | ||
|
|
490355c65e | ||
|
|
1f2df5f079 | ||
|
|
2586eccaca | ||
|
|
7f9f5af7e6 | ||
|
|
78532211f3 | ||
|
|
240471764a | ||
|
|
63bf3921b0 | ||
|
|
56f8dceb8f | ||
|
|
0d94d7643c | ||
|
|
99eb525d22 | ||
|
|
58e3ef6827 | ||
|
|
4bebf70482 | ||
|
|
0e514659be | ||
|
|
78114b620a | ||
|
|
bc4c57e734 | ||
|
|
358bdb93ea | ||
|
|
8bf48f6465 | ||
|
|
431facef0b | ||
|
|
3d0d440673 | ||
|
|
8deff66274 | ||
|
|
a071d0581c | ||
|
|
6f747db858 | ||
|
|
69bedf0b26 | ||
|
|
ce97232eb2 | ||
|
|
516c9c931a | ||
|
|
c8be383c05 | ||
|
|
9ae91a6ab6 | ||
|
|
c0e1d6b0a8 | ||
|
|
a0ce9258cd | ||
|
|
1cef53af5a | ||
|
|
9800ad07c2 | ||
|
|
3fd5f10afe | ||
|
|
edc26ce1ca | ||
|
|
032732cea1 | ||
|
|
b52768e6fb | ||
|
|
e70e57bf47 | ||
|
|
f6f26f7950 | ||
|
|
00faceb25f | ||
|
|
7f956d60f7 | ||
|
|
5dc40706fa | ||
|
|
26a34d5900 | ||
|
|
580e010b33 | ||
|
|
ccbe67c1bc | ||
|
|
506a4b1a84 | ||
|
|
b538a0e53d | ||
|
|
58a6f45c15 | ||
|
|
feb4affea6 | ||
|
|
3ffcef2ec1 | ||
|
|
990cd84ee3 | ||
|
|
0b1c240802 | ||
|
|
2c0dbd15d1 | ||
|
|
161baf1708 | ||
|
|
1c6aa680c5 | ||
|
|
e650cb2d0a | ||
|
|
eb1dd6c844 | ||
|
|
fb4796e188 | ||
|
|
b84d4d8e9e | ||
|
|
eaa8249e76 | ||
|
|
a63ba7cb81 | ||
|
|
beec3322b8 | ||
|
|
21bc02709a | ||
|
|
32b5298cdd | ||
|
|
da1ff62725 | ||
|
|
077b590851 | ||
|
|
71af9d105d | ||
|
|
82b97fada4 | ||
|
|
428a1bf03d | ||
|
|
09350dd1f0 | ||
|
|
d2d13a37c9 | ||
|
|
be82bfe814 | ||
|
|
5c775ad82e | ||
|
|
941e5ede12 | ||
|
|
2577f4e64b | ||
|
|
ee21c15fec | ||
|
|
93761e67ef | ||
|
|
b7995fafde | ||
|
|
cde461999f |
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
@@ -10,6 +10,13 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Signing is described in docs/android-release.md.
|
||||
env:
|
||||
# Certificate SHA-256 of android/app/debug.keystore, the stock React Native
|
||||
# template key (CN=Android Debug, SHA-1 5E:8F:16:06:...:F6:25). It is public,
|
||||
# so Android 9+ must never see it as the signer of a published build.
|
||||
DEBUG_CERT_SHA256: fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,6 +24,26 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# Fail in seconds instead of after a 20-minute build. There is no
|
||||
# unsigned or debug-signed fallback: anything signed with the debug key
|
||||
# can be "updated" by anyone.
|
||||
- name: Require the release signing secrets
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
missing=""
|
||||
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
|
||||
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
|
||||
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
|
||||
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. Release APKs are never signed with the public debug key; see docs/android-release.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -30,7 +57,12 @@ jobs:
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
# The action installs the legacy `tools` package by default, which the
|
||||
# runner's cmdline-tools 16 no longer carries ("Failed to find package
|
||||
# 'tools'"). Gradle fetches the platforms and build-tools it needs.
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
|
||||
- name: Cache Android NDK / CMake
|
||||
uses: actions/cache@v4
|
||||
@@ -66,23 +98,38 @@ jobs:
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Decode release keystore
|
||||
id: keystore
|
||||
# Checks the password and alias before the long build, and refuses a
|
||||
# debug key that was uploaded as the release key by mistake.
|
||||
- name: Decode and check the release key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
if [ -n "$KEYSTORE_BASE64" ]; then
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT"
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||
CERT="$RUNNER_TEMP/release-cert.der"
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||
if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||
-storepass:env KEYSTORE_PASSWORD -file "$CERT"; then
|
||||
echo "::error title=Release key unreadable::Could not read alias ANDROID_KEY_ALIAS from ANDROID_KEYSTORE_BASE64 with ANDROID_KEYSTORE_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
|
||||
OWNER=$(keytool -printcert -file "$CERT")
|
||||
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ] || [[ "$OWNER" == *"CN=Android Debug"* ]]; then
|
||||
echo "::error title=Release key is a debug key::ANDROID_KEYSTORE_BASE64 holds an Android debug key. Generate a release key as described in docs/android-release.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release certificate SHA-256: $CERT_SHA256"
|
||||
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
|
||||
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build release APK
|
||||
working-directory: android
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.keystore.outputs.path }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
@@ -90,15 +137,138 @@ jobs:
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Rename APK with version
|
||||
- name: Put apksigner on the PATH
|
||||
run: |
|
||||
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1)
|
||||
echo "Using $BUILD_TOOLS"
|
||||
echo "$BUILD_TOOLS" >> "$GITHUB_PATH"
|
||||
|
||||
# Optional v3 key rotation away from the debug key that signed every
|
||||
# release up to 0.1.62. Gradle signed the APK with the release key only,
|
||||
# which existing installs reject as a different app. With a lineage the
|
||||
# APK is re-signed so that Android 9+ sees the release key plus proof
|
||||
# that the debug key handed over to it, while Android 7-8.1 (v2 only)
|
||||
# still see the debug key. docs/android-release.md has the details.
|
||||
- name: Apply the signing key rotation
|
||||
id: rotation
|
||||
env:
|
||||
LINEAGE_BASE64: ${{ secrets.ANDROID_SIGNING_LINEAGE_BASE64 }}
|
||||
KEYSTORE: ${{ steps.key.outputs.store_file }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$LINEAGE_BASE64" ]; then
|
||||
echo "No ANDROID_SIGNING_LINEAGE_BASE64: keeping the release-key-only signature."
|
||||
echo "rotated=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
LINEAGE="$RUNNER_TEMP/signing.lineage"
|
||||
echo "$LINEAGE_BASE64" | base64 -d > "$LINEAGE"
|
||||
apksigner lineage --in "$LINEAGE" --print-certs | tee "$RUNNER_TEMP/lineage.txt"
|
||||
|
||||
DIGESTS=$(sed -n 's/^Signer #[0-9]* in lineage certificate SHA-256 digest: //p' "$RUNNER_TEMP/lineage.txt")
|
||||
if [ "$(sed -n '1p' <<< "$DIGESTS")" != "$DEBUG_CERT_SHA256" ] \
|
||||
|| [ "$(sed -n '$p' <<< "$DIGESTS")" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong signing lineage::ANDROID_SIGNING_LINEAGE_BASE64 must lead from the debug key to the key in ANDROID_KEYSTORE_BASE64."
|
||||
exit 1
|
||||
fi
|
||||
# Rollback on an old signer lets anything signed with it take the app
|
||||
# back after the rotation, which would undo the whole point.
|
||||
PAST_ROLLBACK=$(grep 'Has rollback capability' "$RUNNER_TEMP/lineage.txt" | sed -e 's/.*: *//' -e '$d')
|
||||
if [[ "$PAST_ROLLBACK" == *true* ]]; then
|
||||
echo "::error title=Unsafe signing lineage::An old signer in the lineage has the rollback capability. Recreate it with --set-rollback false."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
||||
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
||||
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
apksigner sign \
|
||||
--v4-signing-enabled false \
|
||||
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||
--ks-pass pass:android --key-pass pass:android \
|
||||
--next-signer \
|
||||
--ks "$KEYSTORE" --ks-key-alias "$KEY_ALIAS" \
|
||||
--ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEY_PASSWORD \
|
||||
--lineage "$LINEAGE" \
|
||||
--rotation-min-sdk-version 28 \
|
||||
--out "$RUNNER_TEMP/app-release-rotated.apk" \
|
||||
"$APK"
|
||||
mv "$RUNNER_TEMP/app-release-rotated.apk" "$APK"
|
||||
echo "rotated=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The last gate before anything is uploaded.
|
||||
- name: Verify the APK signature
|
||||
env:
|
||||
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
apksigner verify --verbose --print-certs "$APK"
|
||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||
signers() {
|
||||
apksigner verify --print-certs "$@" "$APK" \
|
||||
| sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' \
|
||||
| sort -u
|
||||
}
|
||||
MODERN=$(signers --min-sdk-version 28)
|
||||
LEGACY=$(signers --max-sdk-version 27)
|
||||
echo "Signer on Android 9+: $MODERN"
|
||||
echo "Signer on Android 7-8.1: $LEGACY"
|
||||
|
||||
if [[ "$MODERN" == *"$DEBUG_CERT_SHA256"* ]]; then
|
||||
echo "::error title=APK is debug-signed::Android 9+ would see the public debug key as the signer."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$MODERN" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong APK signer::Android 9+ would see '$MODERN', expected only the release key $RELEASE_CERT_SHA256."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROTATED" = "true" ]; then
|
||||
echo "::warning title=Android 7-8.1 still trust the debug key::The v2 signature is made with the debug key so that installs on Android 7-8.1 can update. Those devices remain exposed; see docs/android-release.md."
|
||||
elif [ "$LEGACY" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong APK signer::Android 7-8.1 would see '$LEGACY', expected only the release key $RELEASE_CERT_SHA256."
|
||||
exit 1
|
||||
fi
|
||||
aapt2 dump badging "$APK" | grep '^package:' || true
|
||||
|
||||
# The R8 mapping goes out with the APK: without the one from this exact
|
||||
# build, the obfuscated frames in a release stack trace cannot be
|
||||
# decoded (docs/android-release.md, "Stack traces").
|
||||
- name: Rename APK and R8 mapping with version
|
||||
id: artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING=""
|
||||
if [ -f "$MAPPING_SRC" ]; then
|
||||
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||
cp "$MAPPING_SRC" "$MAPPING"
|
||||
elif grep -Eq '^android\.enableMinifyInReleaseBuilds=true\s*$' android/gradle.properties; then
|
||||
echo "::error title=R8 mapping missing::R8 is on but the build wrote no $MAPPING_SRC, so stack traces from this APK could not be decoded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "files<<FILES_EOF"
|
||||
echo "$DEST"
|
||||
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
||||
echo "FILES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -106,6 +276,13 @@ jobs:
|
||||
path: ${{ steps.artifact.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.artifact.outputs.mapping != ''
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.mapping }}
|
||||
path: ${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Resolve target release tag
|
||||
id: target_tag
|
||||
env:
|
||||
@@ -125,9 +302,9 @@ jobs:
|
||||
echo "No release tag resolved - APK is in workflow artifacts only."
|
||||
fi
|
||||
|
||||
- name: Attach APK to release
|
||||
- name: Attach APK and R8 mapping to release
|
||||
if: steps.target_tag.outputs.tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||
files: ${{ steps.artifact.outputs.path }}
|
||||
files: ${{ steps.artifact.outputs.files }}
|
||||
|
||||
@@ -17,6 +17,9 @@ expo-env.d.ts
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.keystore
|
||||
!/android/app/debug.keystore
|
||||
*.lineage
|
||||
|
||||
# Metro / Expo dev-server logs
|
||||
.metro-health-check*
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
||||
import {
|
||||
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
||||
} from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
|
||||
import { startPushUpdates } from './src/api/push';
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
import type { StateChange } from './src/api/types';
|
||||
import { dispatchStateChange } from './src/lib/state-change-bus';
|
||||
import {
|
||||
startCalendarNotificationSync,
|
||||
startCalendarReminderTapHandling,
|
||||
} from './src/lib/calendar-notifications';
|
||||
import { startLivenessMonitor } from './src/lib/connection-liveness';
|
||||
import {
|
||||
setPendingCalendarOpen,
|
||||
type CalendarReminderTarget,
|
||||
} from './src/navigation/pending-calendar-open';
|
||||
import { sweepStaleExportFiles } from './src/lib/email-export';
|
||||
import { useFilterStore } from './src/stores/filter-store';
|
||||
import { useVacationStore } from './src/stores/vacation-store';
|
||||
import {
|
||||
addMessageListener,
|
||||
addNotificationTapListener,
|
||||
addTokenRefreshListener,
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
notificationTapJmapAccountId,
|
||||
resyncPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
import ComposeScreen from './src/screens/ComposeScreen';
|
||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
@@ -37,13 +56,25 @@ import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
||||
import { prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||
import { ToastHost } from './src/components/ToastHost';
|
||||
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
||||
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||
import { getEmails, getFullEmail } from './src/api/email';
|
||||
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||
import { replyComposeParams } from './src/lib/reply-compose';
|
||||
import { handleDeepLink, parseDeepLink, shareToDeepLink, type DeepLink } from './src/navigation/linking';
|
||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { useOutboxStore } from './src/stores/outbox-store';
|
||||
@@ -70,13 +101,106 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
||||
}
|
||||
|
||||
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
subject: payload.subject,
|
||||
// A group mailbox's message lives under another JMAP account (#839).
|
||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||
});
|
||||
}
|
||||
|
||||
// A tapped calendar reminder opens its event (or task) in the Calendar tab,
|
||||
// under the account it was scheduled for. The target is parked for
|
||||
// CalendarScreen, which also picks it up when it mounts after a cold start.
|
||||
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
|
||||
const auth = useAuthStore.getState();
|
||||
if (target.appAccountId && target.appAccountId !== auth.activeAccountId) {
|
||||
if (!useAccountStore.getState().getAccountById(target.appAccountId)) return;
|
||||
await auth.switchAccount(target.appAccountId);
|
||||
if (useAuthStore.getState().activeAccountId !== target.appAccountId) return;
|
||||
}
|
||||
setPendingCalendarOpen(target);
|
||||
// On a cold start the navigator mounts right after the auth gate flips.
|
||||
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('MainTabs', { screen: 'Calendar' } as never);
|
||||
}
|
||||
}
|
||||
|
||||
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
||||
// Android share-sheet payloads all end up here once the navigator is ready.
|
||||
async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
await handleDeepLink(link, {
|
||||
navigation: navigationRef,
|
||||
resolveThreadId: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const [email] = await getEmails([emailId], jmapAccountId);
|
||||
return email?.threadId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
openReply: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const params = replyComposeParams('reply', await getFullEmail(emailId, jmapAccountId), jmapAccountId);
|
||||
if (!params || !navigationRef.isReady()) return false;
|
||||
navigationRef.navigate('Compose', params);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
openDraft: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const draft = draftContextFromEmail(await getFullEmail(emailId, jmapAccountId), jmapAccountId);
|
||||
if (!navigationRef.isReady()) return false;
|
||||
navigationRef.navigate('Compose', { draft });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
switchAccount: async (accountId) => {
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.activeAccountId === accountId) return true;
|
||||
if (!useAccountStore.getState().getAccountById(accountId)) return false;
|
||||
await auth.switchAccount(accountId);
|
||||
return useAuthStore.getState().activeAccountId === accountId;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
||||
// that lands after leaving the screen it came from (a viewer action finishing
|
||||
// on the Unified Inbox or a contact) is still seen, and a reply held by the
|
||||
// undo-send delay can be undone from the viewer it was sent from. Every
|
||||
// screen carries the hosts, and only the focused one renders them, so
|
||||
// neither ever shows twice (the screen below the top one is kept live).
|
||||
// The composer gets no undo bar: its Undo would hand the open composer
|
||||
// another draft. A held send's bar shows on the screen below once it closes.
|
||||
function FocusedHosts({ undo }: { undo: boolean }) {
|
||||
if (!useIsFocused()) return null;
|
||||
return (
|
||||
<>
|
||||
{undo && <UndoSnackbar />}
|
||||
<ToastHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<FocusedHosts undo={route.name !== 'Compose'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen({ message }: { message: string }) {
|
||||
const c = useColors();
|
||||
return (
|
||||
@@ -89,21 +213,38 @@ function LoadingScreen({ message }: { message: string }) {
|
||||
|
||||
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
|
||||
const c = useColors();
|
||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||
// Select the count, not the list: every mailbox fetch sets a new array, and
|
||||
// re-rendering here re-renders the mail list with it.
|
||||
const inboxUnreadCount = useEmailStore(
|
||||
(state) => state.mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0,
|
||||
);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const disabledTabStyle = { opacity: 0.4 } as const;
|
||||
const t = useLocaleStore((state) => state.t);
|
||||
// The webmail's navigation names; a tab the server lacks says so.
|
||||
const tabLabels = {
|
||||
mail: t('sidebar.mail', 'Mail'),
|
||||
calendar: t('sidebar.calendar', 'Calendar'),
|
||||
contacts: t('sidebar.contacts', 'Contacts'),
|
||||
files: t('sidebar.files', 'Files'),
|
||||
settings: t('sidebar.settings', 'Settings'),
|
||||
};
|
||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<PushOnboardingPrompt />
|
||||
<AppIconBadge />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Tabs out of view stop re-rendering until they are shown again.
|
||||
freezeOnBlur: true,
|
||||
tabBarActiveTintColor: c.text,
|
||||
tabBarInactiveTintColor: c.textSecondary,
|
||||
tabBarStyle: {
|
||||
@@ -123,6 +264,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
<Tab.Screen
|
||||
name="Mail"
|
||||
options={{
|
||||
title: tabLabels.mail,
|
||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
@@ -143,10 +285,22 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
<EmailListScreen
|
||||
onComposePress={() => navigation.navigate('Compose')}
|
||||
onEmailPress={(email) => {
|
||||
// The list holds the open folder's mail: name its account so the
|
||||
// viewer never has to guess it from whatever folder is open.
|
||||
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
|
||||
const target = {
|
||||
jmapAccountId: mailboxAccountId(all, currentMailboxId),
|
||||
// A row of an "All folders" list or a tag view names its own
|
||||
// account instead, and pages over that account's rows.
|
||||
...viewerParamsForRow(email),
|
||||
};
|
||||
// Start on the body now: mounting the viewer takes a while.
|
||||
prefetchMessage(email, target.jmapAccountId);
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
...target,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -156,9 +310,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
title: tabLabels.calendar,
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
|
||||
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
@@ -170,9 +325,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
|
||||
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
@@ -184,9 +340,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
name="Files"
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
title: tabLabels.files,
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
|
||||
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
@@ -197,6 +354,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
options={{
|
||||
title: tabLabels.settings,
|
||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||
}}
|
||||
>
|
||||
@@ -212,6 +370,7 @@ export default function App() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
const t = useLocaleStore((state) => state.t);
|
||||
|
||||
// Resolve the user's theme preference to a concrete light/dark style for the
|
||||
// system status bar. The rest of the app's colors are still hard-coded dark
|
||||
@@ -221,6 +380,13 @@ export default function App() {
|
||||
const resolvedScheme: 'light' | 'dark' =
|
||||
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
|
||||
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
|
||||
// React Navigation's default theme is light: without this its containers
|
||||
// paint white behind and between screens, even in dark mode.
|
||||
const background = useColors().background;
|
||||
const navigationTheme = React.useMemo(() => {
|
||||
const base = resolvedScheme === 'light' ? DefaultTheme : DarkTheme;
|
||||
return { ...base, colors: { ...base.colors, background, card: background } };
|
||||
}, [resolvedScheme, background]);
|
||||
// Persisted active account is the signal that the user was already signed
|
||||
// in on the previous launch. When present we render the main UI with the
|
||||
// cached mail list instead of the "Restoring session" spinner; the real
|
||||
@@ -236,9 +402,20 @@ export default function App() {
|
||||
React.useEffect(() => {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
void useLocaleStore.getState().hydrate();
|
||||
// Calendar reminders are kept scheduled from launch, not only once the
|
||||
// Calendar tab has been opened.
|
||||
startCalendarNotificationSync();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
// Home-screen widgets follow the signed-in data; once the last account is
|
||||
// gone they are wiped so no mail stays visible on the launcher.
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) return startWidgetSync();
|
||||
if (hasRestoredSession) void signOutWidgets();
|
||||
return undefined;
|
||||
}, [isAuthenticated, hasRestoredSession]);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
@@ -273,6 +450,9 @@ export default function App() {
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
// Attachments shared out of the app linger in the cache dir; drop the
|
||||
// ones older than a day so a granted content URI can't read them forever.
|
||||
void sweepStaleExportFiles();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
@@ -294,12 +474,21 @@ export default function App() {
|
||||
});
|
||||
}, [haveLiveSession]);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
// Foreground FCM messages: the Kotlin service skips the headless task while
|
||||
// the app is visible, so feed the relay's StateChange straight into the
|
||||
// stores. SSE normally beats it, but this covers the window where the SSE
|
||||
// socket is down and the poll fallback has not fired yet.
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = addMessageListener((payload) => {
|
||||
console.log('[push] fcm message', payload.title);
|
||||
const raw = payload?.data?.changed;
|
||||
if (!raw) return;
|
||||
try {
|
||||
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
if (!changed || typeof changed !== 'object') return;
|
||||
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
|
||||
} catch {
|
||||
// malformed payload - ignore
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -328,11 +517,67 @@ export default function App() {
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Calendar reminders are local expo notifications; their taps come through
|
||||
// expo-notifications, not the native tap store used for mail push above.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return startCalendarReminderTapHandling((target) => {
|
||||
void openCalendarReminder(target);
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Deep links and share-sheet payloads. The cold-start URL / share is read
|
||||
// once auth is restored so the target screen has credentials.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
const open = (link: DeepLink | null) => {
|
||||
if (!link || cancelled) return;
|
||||
// Give the navigator a tick to mount after the auth gate flips.
|
||||
setTimeout(() => { void openDeepLink(link); }, 50);
|
||||
};
|
||||
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
|
||||
void getInitialShare().then((share) => {
|
||||
if (!share) return;
|
||||
const link = shareToDeepLink(share);
|
||||
if (link.kind === 'compose' && share.uris?.length) {
|
||||
navigationRef.isReady() && navigationRef.navigate('Compose', {
|
||||
prefillTo: link.to,
|
||||
prefillSubject: link.subject,
|
||||
prefillBody: link.body,
|
||||
prefillAttachments: shareAttachments(share),
|
||||
});
|
||||
return;
|
||||
}
|
||||
open(link);
|
||||
});
|
||||
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
|
||||
const shareSub = addShareListener((share) => {
|
||||
const link = shareToDeepLink(share);
|
||||
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
|
||||
navigationRef.navigate('Compose', {
|
||||
prefillTo: link.to,
|
||||
prefillSubject: link.subject,
|
||||
prefillBody: link.body,
|
||||
prefillAttachments: shareAttachments(share),
|
||||
});
|
||||
return;
|
||||
}
|
||||
open(link);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
urlSub.remove();
|
||||
shareSub();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||
// setups intact.
|
||||
// setups intact. An account the user turned push off for, or whose device
|
||||
// registration was revoked, stays off (see resyncPushNotifications).
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
@@ -353,7 +598,7 @@ export default function App() {
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl) return;
|
||||
try {
|
||||
await setupPushNotifications({
|
||||
await resyncPushNotifications({
|
||||
relayBaseUrl,
|
||||
accountLabel: client.username ?? undefined,
|
||||
});
|
||||
@@ -370,52 +615,129 @@ export default function App() {
|
||||
const unsubscribe = addTokenRefreshListener(() => {
|
||||
void doSetup();
|
||||
});
|
||||
// UnifiedPush equivalent of an FCM token rotation: the distributor handed
|
||||
// out a new endpoint, so re-register it with the relay.
|
||||
const unsubscribeUp = addUnifiedPushEndpointListener(() => {
|
||||
void doSetup();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
unsubscribeUp();
|
||||
};
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
// Live updates (SSE with polling fallback), re-armed on every account
|
||||
// switch and every re-established session — the singleton `client` object
|
||||
// never changes identity, so it cannot be the dependency on its own.
|
||||
// Backgrounding closes the stream (no socket + server pings while the app
|
||||
// is asleep); foregrounding reconnects it and refreshes what the user is
|
||||
// looking at, since events that happened in between are gone for good.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
if (!isAuthenticated || !client || !haveLiveSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let handle: LiveUpdatesHandle | null = null;
|
||||
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
|
||||
|
||||
void (async () => {
|
||||
const onStateChange = async (change: StateChange) => {
|
||||
// Filters / vacation edited elsewhere (webmail, another device): refetch
|
||||
// so the settings panes don't save stale state over the newer script.
|
||||
const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })();
|
||||
const own = primary ? change.changed?.[primary] : undefined;
|
||||
const extra: Promise<unknown>[] = [];
|
||||
if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined));
|
||||
if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined));
|
||||
dispatchStateChange(change);
|
||||
await Promise.all([
|
||||
useEmailStore.getState().handleStateChange(change),
|
||||
useContactsStore.getState().handleStateChange(change),
|
||||
useCalendarStore.getState().handleStateChange(change),
|
||||
...extra,
|
||||
]);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const stopPushUpdates = await startPushUpdates(client, {
|
||||
onStateChange: async (change) => {
|
||||
await Promise.all([
|
||||
useEmailStore.getState().handleStateChange(change),
|
||||
useContactsStore.getState().handleStateChange(change),
|
||||
useCalendarStore.getState().handleStateChange(change),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn(error.message);
|
||||
},
|
||||
const next = await startLiveUpdates({
|
||||
onStateChange: (change) => { void onStateChange(change); },
|
||||
onError: (error) => { console.warn('[push]', error.message); },
|
||||
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
|
||||
isActive: () => appActive,
|
||||
});
|
||||
|
||||
if (!mounted) {
|
||||
stopPushUpdates();
|
||||
next.close();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = stopPushUpdates;
|
||||
handle = next;
|
||||
} catch (error) {
|
||||
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const refreshAfterResume = () => {
|
||||
const email = useEmailStore.getState();
|
||||
void email.fetchMailboxes();
|
||||
if (email.currentMailboxId) void email.refreshEmails();
|
||||
void useOutboxStore.getState().flush();
|
||||
};
|
||||
|
||||
// Foreground liveness for the per-account connection dot: the open
|
||||
// stream's pings, or a `Core/echo` every 30 s while there is none (see
|
||||
// startLivenessMonitor). NetInfo cannot tell an unreachable server.
|
||||
const liveness = startLivenessMonitor({
|
||||
ping: () => jmapClient.ping(),
|
||||
streamHealthy: () => handle?.healthy ?? false,
|
||||
isActive: () => appActive,
|
||||
isOnline: () => useNetworkStore.getState().online,
|
||||
onConnected: (connected) => {
|
||||
if (!mounted || !activeAccountId) return;
|
||||
const account = useAccountStore.getState().getAccountById(activeAccountId);
|
||||
if (account && account.isConnected !== connected) {
|
||||
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
void start();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
const nowActive = state === 'active';
|
||||
if (nowActive === appActive) return;
|
||||
appActive = nowActive;
|
||||
if (!nowActive) {
|
||||
handle?.close();
|
||||
handle = null;
|
||||
return;
|
||||
}
|
||||
// Coming back: reconnect and catch up on what was missed at once;
|
||||
// one echo alongside checks the server for the connection dot.
|
||||
if (!handle) void start();
|
||||
else handle.reconnect();
|
||||
refreshAfterResume();
|
||||
void liveness.check();
|
||||
});
|
||||
|
||||
// Reconnect when the network comes back while foregrounded.
|
||||
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && appActive) {
|
||||
if (handle) handle.reconnect();
|
||||
else void start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
cleanup?.();
|
||||
subscription.remove();
|
||||
unsubscribeNetwork();
|
||||
liveness.stop();
|
||||
handle?.close();
|
||||
handle = null;
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, haveLiveSession, activeAccountId]);
|
||||
|
||||
// Skip the "Restoring session" flash for returning users: if we already
|
||||
// have a persisted active account, render the main UI immediately with
|
||||
@@ -425,7 +747,7 @@ export default function App() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoadingScreen message="Loading..." />
|
||||
<LoadingScreen message={t('common.loading', 'Loading...')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -440,9 +762,11 @@ export default function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
{/* On Fabric, native-stack keeps the screen right below the top live and
|
||||
freezes the ones further down. */}
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Webmail parity checklist
|
||||
|
||||
Audit of **Bulwark Mobile** (`bulwarkmail/native`, v0.1.58 @ `82b97fa`) against
|
||||
**Bulwark Webmail** (`bulwarkmail/webmail`, v1.9.2 @ `39dcc6b2`), taken on
|
||||
2026-08-29. The goal is feature *and* bugfix parity: every user-facing webmail
|
||||
behaviour, and every webmail changelog fix since the native app was started
|
||||
(1.4.5 → 1.9.2), was checked against the native code.
|
||||
|
||||
The audit was done by reading code on both sides, not by running the app. Every
|
||||
finding cites `file:line` on both sides so it can be re-verified quickly; a
|
||||
sample of the 32 P1 findings was additionally spot-checked by hand.
|
||||
|
||||
## How to use this document
|
||||
|
||||
- The detailed findings live in [docs/parity/](docs/parity/), one file per area.
|
||||
Tick the `- [ ]` boxes there as you fix things; this file is the index.
|
||||
- Each finding has a **priority** and a **category**:
|
||||
|
||||
| Priority | Meaning |
|
||||
|---|---|
|
||||
| `P1` | data loss, wrong send/delete, security, crash, or a core flow that is blocked |
|
||||
| `P2` | notable feature missing or visibly wrong |
|
||||
| `P3` | polish, nice-to-have, minor divergence |
|
||||
|
||||
| Category | Meaning |
|
||||
|---|---|
|
||||
| `missing` | webmail has it, native does not |
|
||||
| `partial` | native has a reduced version |
|
||||
| `bug` | native has it, but it is wrong |
|
||||
| `bugfix-parity` | a bug the webmail fixed (changelog / issue number cited) that is still live in native |
|
||||
| `rn-only-bug` | a bug that only exists in native (found while reading) |
|
||||
|
||||
- `WEB` paths are relative to the webmail repo, `RN` paths to this repo.
|
||||
Issue numbers `#NNN` refer to the **webmail** tracker unless prefixed
|
||||
"native #NN".
|
||||
- Each area file ends with **Verified at parity** (do not redo) and
|
||||
**N/A on mobile** (deliberately out of scope) lists.
|
||||
- A few findings appear in two areas because they sit on a boundary (e.g.
|
||||
"forward drops attachments" is in both viewer and composer). Fix once, tick
|
||||
both.
|
||||
|
||||
## Baseline health of this repo (2026-08-29)
|
||||
|
||||
- [x] **`npm test` is red on `main`** — `P2` — `rn-only-bug` *(fixed in b84d4d8)*
|
||||
- `src/stores/__tests__/auth-store.test.ts` fails to load with
|
||||
`SyntaxError: Unexpected token 'typeof'`. The chain
|
||||
`auth-store → email-store → outbox-store → network-store` imports
|
||||
`@react-native-community/netinfo`, which `src/test-setup.ts` never mocks
|
||||
(it mocks `react-native`, `expo-secure-store`, `expo-web-browser`,
|
||||
AsyncStorage only). Broken since `5d47f47` (2026-05-30) when the outbox
|
||||
import was added; `email-store.test.ts` only passes because it mocks
|
||||
`../outbox-store` itself.
|
||||
- Fix hint: add `vi.mock('@react-native-community/netinfo', () => ({ default: { addEventListener: vi.fn(() => () => {}), fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })) } }))`
|
||||
to `src/test-setup.ts`. Expected afterwards: 31/31 suites (432+ tests).
|
||||
- [x] **CI runs neither `tsc` nor `vitest`** — `P2` — `missing` *(fixed in fb4796e)*
|
||||
- `.github/workflows/` only has `release-android.yml` and `release-ios.yml`.
|
||||
Add a `ci.yml` running `npm ci && npm run typecheck && npm test` on push/PR.
|
||||
- `npx tsc --noEmit` is clean.
|
||||
- Uncommitted work in the tree at audit time: ascending/descending mail sort
|
||||
(native #5) in `src/stores/email-store.ts`, `src/stores/settings-store.ts`,
|
||||
`src/screens/EmailListScreen.tsx`, `src/stores/__tests__/email-store.test.ts`.
|
||||
Findings that touch list ordering assume that lands.
|
||||
- [x] **README is stale** — `P3` — it says "Filters & rules, S/MIME, plugins, *(fixed in 9ae91a6)*
|
||||
themes, file storage – UI stubs only". Filters, Files and file sharing are
|
||||
real (see areas 07); S/MIME, plugins, themes and sidebar apps are still
|
||||
stubs (area 08).
|
||||
|
||||
## Status after the 2026-08-29 fix pass
|
||||
|
||||
Worked down the same day, area by area, by one orchestrating session plus one
|
||||
agent per area, each change committed on its own (`type: short description`).
|
||||
Per-area counts of ticked items are in the area files; every tick carries the
|
||||
commit hash. The remaining `- [ ]` items are either deferred with a note
|
||||
("deferred: …") or need work outside this repo (webmail hand-off `flow=oauth`
|
||||
for TOTP accounts, relay APNs/UnifiedPush transports, a server-side settings
|
||||
store for native #1).
|
||||
|
||||
## Status after the 2026-09-24 fix pass
|
||||
|
||||
The native audit of 2026-09-22 ([docs/audit-2026-09.md](docs/audit-2026-09.md))
|
||||
spot-checked this list and found 14 ticks that were broken or overstated and 11
|
||||
open items that were already done. The 2026-09-24 fix pass fixed 13 of the 14
|
||||
(their ticks now also name the fixing commit; the date-locale item is open
|
||||
again), dropped three obsolete items and ticked what it completed: 380 of 415
|
||||
items are done, 35 open. What still needs a device check or a decision is in
|
||||
the audit's "Fix pass, 2026-09-24" section.
|
||||
|
||||
## Areas
|
||||
|
||||
| # | Area | File | Items | Done | Open | P1 | P2 | P3 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 01 | Authentication, login, session, multi-account | [docs/parity/01-auth-accounts.md](docs/parity/01-auth-accounts.md) | 30 | 29 | 1 | 4 | 8 | 18 |
|
||||
| 02 | Mail list, folders, unified views, search, tags | [docs/parity/02-mail-list-folders.md](docs/parity/02-mail-list-folders.md) | 54 | 50 | 4 | 1 | 20 | 33 |
|
||||
| 03 | Email viewer, thread view, rendering, attachments | [docs/parity/03-email-viewer.md](docs/parity/03-email-viewer.md) | 46 | 45 | 1 | 6 | 13 | 26 |
|
||||
| 04 | Composer, drafts, sending, identities, templates, scheduled send | [docs/parity/04-composer-send.md](docs/parity/04-composer-send.md) | 51 | 46 | 5 | 5 | 14 | 32 |
|
||||
| 05 | Calendar and tasks | [docs/parity/05-calendar.md](docs/parity/05-calendar.md) | 50 | 43 | 7 | 5 | 20 | 25 |
|
||||
| 06 | Contacts and address books | [docs/parity/06-contacts.md](docs/parity/06-contacts.md) | 50 | 46 | 4 | 1 | 18 | 31 |
|
||||
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 32 | 29 | 3 | 3 | 8 | 21 |
|
||||
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 38 | 8 | 0 | 14 | 32 |
|
||||
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 54 | 2 | 7 | 23 | 26 |
|
||||
| | **Total** | | **415** | **380** | **35** | **32** | **138** | **244** |
|
||||
|
||||
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24. Done and
|
||||
Open split them by tick; the P columns count the priority tags on those items
|
||||
(one item carries none).
|
||||
|
||||
## All P1 findings (fix these first)
|
||||
|
||||
### Data loss / wrong send
|
||||
- [x] No draft autosave, no save-on-close, and the OS back gesture bypasses the discard guard — closing a compose loses everything. → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
|
||||
- [x] Cannot open or edit an existing draft (Drafts folder opens the read-only viewer). → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
|
||||
- [x] Message is filed into **Sent before `EmailSubmission/set`**; a failed send leaves a never-sent copy in Sent and a retry duplicates it. → [04](docs/parity/04-composer-send.md) *(fixed in ccbe67c)*
|
||||
- [x] `In-Reply-To` / `References` are written with the **JMAP email id** instead of the RFC Message-ID (`messageId`/`references` are never even fetched) — every reply breaks threading in recipients' clients. → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
|
||||
- [x] Reply addressing: `Reply-To` ignored, the user's own address kept on reply-all, wrong recipients on self-sent replies (#703, 1.9.0). → [03](docs/parity/03-email-viewer.md) *(fixed in 7f956d6)*
|
||||
- [x] **Forward drops every attachment.** → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
|
||||
- [x] Sieve: RN generator has no `attachment` field and emits `header :contains "undefined" ""` for webmail-authored attachment rules — **any save on the phone corrupts the shared server script**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] Sieve: multi-value conditions (`string[]`, webmail 1.7.3) make the whole script "opaque" on RN; "Reset to visual builder" then wipes all rules. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] `FilterRuleModal` is always mounted and never re-seeds: editing rule B shows and **saves rule A's fields under B's id**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] Calendar recurrence scope dialog is a stub: "This event" / "This and following" act as "All events" — deleting one occurrence **destroys the series**. → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
|
||||
- [x] Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts, earlier occurrences vanish). → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
|
||||
- [x] All-day events grow by one day on every create-via-toggle and every re-save. → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
|
||||
- [x] Events are created without `timeZone` (floating time). → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
|
||||
- [x] Invitations are never sent: participants use the retired `sendTo`, no organizer participant, no `organizerCalendarAddress` (#500, #731). → [05](docs/parity/05-calendar.md) *(fixed in 5dc4070)*
|
||||
- [x] vCard import writes dates as raw strings instead of RFC 9553 PartialDate (#224) — Stalwart rejects the whole card, so any contact with a birthday is lost on import. → [06](docs/parity/06-contacts.md) *(fixed in e650cb2)*
|
||||
|
||||
### Core flow blocked / wrong content shown
|
||||
- [x] TOTP / 2FA password login is impossible (402 is shown as a generic failure). → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
|
||||
- [x] Webmail password handoff breaks for TOTP-protected accounts (cross-repo: webmail should hand off `flow=oauth` after a TOTP-upgraded login). → [01](docs/parity/01-auth-accounts.md) *(fixed in 0d94d76)*
|
||||
- [x] A failed "add account" resets the singleton client before connecting, killing the live session until relaunch. → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
|
||||
- [x] Account Security screen is dead against every Stalwart server: it checks `session.capabilities` but Stalwart advertises `urn:stalwart:jmap` only in `accountCapabilities` (verified on 0.16.19) — **native #47**. → [01](docs/parity/01-auth-accounts.md) *(fixed in 032732c)*
|
||||
- [x] Thread view: only the newest message of a thread is reachable; older messages cannot be opened unless threading is disabled. → [03](docs/parity/03-email-viewer.md) *(fixed in d2ed27f)*
|
||||
- [x] Pager shows another message's body (or nothing) when the opened id is not in the active folder page — unified inbox, group inboxes, contact activity. → [02](docs/parity/02-mail-list-folders.md), [03](docs/parity/03-email-viewer.md) *(fixed in c8be383)*
|
||||
- [x] HTML-only messages are rendered — and quoted in replies — as raw HTML source (**native #46**). `<style>` stripping and an old dark-mode CSS revision are the likely cause of **native #49**. → [03](docs/parity/03-email-viewer.md) *(fixed in 06742ef)*
|
||||
|
||||
### JMAP client, live sync, security
|
||||
- [x] No request deadline on any fetch — a stalled socket (iOS background suspension, dead pooled connection) hangs send/save forever (#702). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 0b1c240)*
|
||||
- [x] Most `src/api/*.ts` functions index `methodResponses[0][1]` blindly and never check `error` / `notUpdated` / `notDestroyed` — a rejected mutation is reported as success and the outbox drops it. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in ccbe67c)*
|
||||
- [x] Password change leaves the stored credential stale; the next launch evicts the account. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 032732c)*
|
||||
- [x] No `AppState` handling: SSE is never paused in background nor recovered on foreground, and `react-native-sse` never reconnects after a status-0 network error — push is silently dead after the first blip. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] SSE `Authorization` header is captured once; after an OAuth refresh every reconnect sends the stale token (401 → re-poll every 5 s forever). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] Push effect is keyed on the singleton client, so the SSE stream stays bound to the previous account after `switchAccount`. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] Webmail password handoff sends the clear-text password in a custom-scheme redirect fragment that any app can register; OAuth `state` uses `Math.random`; `server_url`/`token_endpoint` in the callback are trusted as-is. → [09](docs/parity/09-jmap-core-sync-security.md), [01](docs/parity/01-auth-accounts.md) *(fixed in 2c0dbd1)*
|
||||
|
||||
### Repo health
|
||||
- [x] `npm test` is red on `main` (see Baseline health above). *(fixed in b84d4d8)*
|
||||
|
||||
## Native issues mapped to findings
|
||||
|
||||
| Native issue | Where it is explained |
|
||||
|---|---|
|
||||
| #49 Unreadable text in dark-mode emails | [03](docs/parity/03-email-viewer.md) `<style>` stripping + stale dark-mode CSS |
|
||||
| #48 / #44 UnifiedPush / non-FCM push | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
||||
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
||||
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #34 Native contacts/calendar sync adapters | feature request, out of parity scope; noted in [06](docs/parity/06-contacts.md) |
|
||||
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
||||
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
||||
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
These come up in nearly every area; fixing them centrally pays off more than
|
||||
per-screen patches.
|
||||
|
||||
1. **Email headers are never fetched.** `EMAIL_FULL_PROPERTIES` lacks
|
||||
`messageId`, `inReplyTo`, `references`, `headers`, `replyTo` usage. This
|
||||
single gap causes the broken threading headers, missing Reply-To handling,
|
||||
no SPF/DKIM/DMARC chips, no List-Unsubscribe, no read receipts, no details
|
||||
panel. (03, 04)
|
||||
2. **No thread model.** The list collapses threads but the viewer is a
|
||||
single-message pager over the folder page. (02, 03)
|
||||
3. **Hard-coded English.** RN has an i18n layer and 15 vendored locales, but
|
||||
84 of 94 screen/component files never call `t()`; calendar, files,
|
||||
vacation, scheduled, login and every settings pane except Language and
|
||||
Filters are English-only. Catalogs are ~6 weeks behind webmail (640 missing
|
||||
keys), and there is no interpolation, plurals or RTL. (all areas, 08)
|
||||
4. **Single-account assumptions.** Contacts, calendars (subscriptions), filters,
|
||||
vacation and Files are primary-account only; shared/group accounts work
|
||||
for mail folders but not elsewhere. (01, 05, 06, 07)
|
||||
5. **Webmail bugfixes that are one-line ports:** `$junk`/`$notjunk` flip on
|
||||
spam (#850), search defaults to all folders (#788), folder substring dedup
|
||||
(#771), permanent-delete confirmation, `$pinned` vs `$important`, FileNode
|
||||
`modified` vs `updated` (#700), percent-decoding WebDAV names (#869),
|
||||
RSCALE/SKIP=OMIT removal (#805), first-touch gate for lazy default
|
||||
calendar/address book creation (#907), `supported-calendar-component-set`
|
||||
pinning (#760), request timeouts (#702), relative session URL resolution.
|
||||
6. **Settings that do nothing.** 16–17 persisted keys are never read outside
|
||||
the settings UI (layout, sound, calendar notifications, sidebar apps,
|
||||
S/MIME, plugins, theme id, files thumbnails, …). Either wire them or remove
|
||||
the toggles. (08, table in that file)
|
||||
7. **Deep links / intents.** `bulwarkmobile://` is registered but nothing
|
||||
handles it; no `mailto:` or share-to-app intent filters. (04, 08)
|
||||
8. **Push.** Subscribes to `Email`+`Mailbox` instead of `EmailDelivery`, no
|
||||
`emailPush` spam filter, ignores relay-forwarded ids and guesses "newest
|
||||
unread", never matches the payload to an account, no device list/revoke,
|
||||
Android/FCM only. (08)
|
||||
9. **JMAP client robustness.** `src/api/jmap-client.ts` is a thin transport
|
||||
with none of the layers webmail added since 1.4.5: request deadlines,
|
||||
`maxConcurrentRequests` back-off, transient retry, rate-limit pause,
|
||||
`maxObjectsInSet` / `maxSizeUpload` chunking, keep-alive monitoring,
|
||||
first-touch serialisation, and — most importantly — method-error checking.
|
||||
A shared `requireMethodResult` / `assertSetResult` helper used by every
|
||||
`src/api/*.ts` function fixes a whole class of silent failures. (09)
|
||||
10. **Live sync lifecycle.** No `AppState` or connectivity hooks around the
|
||||
EventSource; reconnect, token refresh, account switch and client-cert
|
||||
users all leave the stream dead or stale. (09)
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. Repo health: fix `test-setup.ts`, add CI. Half a day.
|
||||
2. The P1 list above, roughly in the order given (composer/send correctness,
|
||||
Sieve corruption, calendar write path, vCard dates, auth blockers, viewer,
|
||||
JMAP client + SSE lifecycle).
|
||||
3. Cross-cutting themes 1, 2 and 9 (fetch headers, thread view, method-error
|
||||
checking + request deadline) — they unlock a large slice of the P2 items in
|
||||
03/04/09 and make the outbox trustworthy.
|
||||
4. P2 items per area, starting with 02 (spam keywords, search scope, unified
|
||||
inbox actions/paging, tags in rows) and 08 (push correctness, i18n
|
||||
catalogs + `t()` sweep).
|
||||
5. P3 items opportunistically while touching the same files.
|
||||
|
||||
## Explicitly out of scope on mobile
|
||||
|
||||
Collected from the per-area N/A lists so nobody re-litigates them: keyboard
|
||||
shortcuts, drag & drop, three-pane / Pro split-screen shell, browser
|
||||
back/forward and permalinks in the address bar, PWA install / service worker,
|
||||
print, guided tour, CSP nonces / server-side SSRF guards, admin dashboard and
|
||||
setup wizard, plugin sandbox and marketplace (a minimal "sidebar apps in a
|
||||
WebView" subset is described in 08), impersonation, TOTP re-auth dialog for
|
||||
cookie sessions, server-side telemetry, OpenGraph cards. Where a webmail
|
||||
feature is server-mediated (settings sync, iCal fetch proxy, TOTP token
|
||||
exchange, push relay list) the area file says what the native equivalent
|
||||
would be.
|
||||
|
||||
## Keeping this current
|
||||
|
||||
Re-run the audit after each webmail release: read the new changelog "Fixes"
|
||||
bullets, decide `RN affected / not affected / N/A`, and append to the matching
|
||||
area file. When a finding is fixed, tick it and add the native commit hash in
|
||||
the same line so the next audit can skip it.
|
||||
@@ -21,13 +21,13 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- Email list, threads, compose
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via FCM relay
|
||||
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS builds, but push notifications and client certificates are Android-only so far
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
|
||||
@@ -40,7 +40,7 @@ npx expo start
|
||||
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
For release APK builds and signing see [docs/android-release.md](docs/android-release.md).
|
||||
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
|
||||
|
||||
## License
|
||||
|
||||
@@ -7,6 +7,62 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
def appVersionName = file("${projectRoot}/VERSION").text.trim()
|
||||
|
||||
/**
|
||||
* versionCode has to grow with every release, or Android refuses the update as
|
||||
* a downgrade. It is derived from VERSION as
|
||||
*
|
||||
* major * 10000000 + minor * 10000 + patch (0.1.62 -> 10062)
|
||||
*
|
||||
* which keeps increasing as long as patch stays below 10000 and minor below
|
||||
* 1000 (scripts/bump-version.mjs, which only ever bumps patch, enforces both),
|
||||
* and stays under Android's 2100000000 ceiling up to major 209.
|
||||
*/
|
||||
def versionParts = (appVersionName =~ /^(\d+)\.(\d+)\.(\d+)$/)
|
||||
if (!versionParts.matches()) {
|
||||
throw new GradleException("VERSION must be MAJOR.MINOR.PATCH, got '${appVersionName}'")
|
||||
}
|
||||
def (versionMajor, versionMinor, versionPatch) = (1..3).collect { versionParts.group(it) as long }
|
||||
if (versionMinor > 999 || versionPatch > 9999) {
|
||||
throw new GradleException("VERSION ${appVersionName}: minor must stay below 1000 and patch below 10000, " +
|
||||
"or versionCode stops increasing. Move to the next minor or major version instead.")
|
||||
}
|
||||
def appVersionCode = versionMajor * 10000000L + versionMinor * 10000L + versionPatch
|
||||
if (appVersionCode < 1 || appVersionCode > 2100000000L) {
|
||||
throw new GradleException("VERSION ${appVersionName} gives versionCode ${appVersionCode}, " +
|
||||
"outside the 1..2100000000 range Android accepts.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Release signing. The key never lives in the repo: CI passes it as
|
||||
* ORG_GRADLE_PROJECT_BULWARK_RELEASE_* environment variables, and a local
|
||||
* release build can set the same names with -P, in ~/.gradle/gradle.properties
|
||||
* or as plain environment variables. See docs/android-release.md.
|
||||
*
|
||||
* There is deliberately no automatic fallback to the debug key. It is the
|
||||
* public React Native template key, so an APK signed with it can be replaced
|
||||
* by an "update" anyone can sign.
|
||||
*/
|
||||
def releaseSigningNames = [
|
||||
'BULWARK_RELEASE_STORE_FILE',
|
||||
'BULWARK_RELEASE_STORE_PASSWORD',
|
||||
'BULWARK_RELEASE_KEY_ALIAS',
|
||||
'BULWARK_RELEASE_KEY_PASSWORD',
|
||||
]
|
||||
def releaseSigning = releaseSigningNames.collectEntries { name ->
|
||||
[(name): (findProperty(name) ?: System.getenv(name))?.toString()]
|
||||
}
|
||||
def missingReleaseSigning = releaseSigningNames.findAll { !releaseSigning[it]?.trim() }
|
||||
def releaseSigningConfigured = missingReleaseSigning.isEmpty()
|
||||
def releaseStoreFile = releaseSigningConfigured ? file(releaseSigning.BULWARK_RELEASE_STORE_FILE.trim()) : null
|
||||
|
||||
// Escape hatch for a local release build that never leaves the machine
|
||||
// (profiling, checking R8). It is refused whenever CI is set, so a stray flag
|
||||
// cannot turn a published build back into a debug-signed one.
|
||||
def ciEnv = System.getenv('CI')?.trim()?.toLowerCase()
|
||||
def onCi = ciEnv && !(ciEnv in ['false', '0'])
|
||||
def allowDebugSignedRelease = (findProperty('allowDebugSignedRelease') ?: 'false').toString().trim().toBoolean()
|
||||
def debugSignedRelease = !releaseSigningConfigured && allowDebugSignedRelease && !onCi
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
@@ -95,7 +151,7 @@ android {
|
||||
applicationId 'com.anonymous.bulwarkmobile'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionCode appVersionCode.toInteger()
|
||||
versionName appVersionName
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
@@ -107,13 +163,12 @@ android {
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
if (project.hasProperty('BULWARK_RELEASE_STORE_FILE')
|
||||
&& project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) {
|
||||
if (releaseSigningConfigured) {
|
||||
release {
|
||||
storeFile file(project.property('BULWARK_RELEASE_STORE_FILE'))
|
||||
storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD')
|
||||
keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS')
|
||||
keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD')
|
||||
storeFile releaseStoreFile
|
||||
storePassword releaseSigning.BULWARK_RELEASE_STORE_PASSWORD
|
||||
keyAlias releaseSigning.BULWARK_RELEASE_KEY_ALIAS
|
||||
keyPassword releaseSigning.BULWARK_RELEASE_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +177,13 @@ android {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
signingConfig signingConfigs.findByName('release') ?: signingConfigs.debug
|
||||
// With neither a key nor the opt-in there is no signing config at
|
||||
// all; checkReleaseSigning below stops the build before packaging.
|
||||
if (releaseSigningConfigured) {
|
||||
signingConfig signingConfigs.release
|
||||
} else if (debugSignedRelease) {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
@@ -142,6 +203,48 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a release build that has no real key. This runs only when a release
|
||||
// variant is actually built, so `expo run:android`, assembleDebug and IDE sync
|
||||
// keep working with no key configured.
|
||||
def releaseSigningHelp = [
|
||||
"Release signing is not configured (missing: ${missingReleaseSigning.join(', ')}).",
|
||||
"",
|
||||
"Release builds are never signed with the debug key: it is the public React Native",
|
||||
"template key, so an APK signed with it can be replaced by anyone. Provide the release",
|
||||
"key as gradle properties (-P or ~/.gradle/gradle.properties) or environment variables:",
|
||||
" BULWARK_RELEASE_STORE_FILE path to the keystore",
|
||||
" BULWARK_RELEASE_STORE_PASSWORD",
|
||||
" BULWARK_RELEASE_KEY_ALIAS",
|
||||
" BULWARK_RELEASE_KEY_PASSWORD",
|
||||
"",
|
||||
onCi && allowDebugSignedRelease
|
||||
? "allowDebugSignedRelease is ignored because CI is set: published builds must use the release key."
|
||||
: "For a local test build that is never installed anywhere else, pass -PallowDebugSignedRelease=true.",
|
||||
"See docs/android-release.md.",
|
||||
].join('\n')
|
||||
|
||||
def checkReleaseSigning = tasks.register('checkReleaseSigning') {
|
||||
group = 'verification'
|
||||
description = 'Fails a release build that has no release signing key.'
|
||||
doLast {
|
||||
if (releaseSigningConfigured) {
|
||||
if (!releaseStoreFile.isFile()) {
|
||||
throw new GradleException("BULWARK_RELEASE_STORE_FILE points to ${releaseStoreFile}, which does not exist.")
|
||||
}
|
||||
} else if (debugSignedRelease) {
|
||||
logger.warn("WARNING: this release build is signed with the PUBLIC debug key " +
|
||||
"(allowDebugSignedRelease). Do not distribute it.")
|
||||
} else {
|
||||
throw new GradleException(releaseSigningHelp)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name == 'preReleaseBuild') {
|
||||
task.dependsOn(checkReleaseSigning)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
@@ -171,6 +274,12 @@ dependencies {
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
// UnifiedPush - Google-free push via a user-chosen distributor app such
|
||||
// as ntfy. Coexists with FCM; the user picks the transport in settings.
|
||||
// NOTE: 3.3.2-3.3.4 are built with Kotlin 2.3 and break this project's
|
||||
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
||||
implementation("org.unifiedpush.android:connector:3.3.5")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
@@ -12,3 +12,12 @@
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# The app's own native code (com.anonymous.bulwarkmobile). What R8 must not
|
||||
# drop or rename there is already covered elsewhere: React Native's rules keep
|
||||
# every NativeModule, whose @ReactMethod methods JS calls by name, and AAPT
|
||||
# keeps the application, activity and services listed in AndroidManifest.xml
|
||||
# (FCM, UnifiedPush and the headless JS task). Keeping the package whole on
|
||||
# top of that costs a few KB and leaves its class and method names readable
|
||||
# in a crash report from an obfuscated release build.
|
||||
-keep class com.anonymous.bulwarkmobile.** { *; }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
@@ -28,6 +28,30 @@
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.intent.action.SENDTO"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="mailto"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/*"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
@@ -35,6 +59,239 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
||||
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||
</receiver>
|
||||
<!-- END WIDGETS -->
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -141,7 +141,9 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
connectTimeout = timeoutMs
|
||||
readTimeout = timeoutMs
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
// Redirects are resolved in JS (lib/client-cert.ts) so a POST
|
||||
// stays a POST and credentials never follow a cross-host hop.
|
||||
instanceFollowRedirects = false
|
||||
if (bodyBytes != null) {
|
||||
doOutput = true
|
||||
setFixedLengthStreamingMode(bodyBytes.size)
|
||||
|
||||
@@ -66,6 +66,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
val jmapAccountId = options.takeIf { it.hasKey("jmapAccountId") }?.getString("jmapAccountId")
|
||||
val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey")
|
||||
?: accountId?.let { "bulwark-mail:$it" }
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -74,8 +79,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +92,12 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getInitialShare(promise: Promise) {
|
||||
val payload = ShareIntentStore.consume()
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
private fun postNotification(
|
||||
notificationId: String,
|
||||
title: String,
|
||||
@@ -96,6 +108,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
@@ -104,6 +118,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
@@ -113,7 +128,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
)
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
@@ -122,11 +137,64 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pending)
|
||||
if (groupKey != null) {
|
||||
builder.setGroup(groupKey)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
|
||||
// One summary per account group so several deliveries collapse into a
|
||||
// single "N new messages" entry (Android renders the "+N more" itself
|
||||
// from the children). Tapping the summary opens the app on the inbox;
|
||||
// the per-message children carry the deep link.
|
||||
private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) {
|
||||
val ctx = reactApplicationContext
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val children = manager.activeNotifications.filter {
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
val count = maxOf(children.size, 1)
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
val line = listOfNotNull(
|
||||
extras.getCharSequence(android.app.Notification.EXTRA_TITLE),
|
||||
extras.getCharSequence(android.app.Notification.EXTRA_TEXT),
|
||||
).joinToString(" ")
|
||||
if (line.isNotBlank()) inbox.addLine(line)
|
||||
}
|
||||
inbox.setBigContentTitle(groupTitle)
|
||||
inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages")
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
groupKey.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(groupTitle)
|
||||
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
||||
.setStyle(inbox)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
manager.notify(groupKey, groupKey.hashCode(), summary)
|
||||
}
|
||||
|
||||
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
|
||||
val size = 192
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
|
||||
@@ -3,14 +3,20 @@ package com.anonymous.bulwarkmobile
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(
|
||||
BulwarkFcmModule(reactContext),
|
||||
BulwarkClientCertModule(reactContext),
|
||||
)
|
||||
buildList {
|
||||
add(BulwarkFcmModule(reactContext))
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
}
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
|
||||
/**
|
||||
* React Native's HeadlessJsTaskSupport module, which AppRegistry calls when a
|
||||
* headless JS task's promise settles. RN 0.81 registers it only for the old
|
||||
* bridge (CoreModulesPackage); the bridgeless runtime's CoreReactPackage
|
||||
* leaves it out, so in this app JS could never report a finished task and
|
||||
* every BulwarkPushTask ran until its 30 s timeout, keeping the service or job
|
||||
* and its wake lock alive that long. Same logic as the RN module.
|
||||
*/
|
||||
class BulwarkHeadlessJsTaskSupportModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "HeadlessJsTaskSupport"
|
||||
|
||||
@ReactMethod
|
||||
fun notifyTaskFinished(taskId: Double) {
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
|
||||
val id = taskId.toInt()
|
||||
if (tasks.isTaskRunning(id)) tasks.finishTask(id)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun notifyTaskRetry(taskId: Double, promise: Promise) {
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
|
||||
val id = taskId.toInt()
|
||||
promise.resolve(tasks.isTaskRunning(id) && tasks.retryTask(id))
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
@@ -31,7 +32,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
if (!isAppInForeground()) {
|
||||
startHeadlessTask(data)
|
||||
startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
val params = Arguments.createMap().apply {
|
||||
@@ -42,25 +43,60 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
BulwarkFcmModule.emit("fcm:message", params)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
private fun startHeadlessTask(data: Map<String, String>) {
|
||||
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
applicationContext.startService(intent)
|
||||
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "bulwark_mail"
|
||||
private const val TAG = "BulwarkPush"
|
||||
private const val START_WAKE_LOCK_MS = 10_000L
|
||||
|
||||
// Shared with BulwarkUnifiedPushService - a push arriving over either
|
||||
// transport is dispatched the same way.
|
||||
fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
fun startHeadlessTask(context: Context, data: Map<String, String>) {
|
||||
val intent = Intent(context, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
// Keep the device awake until the service holds React Native's
|
||||
// wake lock; BulwarkPushTaskService releases this one then. Not
|
||||
// HeadlessJsTaskService.acquireWakeLockNow: it is not
|
||||
// thread-safe, and FCM calls this on a worker thread while the
|
||||
// service takes the same static lock on the main thread, so both
|
||||
// could create a lock and the overwritten one stayed held.
|
||||
startWakeLock = context.getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bulwark:push-start")
|
||||
.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(START_WAKE_LOCK_MS)
|
||||
}
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Android 8+ refuses to start a service from the background
|
||||
// unless the app is temporarily allowlisted. A high-priority
|
||||
// FCM message does that; a downgraded one or a UnifiedPush
|
||||
// message may not (BackgroundServiceStartNotAllowedException
|
||||
// on 12+), and uncaught this crashed the app on every such
|
||||
// push. Run the same task from a job instead.
|
||||
releaseStartWakeLock()
|
||||
Log.w(TAG, "Push service start refused in the background, using a job: ${e.message}")
|
||||
BulwarkPushJobService.schedule(context, data)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var startWakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
/** Drops the wake lock taken in [startHeadlessTask]; it also times out on its own. */
|
||||
fun releaseStartWakeLock() {
|
||||
startWakeLock?.release()
|
||||
startWakeLock = null
|
||||
}
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.job.JobInfo
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobScheduler
|
||||
import android.app.job.JobService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactInstanceEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
|
||||
*
|
||||
* A push normally starts BulwarkPushTaskService, but Android 8+ only lets a
|
||||
* background app start a service while it is temporarily allowlisted, which a
|
||||
* high-priority FCM message does. FCM delivers some messages at normal
|
||||
* priority anyway (it downgrades apps whose high-priority pushes rarely end in
|
||||
* a notification), and a UnifiedPush distributor may not raise the app at all.
|
||||
* The service start then throws, so the push is handed to this job instead:
|
||||
* expedited on Android 12+, where it runs right away while the app has
|
||||
* expedited-job quota and as a regular job after that, and a regular job on
|
||||
* Android 8-11. It needs no new dependency and ends in the same notification
|
||||
* as the service path.
|
||||
*/
|
||||
class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
||||
private class RunningTask(val params: JobParameters, val startedAt: Long)
|
||||
|
||||
private val running = ConcurrentHashMap<Int, RunningTask>()
|
||||
private var taskContext: HeadlessJsTaskContext? = null
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
|
||||
withReadyReactContext { startTask(it, config, params) }
|
||||
return true
|
||||
}
|
||||
|
||||
// The JS task has its own 30 s timeout and finishes on its own; nothing to
|
||||
// reschedule if the system stops the job first.
|
||||
override fun onStopJob(params: JobParameters): Boolean {
|
||||
running.values.removeIf { it.params.jobId == params.jobId }
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
taskContext?.removeTaskEventListener(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
val task = running.remove(taskId) ?: return
|
||||
val ms = SystemClock.elapsedRealtime() - task.startedAt
|
||||
Log.i(TAG, "Push task $taskId from job ${task.params.jobId} finished after $ms ms")
|
||||
jobFinished(task.params, false)
|
||||
}
|
||||
|
||||
private fun startTask(context: ReactContext, config: HeadlessJsTaskConfig, params: JobParameters) {
|
||||
val tasks = HeadlessJsTaskContext.getInstance(context)
|
||||
if (taskContext !== tasks) {
|
||||
taskContext?.removeTaskEventListener(this)
|
||||
tasks.addTaskEventListener(this)
|
||||
taskContext = tasks
|
||||
}
|
||||
UiThreadUtil.runOnUiThread {
|
||||
try {
|
||||
val taskId = tasks.startTask(config)
|
||||
running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
|
||||
} catch (e: IllegalStateException) {
|
||||
// The app came to the foreground since the push arrived and
|
||||
// syncs on its own there.
|
||||
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
|
||||
jobFinished(params, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls [onReady] once with a React context whose JS instance is up,
|
||||
* starting React Native if the process was woken for this job.
|
||||
*
|
||||
* A context exists before its instance is ready, and a task started on
|
||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||
* not available" and waits for the timeout), which happened when several
|
||||
* pushes arrived while the app was starting. So the listener goes in
|
||||
* first and the ready check after it; whichever sees the ready instance
|
||||
* first runs the task.
|
||||
*/
|
||||
private fun withReadyReactContext(onReady: (ReactContext) -> Unit) {
|
||||
val done = AtomicBoolean(false)
|
||||
val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) }
|
||||
val app = application as ReactApplication
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||
val listener = object : ReactInstanceEventListener {
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
host.removeReactInstanceEventListener(this)
|
||||
runOnce(context)
|
||||
}
|
||||
}
|
||||
host.addReactInstanceEventListener(listener)
|
||||
val current = host.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
host.removeReactInstanceEventListener(listener)
|
||||
runOnce(current)
|
||||
} else {
|
||||
host.start()
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val manager = app.reactNativeHost.reactInstanceManager
|
||||
val listener = object : ReactInstanceEventListener {
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
manager.removeReactInstanceEventListener(this)
|
||||
runOnce(context)
|
||||
}
|
||||
}
|
||||
manager.addReactInstanceEventListener(listener)
|
||||
val current = manager.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
manager.removeReactInstanceEventListener(listener)
|
||||
runOnce(current)
|
||||
} else if (!manager.hasStartedCreatingInitialContext()) {
|
||||
manager.createReactContextInBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkPush"
|
||||
|
||||
// A block of job ids for push jobs, one per push that is waiting or
|
||||
// running. A pending job with the same id would be replaced (and a
|
||||
// running one stopped), so each push takes a free id.
|
||||
private const val JOB_ID_BASE = 0x42554c00
|
||||
private const val JOB_ID_COUNT = 32
|
||||
|
||||
/** Schedules the push task as a job. Returns false if no job could be scheduled. */
|
||||
fun schedule(context: Context, data: Map<String, String>): Boolean = try {
|
||||
val scheduler = context.getSystemService(JobScheduler::class.java)
|
||||
val busy = scheduler.allPendingJobs.mapTo(HashSet()) { it.id }
|
||||
val jobId = (JOB_ID_BASE until JOB_ID_BASE + JOB_ID_COUNT).firstOrNull { it !in busy }
|
||||
if (jobId == null) {
|
||||
// Every slot holds a push that has not finished yet; any of
|
||||
// them fetches the same new mail.
|
||||
Log.w(TAG, "Push not scheduled: $JOB_ID_COUNT push jobs already pending")
|
||||
false
|
||||
} else {
|
||||
val extras = PersistableBundle().apply {
|
||||
for ((key, value) in data) putString(key, value)
|
||||
}
|
||||
val job = JobInfo.Builder(jobId, ComponentName(context, BulwarkPushJobService::class.java))
|
||||
.setExtras(extras)
|
||||
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
|
||||
.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) setExpedited(true) }
|
||||
.build()
|
||||
val scheduled = scheduler.schedule(job) == JobScheduler.RESULT_SUCCESS
|
||||
Log.i(TAG, "Push job $jobId scheduled: $scheduled")
|
||||
scheduled
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Push job could not be scheduled", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
|
||||
class BulwarkPushTaskService : HeadlessJsTaskService() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
super.onStartCommand(intent, flags, startId).also {
|
||||
// startTask has taken React Native's wake lock by now.
|
||||
BulwarkMessagingService.releaseStartWakeLock()
|
||||
}
|
||||
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
|
||||
val extras = intent?.extras ?: return null
|
||||
return HeadlessJsTaskConfig(
|
||||
"BulwarkPushTask",
|
||||
Arguments.fromBundle(extras),
|
||||
30000L,
|
||||
false,
|
||||
)
|
||||
return taskConfig(extras)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Shared with BulwarkPushJobService, which runs the same task when
|
||||
// this service may not be started.
|
||||
fun taskConfig(extras: Bundle): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig(
|
||||
"BulwarkPushTask",
|
||||
Arguments.fromBundle(extras),
|
||||
30000L,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
// JS-facing surface for UnifiedPush distributor management. Events from the
|
||||
// distributor (endpoint changes, incoming messages) arrive through
|
||||
// BulwarkUnifiedPushService, which emits them on the shared device-event
|
||||
// channel.
|
||||
class BulwarkUnifiedPushModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkUnifiedPush"
|
||||
|
||||
@ReactMethod
|
||||
fun getDistributors(promise: Promise) {
|
||||
try {
|
||||
val array = Arguments.createArray()
|
||||
for (d in UnifiedPush.getDistributors(reactApplicationContext)) array.pushString(d)
|
||||
promise.resolve(array)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributors_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSavedDistributor(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(UnifiedPush.getSavedDistributor(reactApplicationContext))
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getAckDistributor(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(UnifiedPush.getAckDistributor(reactApplicationContext))
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun saveDistributor(distributor: String, promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.saveDistributor(reactApplicationContext, distributor)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_save_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the saved distributor for an endpoint. The endpoint itself arrives
|
||||
// asynchronously via BulwarkUnifiedPushService.onNewEndpoint (event
|
||||
// `up:newEndpoint`); failures via `up:registrationFailed`. `vapid` is the
|
||||
// relay's VAPID public key - optional, some distributors use it to
|
||||
// restrict who may push.
|
||||
@ReactMethod
|
||||
fun register(vapid: String?, promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.register(
|
||||
reactApplicationContext,
|
||||
messageForDistributor = null,
|
||||
vapid = vapid?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_register_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun unregister(promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.unregister(reactApplicationContext)
|
||||
BulwarkUnifiedPushService.clearEndpoint(reactApplicationContext)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_unregister_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getEndpoint(promise: Promise) {
|
||||
val stored = BulwarkUnifiedPushService.readEndpoint(reactApplicationContext)
|
||||
if (stored == null) {
|
||||
promise.resolve(null)
|
||||
return
|
||||
}
|
||||
val (url, p256dh, auth) = stored
|
||||
val map = Arguments.createMap().apply {
|
||||
putString("url", url)
|
||||
putString("p256dh", p256dh)
|
||||
putString("auth", auth)
|
||||
}
|
||||
promise.resolve(map)
|
||||
}
|
||||
|
||||
// NativeEventEmitter required no-ops.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) {}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
// Receives UnifiedPush events from the user's distributor app (ntfy, ...).
|
||||
// The relay sends the same content-blind ForwardPayload it sends over FCM -
|
||||
// as encrypted Web Push when the connector provided RFC 8291 keys (the
|
||||
// connector decrypts before this service sees it) - so an incoming message is
|
||||
// dispatched exactly like an FCM data message: headless task when the app is
|
||||
// backgrounded, `fcm:message` device event for the live JS instance.
|
||||
class BulwarkUnifiedPushService : PushService() {
|
||||
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
saveEndpoint(this, endpoint)
|
||||
val params = Arguments.createMap().apply {
|
||||
putString("url", endpoint.url)
|
||||
putString("p256dh", endpoint.pubKeySet?.pubKey)
|
||||
putString("auth", endpoint.pubKeySet?.auth)
|
||||
}
|
||||
BulwarkFcmModule.emit("up:newEndpoint", params)
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
BulwarkMessagingService.ensureChannel(this)
|
||||
val data = parsePayload(message.content) ?: return
|
||||
|
||||
if (!BulwarkMessagingService.isAppInForeground()) {
|
||||
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
// Same event name and shape as the FCM path so the foreground JS
|
||||
// handler (App.tsx addMessageListener) works transport-agnostically.
|
||||
val params = Arguments.createMap().apply {
|
||||
val dataMap = Arguments.createMap()
|
||||
for ((k, v) in data) dataMap.putString(k, v)
|
||||
putMap("data", dataMap)
|
||||
}
|
||||
BulwarkFcmModule.emit("fcm:message", params)
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) {
|
||||
clearEndpoint(this)
|
||||
BulwarkFcmModule.emit("up:unregistered", Arguments.createMap())
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
val params = Arguments.createMap().apply { putString("reason", reason.name) }
|
||||
BulwarkFcmModule.emit("up:registrationFailed", params)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "bulwark_unifiedpush"
|
||||
private const val KEY_URL = "endpointUrl"
|
||||
private const val KEY_P256DH = "endpointP256dh"
|
||||
private const val KEY_AUTH = "endpointAuth"
|
||||
|
||||
// The endpoint arrives asynchronously (possibly while no JS instance
|
||||
// is alive), so persist it for BulwarkUnifiedPushModule.getEndpoint.
|
||||
fun saveEndpoint(context: Context, endpoint: PushEndpoint) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_URL, endpoint.url)
|
||||
.putString(KEY_P256DH, endpoint.pubKeySet?.pubKey)
|
||||
.putString(KEY_AUTH, endpoint.pubKeySet?.auth)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun readEndpoint(context: Context): Triple<String, String?, String?>? {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val url = prefs.getString(KEY_URL, null) ?: return null
|
||||
return Triple(url, prefs.getString(KEY_P256DH, null), prefs.getString(KEY_AUTH, null))
|
||||
}
|
||||
|
||||
fun clearEndpoint(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
// The relay's ForwardPayload JSON, flattened to the string map the
|
||||
// FCM data payload uses (repos/relay/src/fcm.ts) so the headless task
|
||||
// and foreground handler can stay transport-blind.
|
||||
fun parsePayload(content: ByteArray): Map<String, String>? = try {
|
||||
val json = JSONObject(String(content, Charsets.UTF_8))
|
||||
buildMap {
|
||||
json.optString("kind").takeIf { it.isNotEmpty() }?.let { put("kind", it) }
|
||||
json.optString("accountLabel").takeIf { it.isNotEmpty() }?.let { put("accountLabel", it) }
|
||||
json.optString("accountId").takeIf { it.isNotEmpty() }?.let { put("accountId", it) }
|
||||
put("emailIds", (json.optJSONArray("emailIds") ?: JSONArray()).toString())
|
||||
put("changed", (json.optJSONObject("changed") ?: JSONObject()).toString())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,19 @@ class MainActivity : ReactActivity() {
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
ShareIntentStore.rewriteSendToAsView(intent)
|
||||
super.onCreate(null)
|
||||
NotificationTapStore.captureFromIntent(intent)
|
||||
ShareIntentStore.captureFromIntent(intent, contentResolver)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
ShareIntentStore.rewriteSendToAsView(intent)
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
|
||||
BulwarkFcmModule.emit("app:share", share.toMap())
|
||||
}
|
||||
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
||||
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
|
||||
}
|
||||
|
||||
@@ -40,13 +40,17 @@ object NotificationTapStore {
|
||||
// the navigation payload.
|
||||
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
|
||||
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId)
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -55,12 +59,14 @@ object NotificationTapStore {
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +74,5 @@ object NotificationTapStore {
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
|
||||
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
|
||||
// intent (the system share sheet). MainActivity fills this on onCreate /
|
||||
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
|
||||
// live "app:share" event and opens the composer. `SENDTO mailto:` intents are
|
||||
// rewritten into `VIEW mailto:` links instead (see rewriteSendToAsView).
|
||||
object ShareIntentStore {
|
||||
@Volatile
|
||||
private var pending: SharePayload? = null
|
||||
|
||||
fun consume(): SharePayload? {
|
||||
val value = pending
|
||||
pending = null
|
||||
return value
|
||||
}
|
||||
|
||||
fun captureFromIntent(intent: Intent?, resolver: ContentResolver): SharePayload? {
|
||||
val action = intent?.action ?: return null
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT)
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.take(512)
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (action == Intent.ACTION_SEND) {
|
||||
streamExtra(intent)?.let { uris.add(it) }
|
||||
} else {
|
||||
streamExtras(intent)?.let { uris.addAll(it) }
|
||||
}
|
||||
val type = intent.type
|
||||
|
||||
if (text.isNullOrBlank() && uris.isEmpty()) return null
|
||||
val files = uris.mapIndexed { i, uri -> describe(resolver, uri, type, i) }
|
||||
val payload = SharePayload(
|
||||
text = text,
|
||||
subject = subject,
|
||||
uris = uris.map { it.toString() },
|
||||
mimeTypes = files.map { it.mimeType },
|
||||
names = files.map { it.name },
|
||||
sizes = files.map { it.size },
|
||||
)
|
||||
pending = payload
|
||||
// Clear so a later lifecycle event doesn't replay this share.
|
||||
intent.removeExtra(Intent.EXTRA_TEXT)
|
||||
intent.removeExtra(Intent.EXTRA_SUBJECT)
|
||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
return payload
|
||||
}
|
||||
|
||||
// `SENDTO mailto:` is how most apps start an email, but React Native's
|
||||
// Linking only forwards VIEW intents, so the app opened on the inbox.
|
||||
// Turn it into the equivalent `VIEW mailto:` link, folding the recipient,
|
||||
// subject and text extras into the URI, so it reaches JS like any other
|
||||
// mailto link: Linking.getInitialURL on a cold start, the "url" event
|
||||
// while running. Must run before the intent is handed to React Native.
|
||||
fun rewriteSendToAsView(intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
if (action != Intent.ACTION_SENDTO) return
|
||||
val data = intent.data ?: return
|
||||
if (!"mailto".equals(data.scheme, ignoreCase = true)) return
|
||||
|
||||
val params = mutableListOf<String>()
|
||||
addressExtra(intent, Intent.EXTRA_EMAIL).forEach { params.add("to=${Uri.encode(it)}") }
|
||||
addressExtra(intent, Intent.EXTRA_CC).forEach { params.add("cc=${Uri.encode(it)}") }
|
||||
addressExtra(intent, Intent.EXTRA_BCC).forEach { params.add("bcc=${Uri.encode(it)}") }
|
||||
intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.let {
|
||||
params.add("subject=${Uri.encode(it.toString().take(512))}")
|
||||
}
|
||||
intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let {
|
||||
params.add("body=${Uri.encode(it.toString().take(MAX_TEXT))}")
|
||||
}
|
||||
|
||||
val link = data.toString()
|
||||
val separator = if (link.contains('?')) "&" else "?"
|
||||
intent.action = Intent.ACTION_VIEW
|
||||
intent.data = if (params.isEmpty()) data else Uri.parse(link + separator + params.joinToString("&"))
|
||||
}
|
||||
|
||||
// EXTRA_EMAIL/CC/BCC are documented as String[], but some apps put a
|
||||
// single String.
|
||||
private fun addressExtra(intent: Intent, key: String): List<String> =
|
||||
intent.getStringArrayExtra(key)?.filterNotNull() ?: listOfNotNull(intent.getStringExtra(key))
|
||||
|
||||
private class SharedFile(val name: String, val mimeType: String, val size: Double)
|
||||
|
||||
// Ask the provider for the file's real name, type and size: a MediaStore
|
||||
// URI ends in a bare row id ("1000000027") and SEND_MULTIPLE only carries
|
||||
// a wildcard type such as "image/*". Size is -1 when unknown.
|
||||
private fun describe(resolver: ContentResolver, uri: Uri, intentType: String?, index: Int): SharedFile {
|
||||
var displayName: String? = null
|
||||
var size = -1.0
|
||||
try {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameColumn >= 0 && !cursor.isNull(nameColumn)) displayName = cursor.getString(nameColumn)
|
||||
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeColumn >= 0 && !cursor.isNull(sizeColumn)) size = cursor.getLong(sizeColumn).toDouble()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Provider without OpenableColumns: fall back to the URI below.
|
||||
}
|
||||
val resolvedType = try {
|
||||
resolver.getType(uri)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val mimeType = resolvedType ?: intentType?.takeUnless { it.contains('*') } ?: ""
|
||||
val name = displayName?.takeIf { it.isNotBlank() } ?: fallbackName(uri, mimeType, index)
|
||||
return SharedFile(name, mimeType, size)
|
||||
}
|
||||
|
||||
// Last path segment plus an extension for the MIME type, so the
|
||||
// recipient's mail client can still open the attachment.
|
||||
private fun fallbackName(uri: Uri, mimeType: String, index: Int): String {
|
||||
val base = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
|
||||
?: "shared-${index + 1}"
|
||||
if (base.contains('.')) return base
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
return if (extension.isNullOrEmpty()) base else "$base.$extension"
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun streamExtra(intent: Intent): Uri? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun streamExtras(intent: Intent): List<Uri>? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
data class SharePayload(
|
||||
val text: String?,
|
||||
val subject: String?,
|
||||
val uris: List<String>,
|
||||
val mimeTypes: List<String>,
|
||||
val names: List<String>,
|
||||
val sizes: List<Double>,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
if (text != null) putString("text", text)
|
||||
if (subject != null) putString("subject", subject)
|
||||
putArray("uris", Arguments.fromList(uris))
|
||||
putArray("mimeTypes", Arguments.fromList(mimeTypes))
|
||||
putArray("names", Arguments.fromList(names))
|
||||
putArray("sizes", Arguments.fromList(sizes))
|
||||
}
|
||||
}
|
||||
|
||||
// 1 MiB of shared text is plenty; anything bigger is an attachment.
|
||||
private const val MAX_TEXT = 1 * 1024 * 1024
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json.
|
||||
// react-native-android-widget names a widget after its provider class, so each
|
||||
// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx)
|
||||
// renders by that name.
|
||||
package com.anonymous.bulwarkmobile.widget
|
||||
|
||||
import com.reactnativeandroidwidget.RNWidgetProvider
|
||||
|
||||
class InboxWidget : RNWidgetProvider()
|
||||
class TriageWidget : RNWidgetProvider()
|
||||
class UnreadCountWidget : RNWidgetProvider()
|
||||
class LatestMessageWidget : RNWidgetProvider()
|
||||
class FolderCountsWidget : RNWidgetProvider()
|
||||
class MailShortcutsWidget : RNWidgetProvider()
|
||||
class AllAccountsWidget : RNWidgetProvider()
|
||||
class StarredWidget : RNWidgetProvider()
|
||||
class FavouritePeopleWidget : RNWidgetProvider()
|
||||
class MailSearchWidget : RNWidgetProvider()
|
||||
class OutboxWidget : RNWidgetProvider()
|
||||
class TagWidget : RNWidgetProvider()
|
||||
class LastDraftWidget : RNWidgetProvider()
|
||||
class AgendaWidget : RNWidgetProvider()
|
||||
class WeekWidget : RNWidgetProvider()
|
||||
class MonthWidget : RNWidgetProvider()
|
||||
class TodayEventsWidget : RNWidgetProvider()
|
||||
class NextEventWidget : RNWidgetProvider()
|
||||
class DateNextWidget : RNWidgetProvider()
|
||||
class UpNextWidget : RNWidgetProvider()
|
||||
class InvitationsWidget : RNWidgetProvider()
|
||||
class FreeTimeWidget : RNWidgetProvider()
|
||||
class TasksWidget : RNWidgetProvider()
|
||||
class TaskProgressWidget : RNWidgetProvider()
|
||||
class CountdownWidget : RNWidgetProvider()
|
||||
class BirthdaysWidget : RNWidgetProvider()
|
||||
class TodayHubWidget : RNWidgetProvider()
|
||||
class MailAndNextWidget : RNWidgetProvider()
|
||||
class RecentFilesWidget : RNWidgetProvider()
|
||||
class AttachmentsWidget : RNWidgetProvider()
|
||||
class VacationWidget : RNWidgetProvider()
|
||||
class StorageWidget : RNWidgetProvider()
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Status-bar icon for mail notifications: the Bulwark shield from
|
||||
assets/logos/Bulwark Logo White.svg, white on transparent. Android draws
|
||||
only the alpha channel of a small icon; the group scale keeps the shield
|
||||
inside the 22dp live area of the 24dp canvas. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="1000"
|
||||
android:viewportHeight="1000">
|
||||
<group
|
||||
android:pivotX="500"
|
||||
android:pivotY="500"
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 373.467 557.247 L 55.226 271.52 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 373.467 557.247 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 626.533 557.247 L 944.774 271.52 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 626.533 557.247 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 500 638.743 L 500 638.743 C 466.206 638.738 448.855 625.316 424.448 603.033 L 197.809 829.672 C 261.382 884.635 364.213 931.859 500 990 Q 500 990 500 990 C 635.787 931.859 738.618 884.635 802.191 829.672 L 575.552 603.033 C 551.145 625.316 533.794 638.738 500 638.743 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -1 +1,6 @@
|
||||
<resources/>
|
||||
<resources>
|
||||
<!-- As `splash.dark.backgroundColor` in app.config.js. -->
|
||||
<color name="splashscreen_background">#09090b</color>
|
||||
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#09090b</color>
|
||||
</resources>
|
||||
@@ -1,6 +1,9 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#09090b</color>
|
||||
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#09090b</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#09090b</color>
|
||||
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#ffffff</color>
|
||||
</resources>
|
||||
@@ -4,6 +4,7 @@
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">#09090b</item>
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<resources>
|
||||
<string name="widgetprovider_inboxwidget_label">Inbox</string>
|
||||
<string name="widgetprovider_inboxwidget_description">Newest messages with sender, time and subject. Taller sizes add avatars and a line of preview.</string>
|
||||
<string name="widgetprovider_triagewidget_label">Triage</string>
|
||||
<string name="widgetprovider_triagewidget_description">One unread message at a time with archive, delete and reply buttons.</string>
|
||||
<string name="widgetprovider_unreadcountwidget_label">Unread count</string>
|
||||
<string name="widgetprovider_unreadcountwidget_description">Unread count for the inbox, with the latest senders.</string>
|
||||
<string name="widgetprovider_latestmessagewidget_label">Latest message</string>
|
||||
<string name="widgetprovider_latestmessagewidget_description">Only the newest unread message.</string>
|
||||
<string name="widgetprovider_foldercountswidget_label">Folder counts</string>
|
||||
<string name="widgetprovider_foldercountswidget_description">Unread counts for up to four folders.</string>
|
||||
<string name="widgetprovider_mailshortcutswidget_label">Shortcuts</string>
|
||||
<string name="widgetprovider_mailshortcutswidget_description">Compose, calendar, contacts and files.</string>
|
||||
<string name="widgetprovider_allaccountswidget_label">All accounts</string>
|
||||
<string name="widgetprovider_allaccountswidget_description">The unified inbox across every signed-in account.</string>
|
||||
<string name="widgetprovider_starredwidget_label">Starred</string>
|
||||
<string name="widgetprovider_starredwidget_description">Starred messages, newest first.</string>
|
||||
<string name="widgetprovider_favouritepeoplewidget_label">Favourite people</string>
|
||||
<string name="widgetprovider_favouritepeoplewidget_description">The people you hear from most, with their unread count.</string>
|
||||
<string name="widgetprovider_mailsearchwidget_label">Search mail</string>
|
||||
<string name="widgetprovider_mailsearchwidget_description">Search bar with your recent searches.</string>
|
||||
<string name="widgetprovider_outboxwidget_label">Scheduled and outbox</string>
|
||||
<string name="widgetprovider_outboxwidget_description">Scheduled messages and anything waiting in the outbox.</string>
|
||||
<string name="widgetprovider_tagwidget_label">Tag</string>
|
||||
<string name="widgetprovider_tagwidget_description">Unread count for your busiest tag.</string>
|
||||
<string name="widgetprovider_lastdraftwidget_label">Last draft</string>
|
||||
<string name="widgetprovider_lastdraftwidget_description">Back into the draft you left.</string>
|
||||
<string name="widgetprovider_agendawidget_label">Agenda</string>
|
||||
<string name="widgetprovider_agendawidget_description">Upcoming events by day with time, colour and place.</string>
|
||||
<string name="widgetprovider_weekwidget_label">This week</string>
|
||||
<string name="widgetprovider_weekwidget_description">The week at a glance from 8:00 to 18:00.</string>
|
||||
<string name="widgetprovider_monthwidget_label">Month</string>
|
||||
<string name="widgetprovider_monthwidget_description">Month grid with a dot per calendar and the next two events.</string>
|
||||
<string name="widgetprovider_todayeventswidget_label">Today</string>
|
||||
<string name="widgetprovider_todayeventswidget_description">Today\'s date with the rest of today and tomorrow.</string>
|
||||
<string name="widgetprovider_nexteventwidget_label">Next event</string>
|
||||
<string name="widgetprovider_nexteventwidget_description">What\'s next, when and where.</string>
|
||||
<string name="widgetprovider_datenextwidget_label">Date and next two</string>
|
||||
<string name="widgetprovider_datenextwidget_description">Today\'s date with the next two events.</string>
|
||||
<string name="widgetprovider_upnextwidget_label">Up next</string>
|
||||
<string name="widgetprovider_upnextwidget_description">Countdown to the next event with join and running-late buttons.</string>
|
||||
<string name="widgetprovider_invitationswidget_label">Invitations</string>
|
||||
<string name="widgetprovider_invitationswidget_description">Pending invitations with accept, maybe and decline.</string>
|
||||
<string name="widgetprovider_freetimewidget_label">Free time</string>
|
||||
<string name="widgetprovider_freetimewidget_description">When you\'re free this week.</string>
|
||||
<string name="widgetprovider_taskswidget_label">Tasks</string>
|
||||
<string name="widgetprovider_taskswidget_description">Open tasks with tick boxes that work from the home screen.</string>
|
||||
<string name="widgetprovider_taskprogresswidget_label">Task progress</string>
|
||||
<string name="widgetprovider_taskprogresswidget_description">How many tasks are done.</string>
|
||||
<string name="widgetprovider_countdownwidget_label">Countdown</string>
|
||||
<string name="widgetprovider_countdownwidget_description">Days until your next all-day event.</string>
|
||||
<string name="widgetprovider_birthdayswidget_label">Birthdays</string>
|
||||
<string name="widgetprovider_birthdayswidget_description">Upcoming birthdays from your contacts.</string>
|
||||
<string name="widgetprovider_todayhubwidget_label">Today overview</string>
|
||||
<string name="widgetprovider_todayhubwidget_description">Next event, newest unread mail and tasks due, in one widget.</string>
|
||||
<string name="widgetprovider_mailandnextwidget_label">Mail and next event</string>
|
||||
<string name="widgetprovider_mailandnextwidget_description">Unread mail on one side, the next event on the other.</string>
|
||||
<string name="widgetprovider_recentfileswidget_label">Recent files</string>
|
||||
<string name="widgetprovider_recentfileswidget_description">Recently changed items in Files.</string>
|
||||
<string name="widgetprovider_attachmentswidget_label">Recent attachments</string>
|
||||
<string name="widgetprovider_attachmentswidget_description">The newest attachments from your mail.</string>
|
||||
<string name="widgetprovider_vacationwidget_label">Vacation responder</string>
|
||||
<string name="widgetprovider_vacationwidget_description">Vacation responder status with an on/off switch.</string>
|
||||
<string name="widgetprovider_storagewidget_label">Storage</string>
|
||||
<string name="widgetprovider_storagewidget_description">How much of your mailbox quota is used.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_agendawidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/agendawidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_allaccountswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/allaccountswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_attachmentswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/attachmentswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_birthdayswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/birthdayswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_countdownwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/countdownwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_datenextwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/datenextwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_favouritepeoplewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/favouritepeoplewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_foldercountswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/foldercountswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_freetimewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/freetimewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_inboxwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/inboxwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_invitationswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/invitationswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_lastdraftwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/lastdraftwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_latestmessagewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/latestmessagewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_mailandnextwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/mailandnextwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="40dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_mailsearchwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/mailsearchwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_mailshortcutswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/mailshortcutswidget_preview"
|
||||
android:updatePeriodMillis="0"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="180dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_monthwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/monthwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_nexteventwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/nexteventwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_outboxwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/outboxwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_recentfileswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/recentfileswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_starredwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/starredwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_storagewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/storagewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_tagwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/tagwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_taskprogresswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/taskprogresswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_taskswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/taskswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_todayeventswidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/todayeventswidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="250dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="180dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_todayhubwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/todayhubwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_triagewidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/triagewidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_unreadcountwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/unreadcountwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_upnextwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/upnextwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_vacationwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/vacationwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="250dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:description="@string/widgetprovider_weekwidget_description"
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:previewImage="@drawable/weekwidget_preview"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -25,6 +25,11 @@ android.useAndroidX=true
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
|
||||
# Shrink release builds with R8 (code) and the resource shrinker. Keep rules
|
||||
# for classes reached by reflection go in app/proguard-rules.pro.
|
||||
android.enableMinifyInReleaseBuilds=true
|
||||
android.enableShrinkResourcesInReleaseBuilds=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
@@ -63,3 +68,9 @@ expo.useLegacyPackaging=false
|
||||
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
|
||||
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
|
||||
expo.edgeToEdgeEnabled=true
|
||||
|
||||
# AsyncStorage caps its SQLite database at 6 MB unless this is set. The
|
||||
# offline mail cache keeps message bodies there and can be set to 500 MB,
|
||||
# and once the database is full every write fails, the account registry
|
||||
# and the outbox included. This is a ceiling, not an allocation.
|
||||
AsyncStorage_db_size_in_MB=1024
|
||||
|
||||
@@ -26,16 +26,21 @@ module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
slug: 'bulwark-mobile',
|
||||
scheme: 'bulwarkmobile',
|
||||
// mailto: lets Android/iOS offer the app for mail links in other apps.
|
||||
scheme: ['bulwarkmobile', 'mailto'],
|
||||
version: VERSION,
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon.png',
|
||||
userInterfaceStyle: 'dark',
|
||||
userInterfaceStyle: 'automatic',
|
||||
newArchEnabled: true,
|
||||
splash: {
|
||||
image: './assets/splash-icon.png',
|
||||
resizeMode: 'contain',
|
||||
backgroundColor: '#09090b',
|
||||
backgroundColor: '#ffffff',
|
||||
dark: {
|
||||
image: './assets/splash-icon.png',
|
||||
backgroundColor: '#09090b',
|
||||
},
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
@@ -56,6 +61,11 @@ module.exports = {
|
||||
edgeToEdgeEnabled: true,
|
||||
predictiveBackGestureEnabled: false,
|
||||
package: 'com.anonymous.bulwarkmobile',
|
||||
// The AsyncStorage database holds cached message bodies, the outbox and
|
||||
// the account registry; the platform backup would ship all of it to the
|
||||
// user's Google account. Credentials live in SecureStore (excluded by
|
||||
// its own rules) but the mail cache must not leave the device either.
|
||||
allowBackup: false,
|
||||
},
|
||||
web: {
|
||||
favicon: './assets/favicon.png',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const path = require('node:path');
|
||||
|
||||
// babel-preset-expo ships inside `expo` rather than as a direct dependency, so
|
||||
// resolve it from there; this is the preset Expo uses when no config exists.
|
||||
const presetExpo = require.resolve('babel-preset-expo', {
|
||||
paths: [path.dirname(require.resolve('expo/package.json'))],
|
||||
});
|
||||
|
||||
module.exports = function babelConfig(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [presetExpo],
|
||||
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
# Android release signing
|
||||
|
||||
The release APK is built by
|
||||
[.github/workflows/release-android.yml](../.github/workflows/release-android.yml)
|
||||
on an Ubuntu runner whenever a GitHub release is published, and attached to that
|
||||
release. Installed apps pick it up through the in-app updater.
|
||||
|
||||
Every APK up to and including 0.1.62 was signed with `android/app/debug.keystore`,
|
||||
the debug key that ships with the React Native template. Its password is
|
||||
`android` and copies of it are everywhere, so anyone could sign an APK that
|
||||
Android accepts as an update to Bulwark Mobile. Such an "update" runs as the
|
||||
app, with its accounts, SecureStore credentials and mail cache. This is
|
||||
[S2 in the 2026-09 audit](audit-2026-09.md#s2).
|
||||
|
||||
Release builds now need a real key:
|
||||
|
||||
- `android/app/build.gradle` reads the key from `BULWARK_RELEASE_*` properties.
|
||||
Without them, a release build fails in `checkReleaseSigning` before anything
|
||||
is packaged. Debug builds and `npx expo run:android` are not affected.
|
||||
- The workflow fails in its first step when a signing secret is missing, checks
|
||||
the key before the build, and refuses to upload an APK that Android 9+ would
|
||||
not see as signed by the release key.
|
||||
- `versionCode` now comes from `VERSION` (see [versionCode](#versioncode)).
|
||||
|
||||
## One-time setup
|
||||
|
||||
The commands are for Git Bash on Windows, run from the repository root. They
|
||||
need `keytool`, which ships with every JDK, and `apksigner` from the Android SDK
|
||||
build-tools. Its `.jar` runs on Java 8 or newer:
|
||||
|
||||
```bash
|
||||
apksigner() { java -jar "$LOCALAPPDATA/Android/Sdk/build-tools/36.0.0/lib/apksigner.jar" "$@"; }
|
||||
```
|
||||
|
||||
On macOS or Linux, call `$ANDROID_HOME/build-tools/36.0.0/apksigner` instead.
|
||||
|
||||
### 1. Generate the release key
|
||||
|
||||
```bash
|
||||
read -rsp 'Keystore password: ' BULWARK_KS_PASS; echo; export BULWARK_KS_PASS
|
||||
keytool -genkeypair -v -storetype PKCS12 -keystore bulwark-release.p12 \
|
||||
-alias bulwark -keyalg RSA -keysize 4096 -validity 10000 \
|
||||
-dname "CN=Bulwark Mail, O=Bulwark Mail" \
|
||||
-storepass:env BULWARK_KS_PASS -keypass:env BULWARK_KS_PASS
|
||||
```
|
||||
|
||||
`-validity 10000` is about 27 years. A PKCS12 keystore uses one password for
|
||||
the store and the key, so `ANDROID_KEYSTORE_PASSWORD` and `ANDROID_KEY_PASSWORD`
|
||||
below get the same value. `*.p12` is gitignored, but move the file out of the
|
||||
checkout once the secrets are set.
|
||||
|
||||
**Back up `bulwark-release.p12` and its password in two places**, for example
|
||||
a password manager and an offline copy. If the key is lost, no later APK can
|
||||
update an installed app: every user has to uninstall, which loses their accounts
|
||||
and local data, and install again. If the key leaks, you are back where S2
|
||||
started.
|
||||
|
||||
The certificate's SHA-256 is what the workflow log prints as the release key:
|
||||
|
||||
```bash
|
||||
keytool -exportcert -keystore bulwark-release.p12 -alias bulwark \
|
||||
-storepass:env BULWARK_KS_PASS | sha256sum
|
||||
```
|
||||
|
||||
### 2. Create the rotation lineage
|
||||
|
||||
Skip this and the first release signed with the new key cannot be installed
|
||||
over any existing install. Android reports "App not installed as package
|
||||
conflicts with an existing package", and every user has to uninstall and
|
||||
reinstall.
|
||||
|
||||
APK Signature Scheme v3, read by Android 9 and newer, lets an APK carry a
|
||||
*signing lineage*: a record signed by the old key that names the new key as its
|
||||
successor. The old key here is the public debug key, so you can create that
|
||||
record yourself:
|
||||
|
||||
```bash
|
||||
apksigner rotate --out bulwark.lineage \
|
||||
--old-signer --ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||
--ks-pass pass:android --key-pass pass:android \
|
||||
--set-installed-data true --set-rollback false \
|
||||
--set-permission false --set-auth false --set-shared-uid false \
|
||||
--new-signer --ks bulwark-release.p12 --ks-key-alias bulwark \
|
||||
--ks-pass env:BULWARK_KS_PASS --key-pass env:BULWARK_KS_PASS
|
||||
apksigner lineage --in bulwark.lineage --print-certs
|
||||
```
|
||||
|
||||
The `--set-*` flags on the old signer decide what the debug key can still do on
|
||||
a device that has moved to the new key:
|
||||
|
||||
- `--set-installed-data true` lets the new key take over an install made with
|
||||
the debug key. Without it the rotation does nothing.
|
||||
- `--set-rollback false` keeps an APK signed with the debug key from being
|
||||
accepted again afterwards. With `true` the rotation would be pointless, and the
|
||||
workflow refuses such a lineage.
|
||||
- `--set-permission false` stops granting the app's signature permissions to
|
||||
apps signed with the debug key, which includes every React Native debug build.
|
||||
The merged manifest defines one,
|
||||
`com.anonymous.bulwarkmobile.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION`. It
|
||||
guards the receivers that are registered as not exported on Android 12L and
|
||||
older.
|
||||
- `--set-auth false` and `--set-shared-uid false`: the app uses neither.
|
||||
|
||||
The lineage holds only certificates and a signature made with the debug key, so
|
||||
it is not secret. It is stored as a secret only so the workflow can find it.
|
||||
|
||||
### 3. Repository secrets
|
||||
|
||||
Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `ANDROID_KEYSTORE_BASE64` | `bulwark-release.p12`, base64-encoded |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | The keystore password |
|
||||
| `ANDROID_KEY_ALIAS` | `bulwark` |
|
||||
| `ANDROID_KEY_PASSWORD` | The same password again |
|
||||
| `ANDROID_SIGNING_LINEAGE_BASE64` | `bulwark.lineage`, base64-encoded. Optional, but see step 2 |
|
||||
|
||||
With the GitHub CLI, from the checkout and in the same shell as step 1:
|
||||
|
||||
```bash
|
||||
base64 -w0 bulwark-release.p12 | gh secret set ANDROID_KEYSTORE_BASE64
|
||||
printf %s "$BULWARK_KS_PASS" | gh secret set ANDROID_KEYSTORE_PASSWORD
|
||||
printf %s "$BULWARK_KS_PASS" | gh secret set ANDROID_KEY_PASSWORD
|
||||
gh secret set ANDROID_KEY_ALIAS --body bulwark
|
||||
base64 -w0 bulwark.lineage | gh secret set ANDROID_SIGNING_LINEAGE_BASE64
|
||||
```
|
||||
|
||||
Or copy a file as base64 for the web form:
|
||||
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes('bulwark-release.p12')) | Set-Clipboard
|
||||
```
|
||||
|
||||
## The first release with the new key
|
||||
|
||||
1. Set all five secrets.
|
||||
2. Release as usual with `npm run bump`.
|
||||
3. In the workflow run, "Verify the APK signature" prints the release key's
|
||||
SHA-256 as the Android 9+ signer and `fac61745…3b9c`, the debug key, as the
|
||||
Android 7-8.1 signer, followed by a warning about the latter. The warning is
|
||||
expected for as long as the lineage is used.
|
||||
4. Check it on the emulators before you announce anything:
|
||||
- Install 0.1.62 (`gh release download 0.1.62 -p '*.apk'`) on
|
||||
`Pixel_6_Pro_API_32` and `Pixel_7_API_34` and sign in. Then `adb install -r`
|
||||
the new APK. It has to install over 0.1.62 and keep the account.
|
||||
- Set `VERSION` above the new release locally (don't commit it), build with
|
||||
`-PallowDebugSignedRelease=true` (see [Local release builds](#local-release-builds))
|
||||
and `adb install -r` that APK over the updated app. It has to fail with
|
||||
`INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
|
||||
5. Add a line `severity: security` to the release notes (web UI or
|
||||
`gh release edit`). The in-app update banner then cannot be dismissed, which
|
||||
matters because a device on 0.1.62 accepts a debug-signed "update" until it
|
||||
has installed this release.
|
||||
|
||||
## What rotation does and does not protect
|
||||
|
||||
| Device | After it installs a rotated release |
|
||||
| --- | --- |
|
||||
| Android 9 and newer (API 28+) | Updates in place and keeps accounts and data. From then on it accepts only updates signed with the release key. An APK signed with the debug key is refused, and so is one that carries a lineage from the debug key to some other key. |
|
||||
| Android 7.0-8.1 (API 24-27) | Updates in place, but these versions do not read v3 signatures. They check the v2 signature, which the workflow still makes with the debug key so that they can update at all. They keep accepting debug-signed "updates". |
|
||||
|
||||
What it does not cover:
|
||||
|
||||
- **Devices that have not updated yet.** Until a device installs a rotated
|
||||
release it trusts the debug key as before. Whoever gets a debug-signed APK onto
|
||||
it first can attach a lineage to their own key and keep the app for good. The
|
||||
window closes for each device when it updates, which is why the release should
|
||||
carry `severity: security`.
|
||||
- **Android 7.0-8.1**, as long as the lineage is used. New installs on those
|
||||
versions also start out trusting the debug key. The only fix is
|
||||
[ending the migration](#ending-the-migration), after which they need a
|
||||
reinstall.
|
||||
- **Anything that already happened.** Rotation does not repair a device that
|
||||
already runs a malicious "update".
|
||||
- **The release key itself.** Rotation moves the trust to
|
||||
`bulwark-release.p12`. It is only as safe as that file and its password.
|
||||
|
||||
The workflow passes `--rotation-min-sdk-version 28` to `apksigner sign`. By
|
||||
default apksigner puts the rotated key in a v3.1 block that only Android 13+
|
||||
reads and signs the v3.0 block with the old key, which would leave Android
|
||||
9-12L on the debug key.
|
||||
|
||||
If you restricted the Firebase API key in `google-services.json` to Android apps
|
||||
by certificate fingerprint in the Google Cloud console, add the release key's
|
||||
SHA-1 there before the first release and keep the debug key's entry until the
|
||||
migration has ended. Otherwise push registration can fail. An unrestricted key
|
||||
needs nothing.
|
||||
|
||||
## Ending the migration
|
||||
|
||||
Keep `ANDROID_SIGNING_LINEAGE_BASE64` set while installs of 0.1.62 or older may
|
||||
still be around: without it they cannot update at all.
|
||||
|
||||
Once you delete it, every APK is signed with the release key alone:
|
||||
|
||||
- Devices that already moved to the release key keep updating normally.
|
||||
- Anything still on 0.1.62 or older, and every Android 7.0-8.1 install, refuses
|
||||
the update. Those users have to uninstall, reinstall and sign in again, and
|
||||
they lose local data. Afterwards their devices trust only the release key.
|
||||
|
||||
## Local release builds
|
||||
|
||||
CI signs releases, so you normally don't need the release key on your machine.
|
||||
For a local release build, for profiling or checking R8, opt in to the debug key:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease -PallowDebugSignedRelease=true
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:ORG_GRADLE_PROJECT_allowDebugSignedRelease = 'true'
|
||||
npx expo run:android --variant release
|
||||
```
|
||||
|
||||
The build prints a warning. The APK is signed with the public key, so never
|
||||
give it to anyone. On a device that has moved to the release key it will not
|
||||
install over the official app; uninstall that first. `allowDebugSignedRelease`
|
||||
is ignored when the `CI` environment variable is set, so the workflow cannot
|
||||
use it.
|
||||
|
||||
To sign locally with the real key, set these as `-P` flags, in
|
||||
`~/.gradle/gradle.properties` or as environment variables. Never put them in
|
||||
`android/gradle.properties`, which is committed.
|
||||
|
||||
```properties
|
||||
BULWARK_RELEASE_STORE_FILE=C:/Users/you/keys/bulwark-release.p12
|
||||
BULWARK_RELEASE_STORE_PASSWORD=...
|
||||
BULWARK_RELEASE_KEY_ALIAS=bulwark
|
||||
BULWARK_RELEASE_KEY_PASSWORD=...
|
||||
```
|
||||
|
||||
That APK has no lineage, so it will not install over a debug-signed install
|
||||
either.
|
||||
|
||||
With neither, a release build stops and lists what is missing. Debug builds and
|
||||
`npx expo run:android` never need any of this.
|
||||
|
||||
## Stack traces
|
||||
|
||||
Release builds are shrunk and obfuscated by R8, so library frames in a crash
|
||||
report look like `com.facebook.react.uimanager.U.a`. The app's own classes
|
||||
(`com.anonymous.bulwarkmobile`) keep their names. The workflow attaches
|
||||
`bulwark-mobile-<version>-<commit>-mapping.txt` to the release next to the APK
|
||||
and keeps it as a workflow artifact. Decode a trace with the mapping of that
|
||||
exact build:
|
||||
|
||||
```bash
|
||||
retrace bulwark-mobile-0.1.63-abc1234-mapping.txt stacktrace.txt
|
||||
```
|
||||
|
||||
`retrace` is in the Android SDK command-line tools (`cmdline-tools/latest/bin`).
|
||||
A local build writes its mapping to
|
||||
`android/app/build/outputs/mapping/release/mapping.txt`.
|
||||
|
||||
## versionCode
|
||||
|
||||
`versionCode` was `1` for every release up to 0.1.62. It is now derived from
|
||||
`VERSION` in `android/app/build.gradle`:
|
||||
|
||||
```
|
||||
major * 10000000 + minor * 10000 + patch 0.1.63 -> 10063
|
||||
```
|
||||
|
||||
Android refuses an update whose `versionCode` is lower than the installed one,
|
||||
so the number may only grow. The formula keeps growing while patch stays below
|
||||
10000 and minor below 1000, and `npm run bump` refuses to go past either. It
|
||||
stays under Android's limit of 2100000000 up to major 209. Existing installs
|
||||
have `versionCode` 1, so the first release with this change updates them
|
||||
normally.
|
||||
|
||||
Never switch to a formula that gives smaller numbers: installed apps would
|
||||
refuse every update until the numbers caught up.
|
||||
|
||||
## Rotating again
|
||||
|
||||
If the release key ever has to change, extend the lineage instead of starting
|
||||
a new one. Generate `bulwark-release-2.p12` as in step 1, with its password in
|
||||
`NEW_KS_PASS`:
|
||||
|
||||
```bash
|
||||
apksigner rotate --in bulwark.lineage --out bulwark-2.lineage \
|
||||
--old-signer --ks bulwark-release.p12 --ks-key-alias bulwark \
|
||||
--ks-pass env:BULWARK_KS_PASS --key-pass env:BULWARK_KS_PASS --set-rollback false \
|
||||
--new-signer --ks bulwark-release-2.p12 --ks-key-alias bulwark \
|
||||
--ks-pass env:NEW_KS_PASS --key-pass env:NEW_KS_PASS
|
||||
```
|
||||
|
||||
Then update the secrets. The workflow accepts any lineage that starts at the
|
||||
debug key and ends at the key in `ANDROID_KEYSTORE_BASE64`, and refuses one in
|
||||
which an earlier key has the rollback capability. As with the first rotation,
|
||||
only Android 9+ devices that install the new release follow it. If the old key
|
||||
leaked, a device that someone reached first stays theirs.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Authentication, login, session & multi-account
|
||||
|
||||
## Summary
|
||||
RN covers the happy paths (password login, webmail-mediated OAuth handoff, QR pairing, per-account keychain credentials, restore/switch/logout, Stalwart account-security API) and several WEB fixes are already mirrored (refresh coalescing, cached-token reuse, keep-session-on-network-error). The biggest gaps are: no TOTP/2FA password login at all (and the webmail handoff silently breaks for TOTP accounts), the Account Security screen is dead against every Stalwart server because it checks the wrong capability map (native issue #47), a failed "add account" or failed switch destroys the live session, expired/revoked tokens never lead back to the login screen, account display names are never refreshed (#900), relative session URLs are not resolved, and there is no per-account remove / per-device push revoke / public-key management.
|
||||
|
||||
## Findings
|
||||
|
||||
### Login flow
|
||||
|
||||
- [x] **TOTP / 2FA password login is impossible** — `P1` — `missing` — fixed in b52768e
|
||||
- What WEB does: login form has a TOTP field (auto-shown when the server answers 402 "MFA code required", `lib/jmap/client.ts:956-966` → `TOTP_REQUIRED`). With a code it calls `/api/auth/totp-token-exchange` which performs Stalwart's structured login `POST {server}/api/auth {type:'authCode', accountName, accountSecret, mfaToken, clientId, redirectUri, codeChallenge, codeChallengeMethod:'S256'}` then `POST {server}/auth/token` (authorization_code + PKCE) and continues as a bearer session (`app/api/auth/totp-token-exchange/route.ts:26-28, 90-93, 125, 150`; `stores/auth-store.ts:651-706`; changelog 1.7.5 "Support MFA login via the structured auth endpoint"). Legacy `password$totp` basic-auth fallback for pre-0.16 servers (`auth-store.ts:696-702`).
|
||||
- What RN does: `JMAPClient.connect` only does HTTP Basic with the raw password (`src/api/jmap-client.ts:88-106`, `363-383`). A 402 becomes `"Session discovery failed: 402 …"`, which `describeLoginError` only maps for 403/404 (`src/lib/login-errors.ts:57`) so the user sees a generic "Sign-in failed". The 401 copy tells 2FA users to create an app password (`login-errors.ts:39-44`) — that is the only workaround today.
|
||||
- Fix hint: detect 402 with a title containing `totp`/`mfa` in `fetchSession`, show a code step, then implement the two-step exchange directly on device (no CORS on native): `/api/auth` with `mfaToken` + `clientId: 'bulwark-webmail'` + PKCE, then `/auth/token`; persist the result as an OAuth bundle (`tokenEndpoint = ${server}/auth/token`, `clientId`) so `connectWithOAuth` and the existing refresh path take over.
|
||||
|
||||
- [x] **Webmail password handoff breaks for TOTP-protected accounts** — `P1` — `bug` (cross-repo) — fixed in 0d94d76 (RN retries the handed-off password with a code prompt; the webmail-side flow=oauth hand-off is still open)
|
||||
- What WEB does: in mobile-handoff mode the login page always hands back `flow=password` with the raw password the user typed (`app/(main)/[locale]/login/page.tsx:638-642, 282`) even when the webmail itself upgraded that login to token auth via TOTP (`stores/auth-store.ts:651-706`, `upgradedToOAuth`).
|
||||
- What RN does: `loginViaWebmail` feeds the password into `login()` → Basic auth (`src/stores/auth-store.ts:226-229`) → Stalwart 402 → "Sign-in failed". The user completed 2FA in the browser and still cannot sign in.
|
||||
- Fix hint: WEB should hand off `flow=oauth` (access/refresh token, `token_endpoint`, `client_id`) when the password login was TOTP-upgraded (the mobile SSO branch in `app/api/auth/sso/complete/route.ts:66-97` already returns that shape); RN needs no change once WEB does that. Alternatively RN implements the previous finding and retries with a TOTP prompt when the handed-off password is rejected with 402.
|
||||
|
||||
- [x] **No direct OAuth/OIDC (PKCE) against the mail server — a Bulwark webmail at the JMAP origin is assumed** — `P2` — `missing` — fixed in 4bebf70
|
||||
- What WEB does: discovers `/.well-known/oauth-authorization-server` or `openid-configuration` (`lib/oauth/discovery.ts:150-175`), runs client-side PKCE (`login/page.tsx:569-621`, `lib/oauth/pkce.ts`) or server-side SSO; works against Stalwart's built-in OAuth (default client id `bulwark-webmail`, `lib/oauth/token-exchange.ts:14`), Keycloak, Authentik.
|
||||
- What RN does: `ConfirmStep → runHandoff(serverUrl)` opens `${serverUrl}/login?mobile_redirect_uri=…` (`src/lib/oauth.ts:49-56`, `src/screens/LoginScreen.tsx:128-135`). `serverUrl` comes from probing `/.well-known/jmap` on `domain`, `mail.domain`, `webmail.domain` (`src/lib/server-discovery.ts:70-72, 143-170`) — i.e. it finds the Stalwart host, and then assumes a Bulwark webmail lives at that same origin. When webmail is on another host (or the server only runs Stalwart), the browser opens a 404/Stalwart admin page and the only way in is the password step.
|
||||
- Fix hint: either (a) do PKCE natively with `expo-web-browser`/`expo-auth-session` against the discovered `authorization_endpoint` (redirect `bulwarkmobile://auth/callback`, client id `bulwark-webmail`, scopes `openid email profile`), or (b) probe `${serverUrl}/api/config` before opening the handoff and ask for the webmail address when it is not a Bulwark instance.
|
||||
|
||||
- [x] **Login error classification is thinner than WEB** — `P3` — `partial` — fixed in b52768e
|
||||
- What WEB does: `classifyLoginError` maps invalid credentials, network, 5xx `server_error`, `totp_required`; 429 is a `RateLimitError` with retry-after (`stores/auth-store.ts:71-96`, `lib/jmap/client.ts:849-853`).
|
||||
- What RN does: `describeLoginError` (`src/lib/login-errors.ts`) has no 402 (MFA), 429 or 5xx branches; `RateLimitError('Rate limited by server')` falls to the generic "Sign-in failed" with the raw message.
|
||||
- Fix hint: add branches for `402` → 2FA copy, `429`/`RateLimitError` → "try again in N s", `5xx` → "server error, try later".
|
||||
|
||||
- [x] **Saved-username suggestions on the login form** — `P3` — `missing` — fixed in 2404717
|
||||
- What WEB does: remembers the last 5 usernames and offers them as an autocomplete (`login/page.tsx:500-517`).
|
||||
- What RN does: `EmailStep`/`PasswordStep` start empty; in add mode `ChooseStep` only offers the known server, not known usernames.
|
||||
- Fix hint: prefill/offer `accounts[].username` from the registry (and remember the last typed address in AsyncStorage) in `EmailStep`.
|
||||
|
||||
- [x] **Login flow strings are hard-coded English** — `P3` — `partial` — fixed in 2404717
|
||||
- What WEB does: every login string is localized (`login.*` keys used throughout `login/page.tsx`).
|
||||
- What RN does: `LoginScreen.tsx`, all `src/screens/login/*.tsx`, `QrScanModal.tsx` and `login-errors.ts` use literal English while `SettingsScreen` already uses `useLocaleStore().t` (`src/screens/SettingsScreen.tsx:169-190`).
|
||||
- Fix hint: route through `t()` with `login.*` keys (the i18n audit may cover this; listed here so it is not lost).
|
||||
|
||||
- [x] **QR hint copy points to a settings page that does not exist** — `P3` — `bug` — fixed in b52768e
|
||||
- What WEB does: the pairing QR is generated in Settings → Security ("Link device", `components/settings/account-security-settings.tsx:894-1026`).
|
||||
- What RN does: hints say "Settings → Devices → Add phone" (`src/components/QrScanModal.tsx:66`) and "Settings → Devices" (`src/screens/LoginScreen.tsx:221`).
|
||||
- Fix hint: change the copy to Settings → Security → Link device.
|
||||
|
||||
- [x] **Client TLS certificate support is Android-only and does not cover push/SSE** — `P3` — `partial` (native issue #3) — fixed in edc26ce (live updates fall back to polling with a cert; redirects handled in JS (6f747db); iOS implementation still deferred)
|
||||
- What WEB does: N/A (the browser negotiates mTLS).
|
||||
- What RN does: `src/lib/client-cert.ts` bridges an Android `BulwarkClientCert` module and `secureFetch` is used by the JMAP client, blob and discovery code. Gaps: iOS has no implementation (`getNative` returns null off Android, `client-cert.ts:34-45`); the JMAP SSE stream uses `EventSource` with a plain header (`src/api/push.ts:120-137`) so live updates fail behind mTLS; the system browser used for the webmail handoff cannot present the cert; the abort signal does not reach the native path (`server-discovery.ts:103-135` works around it).
|
||||
- Fix hint: document the limits in the native issue; route SSE through a native fetch-based reader or the `react-native-sse` fetch option when a cert alias is set; consider an iOS `URLSession` delegate implementation.
|
||||
|
||||
### Session
|
||||
|
||||
- [x] **Relative session URLs are not resolved** — `P2` — `bugfix-parity` (changelog 1.9.0 "Resolve relative session URLs without corrupting URI templates") — fixed in 0b1c240 (unified-inbox.ts copy is with the mail-list agent)
|
||||
- What WEB does: `rewriteSessionUrl` prefixes the connected origin when `apiUrl`/`downloadUrl`/`uploadUrl`/`eventSourceUrl` have no scheme, and rewrites the origin of absolute ones without touching `{accountId}`/`{blobId}` templates (`lib/jmap/client.ts:1091-1110`).
|
||||
- What RN does: `rewriteSessionUrls` only rewrites absolute URLs; `extractOrigin` returns null for `/jmap/` so the relative value is passed through unchanged and `secureFetch('/jmap/')` fails (`src/api/jmap-client.ts:320-340`). Same in the unified inbox `rewriteApiUrl` (`src/api/unified-inbox.ts:55-60`).
|
||||
- Fix hint: when the URL has no `^https?://`, return `serverOrigin + (url.startsWith('/') ? url : '/' + url)`; keep the plain string splitting (the comment about the RN URL polyfill corrupting templates still applies).
|
||||
|
||||
- [x] **Redirected session fetch may lose the Authorization header** — `P3` — `partial` — fixed in 0b1c240; the check keyed on `response.redirected`, which React Native's fetch never sets, so it only works since 859770b (audit B15)
|
||||
- What WEB does: `fetchSessionResponse` detects a redirected 200 with no accounts/username (Safari and some auth proxies drop `Authorization` on redirect) and refetches `response.url` with the header (`lib/jmap/client.ts:911-925`).
|
||||
- What RN does: `fetchSession` trusts the redirected response (`src/api/jmap-client.ts:363-383`); Stalwart 0.16.19 answers `/.well-known/jmap` with a 307 to `/jmap/session` (verified against stw-test19), and iOS `NSURLSession` can strip the header on redirect, which ends in `resolveAccountId` throwing "No account found in JMAP session".
|
||||
- Fix hint: mirror the WEB check (`response.redirected` and empty `accounts`/`username` → refetch `response.url` with the header).
|
||||
|
||||
- [x] **Transient token-endpoint failure evicts the account** — `P2` — `bugfix-parity` (changelog 1.7.6 "Keep the session when the auth server is briefly unreachable", 1.7.8 "End refresh loops on sign-out and back off failed retries") — fixed in 2c0dbd1
|
||||
- What WEB does: only a definitive 400/401/403 from the token endpoint drops the refresh token (`app/api/auth/token/route.ts:143`); 5xx/network → 503 and the client keeps the account and retries with backoff (`stores/auth-store.ts:1209-1226`, `1792-1799`, `isTransientAuthError` at `:106`).
|
||||
- What RN does: `refreshOAuthAccessToken` throws `HandoffError` for any non-OK status or network error (`src/lib/oauth.ts:239-241`); `forceRefreshToken` turns every failure into `false` (`src/api/jmap-client.ts:211-221`) so an expired access token + a 5xx/offline token endpoint becomes `AuthenticationError`, and `restoreSession`/`switchAccount`/`retrySession` then delete the keychain entry (`src/stores/auth-store.ts:397-405, 505-512, 569-586`).
|
||||
- Fix hint: in `refreshOAuthAccessToken` throw a distinct transient error for network failures, 429 and 5xx; in `fetchSession`/`request` rethrow it as `NetworkError` instead of `AuthenticationError`; only 400/401/403 from the token endpoint are definitive.
|
||||
|
||||
- [x] **A revoked/expired refresh token never leads back to the login screen** — `P2` — `rn-only-bug` — fixed in b52768e
|
||||
- What WEB does: a 401 from refresh marks the session expired, logs out and redirects to login with a "session expired" banner (`stores/auth-store.ts:1209-1214`, `markSessionExpired`, `login/page.tsx` `session_expired` banner).
|
||||
- What RN does: `request()` throws `AuthenticationError('Session expired')` (`src/api/jmap-client.ts:433`) but nothing outside `auth-store`/`login-errors` handles that class (grep over `src/`); the email store just records the message. `retrySession` returns early because `get().session` is still the stale object (`src/stores/auth-store.ts:554`). The user is stuck on a dead session until a relaunch.
|
||||
- Fix hint: give `JMAPClient` an `onAuthFailure` callback (set by auth-store) that runs the `AuthenticationError` branch of `retrySession` (clear creds for the active account, `isAuthenticated:false`, error "Session expired"); or clear `session` in the store on that error so `retrySession` can run.
|
||||
|
||||
- [x] **Logout does not revoke the OAuth refresh token** — `P3` — `partial` — fixed in 4bebf70 (pairing bundles are deliberately not revoked)
|
||||
- What WEB does: `DELETE /api/auth/token` posts the refresh token to the IdP `revocation_endpoint` on logout/remove/logout-all (`app/api/auth/token/route.ts:181-254`).
|
||||
- What RN does: `logout`/`logoutAll` only delete the SecureStore entry (`src/stores/auth-store.ts:286-357`, `src/api/jmap-client.ts:350-361`); the refresh token stays valid server-side.
|
||||
- Fix hint: discover `revocation_endpoint` from `${serverUrl}/.well-known/oauth-authorization-server` and POST `token=<refresh>&token_type_hint=refresh_token&client_id=…` best-effort. Caveat: a QR-paired phone shares the desktop's refresh token (`app/api/auth/pair/create/route.ts:18-22`), so revoking it would also sign out the desktop — only revoke bundles that came from the browser handoff, or accept that behaviour deliberately.
|
||||
|
||||
- [x] **No request deadline on JMAP calls** — `P3` — `partial` (changelog 1.8.1 "Time out stalled JMAP requests so a send can't hang forever (#702)") — fixed in 0b1c240
|
||||
- What WEB does: `timedFetch` aborts when no response headers arrive within 30 s (300 s for blob transfers) and does not retry timed-out non-idempotent requests (`lib/jmap/client.ts:786-816`).
|
||||
- What RN does: `request()`/`fetchSession` use bare `secureFetch` with no timeout (`src/api/jmap-client.ts:363-383, 410-445`); only the native-cert path (30 s) and the discovery probe (2.5 s) have deadlines.
|
||||
- Fix hint: add an `AbortController` timeout in `request()` (and blob helpers), surfacing a distinct timeout error; do not auto-retry sends.
|
||||
|
||||
- [x] **SSE stream is opened without a token freshness check** — `P3` — `partial` (verify with the push audit) — fixed in edc26ce
|
||||
- What WEB does: SSE uses `authenticatedFetch`, so a 401 triggers the refresh path.
|
||||
- What RN does: `connectEventSource` reads `jmapClient.authHeader` once (`src/api/push.ts:137`) without `ensureFreshToken` (private, `src/api/jmap-client.ts:195`), so a nearly-expired access token can be used for a long-lived stream.
|
||||
- Fix hint: expose an `ensureFreshToken()`-then-header helper on the client and call it before connecting/reconnecting the stream.
|
||||
|
||||
### Multi-account
|
||||
|
||||
- [x] **A failed "add account" destroys the active session** — `P1` — `rn-only-bug` — fixed in b52768e
|
||||
- What WEB does: connects the new account with a fresh `JMAPClient` and only snapshots/clears the previous account after `connect()` succeeded (`stores/auth-store.ts:709-716`, `957-962`).
|
||||
- What RN does: `login()` and `completeOAuthHandoff` call `jmapClient.reset()` and reset contacts/calendar *before* connecting (`src/stores/auth-store.ts:110-114, 175-180`). On failure the store still says the previous account is active, but the singleton now holds the wrong credentials and no session; cancelling the modal (`App.tsx:470-481`) restores nothing, so every request throws "Not connected" until relaunch.
|
||||
- Fix hint: connect with a throwaway `new JMAPClient()` (or keep the current creds/session in locals) and only adopt the new connection into the singleton after success; on failure `await jmapClient.loadAccount(previousActive)`.
|
||||
|
||||
- [x] **A failed account switch leaves the client half-switched** — `P2` — `rn-only-bug` — fixed in b52768e
|
||||
- What WEB does: on restore failure the previous account's client is re-activated (`stores/auth-store.ts:1546-1565`); rate-limited/transient failures keep both accounts.
|
||||
- What RN does: `loadAccount(target)` overwrites `credentials` and nulls `session`/`_accountId` on failure (`src/api/jmap-client.ts:244-273`); `switchAccount` restores only the email-store view (`src/stores/auth-store.ts:393-411`) and leaves the store's `session` set, so `retrySession` short-circuits (`:554`) and the previous account is dead.
|
||||
- Fix hint: in the catch, `await jmapClient.loadAccount(previousActive)` (or restore the saved in-memory creds/session) before returning; alternatively set `session: null` so the network-recovery watcher re-establishes it.
|
||||
|
||||
- [x] **Account display name is never refreshed from the server (#900)** — `P2` — `missing` (changelog 1.9.0 "Refresh the account display name from the Stalwart principal on login, restore, and switch") — fixed in b52768e
|
||||
- What WEB does: seeds `displayName`/`email` from the primary identity at login, then `syncAccountDisplayName` reads `x:Account/get` (principal "Full name") on login, restore and switch and updates the registry (`stores/auth-store.ts:146-176, 741-751`; `lib/stalwart/principal.ts`).
|
||||
- What RN does: `addAccount({ displayName: username, email: username })` (`src/stores/auth-store.ts:123-131, 186-193`) and nothing ever updates it; the drawer header shows the raw username (`src/components/SidebarDrawer.tsx:297`). `fetchPrincipal` already exists (`src/api/account-security.ts:157-176`) but only feeds the security screen, and saving a new display name there does not touch the registry.
|
||||
- Fix hint: after each successful connect run `Identity/get` (name/email) and, when `urn:stalwart:jmap` is in the account's `accountCapabilities`, `fetchPrincipal()`; `accountStore.updateAccount(id, { displayName, email })`; also update on `updateDisplayName` success.
|
||||
|
||||
- [x] **OAuth account id/email derived from the raw JMAP username** — `P3` — `partial` — fixed in b52768e (email refreshed from the primary identity; registry id unchanged)
|
||||
- What WEB does: prefers the primary identity email over `Session.username` for OAuth/SSO because OIDC `preferred_username` may not be an address (`stores/auth-store.ts:946-951, 1096`).
|
||||
- What RN does: `connectWithOAuth` uses `session.username || accessToken.slice(0, 8)` (`src/api/jmap-client.ts:135-136`), so the same mailbox logged in via password and via OAuth can produce two registry entries, and the drawer synthesizes `username@host`.
|
||||
- Fix hint: read `Identity/get` after connect and use the primary identity email for `email` (and ideally for the registry id).
|
||||
|
||||
- [x] **Cannot remove a non-active account** — `P2` — `missing` (changelog 1.7.8 "Remove a specific account from the switcher") — done in 6ae21d5, 490355c
|
||||
- What WEB does: per-row remove button in the switcher (`components/layout/account-switcher.tsx:138-143, 322-333`) backed by `removeAccount` which tears down the client, evicts caches and clears cookies (`stores/auth-store.ts:1382-1401`).
|
||||
- What RN does: only "Sign out of <active>" and "Sign out all" (`src/components/SidebarDrawer.tsx:400-437`).
|
||||
- Fix hint: add a per-row remove (long-press or trailing X) that runs `teardownPushNotificationsForAccount(id)`, `jmapClient.clearAccountCredentials(id)`, `useEmailStore.removeAccount(id)`, `accountStore.removeAccount(id)`.
|
||||
|
||||
- [x] **Switcher shows stale connection state and no error indicator** — `P3` — `partial` — done in 6ae21d5 (the drawer shows `hasError` with its message)
|
||||
- What WEB does: shows `hasError` (alert icon) with `errorMessage` and a live `isConnected` dot per account (`account-switcher.tsx:296-303`).
|
||||
- What RN does: renders only the `isConnected` dot (`SidebarDrawer.tsx:355-361`); `hasError`/`errorMessage` (set in `restoreSession`) are never displayed, and since only the active account is ever connected, other rows show whatever value they had last.
|
||||
- Fix hint: render `hasError`; show the dot only for the active account (or treat inactive as "cached").
|
||||
|
||||
- [x] **Accounts list in Settings (reorder, set default, add) is missing** — fixed in 490355c — `P3` — `missing` (changelog 1.7.2 "List and reorder logged-in accounts from settings (#282)", 1.7.7 "Pin the default account on top and drag-to-reorder") — move up/down + set default + remove + add; account-store.reorderAccounts
|
||||
- What WEB does: `AccountSettings` lists all accounts with move up/down, drag reorder, set-default, add (`components/settings/account-settings.tsx:182-220`); switcher pins the default first (`lib/account-utils.ts:117-142`).
|
||||
- What RN does: `AccountSettings` shows only the active account's fields (`src/components/settings/AccountSettings.tsx`); the drawer lists accounts in registry order with set-default only (`SidebarDrawer.tsx:390-406`).
|
||||
- Fix hint: add an accounts section with set-default/remove/reorder (`accountStore` needs a `reorderAccounts`).
|
||||
|
||||
- [x] **AccountSettings reports the wrong auth method and never shows storage** — fixed in 490355c — `P3` — `rn-only-bug` — jmapClient.usesBearerAuth + Quota/get (src/api/quota.ts)
|
||||
- What WEB does: shows OAuth vs Basic from `authMode` and a quota bar (`account-settings.tsx:151-180`).
|
||||
- What RN does: `authMode = props.authMode ?? 'basic'` and `quotaUsed = props.quotaUsed ?? 0` (`AccountSettings.tsx:46-47`) but `SettingsScreen` renders `<Component />` with no props (`SettingsScreen.tsx:242`), so OAuth accounts read "Basic" and the storage row never appears (RN has no `Quota/get` at all — flag for the mail/settings audit).
|
||||
- Fix hint: derive from `jmapClient.usesBearerAuth` (or persist `authMode` on `AccountEntry`); fetch quota via `Quota/get` when `urn:ietf:params:jmap:quota` is advertised.
|
||||
|
||||
- [x] **Hard account cap of 5** — `P3` — `partial` — done in 6ae21d5 (cap raised to 10, `src/lib/account-utils.ts:4`)
|
||||
- What WEB does: 5 on HTTP/1.1, lifted to 50 when HTTP/2 is observed, because each web account pins an SSE socket (`lib/account-utils.ts:75-107`).
|
||||
- What RN does: `MAX_ACCOUNTS = 5` (`src/lib/account-utils.ts:1`) although only the active account holds a live connection.
|
||||
- Fix hint: raise the constant (or drop the cap) once the unified inbox cost per account is acceptable.
|
||||
|
||||
- [ ] **Shared/group account settings scope is missing** — `P3` — `missing` (changelog 1.7.5 "Manage shared/group account settings from the Accounts page") — partly: shared accounts are listed in AccountSettings (490355c), and tapping one scopes Filters and Vacation to it since a86cd1d; calendar and contacts settings are not scoped yet
|
||||
- What WEB does: lists `client.getSharedAccounts()` (non-primary) on the Accounts page and enters a scoped settings mode (filters, vacation, calendars, contacts) via `managed-account-store` (`components/settings/account-settings.tsx:43-48, 112-118, 225-262`; `stores/managed-account-store.ts`).
|
||||
- What RN does: `getSharedMailAccounts()` exists for mail/unified inbox (`src/api/jmap-client.ts:480-495`) but there is no shared-account listing or scoped settings.
|
||||
- Fix hint: list non-personal `session.accounts` in `AccountSettings` and pass an `accountId` into the filter/vacation stores.
|
||||
|
||||
- [x] **Unified-inbox shared-account filter disagrees with `getSharedMailAccounts`** — `P3` — `rn-only-bug` — done in 6ae21d5
|
||||
- What WEB does: a non-personal account counts as mail-capable even when its `accountCapabilities` omit mail (`lib/jmap/client.ts:4421-4445`); RN's own `getSharedMailAccounts` mirrors that (`jmap-client.ts:486-493`).
|
||||
- What RN does: the unified inbox requires `accountCapabilities == null || MAIL in accountCapabilities` (`src/api/unified-inbox.ts:208-210`), so a shared account that advertises other capabilities but not mail is silently skipped there while it appears in the sidebar.
|
||||
- Fix hint: reuse the `!advertisesMail && info.isPersonal → skip` rule from `getSharedMailAccounts`.
|
||||
|
||||
### Account security settings
|
||||
|
||||
- [x] **Account Security screen is dead against every Stalwart server (native issue #47)** — `P1` — `rn-only-bug` — fixed in 032732c
|
||||
- What WEB does: probes `client.hasAccountCapability('urn:stalwart:jmap')`, i.e. the *account-level* capability map (`stores/account-security-store.ts:276-290`, `lib/jmap/client.ts:4173-4177`, `lib/stalwart/principal.ts:33`).
|
||||
- What RN does: `isStalwartSupported()` checks the *session-level* `session.capabilities` (`src/api/account-security.ts:51-54`). Verified against Stalwart 0.16.19 (stw-test19): `urn:stalwart:jmap` is present only under `accounts[<id>].accountCapabilities`, not in the top-level `capabilities`. So `AccountSecuritySettings` always renders "Account security management requires a Stalwart server" (`src/components/settings/AccountSecuritySettings.tsx:584, 622`). The same effect also shows that copy when the session is merely offline (`:583`).
|
||||
- Fix hint: `const acc = session.accounts?.[jmapClient.accountId]; return !!acc?.accountCapabilities?.[STALWART_CAPABILITY] || STALWART_CAPABILITY in (session.capabilities ?? {})`; show an "offline" notice instead of the Stalwart notice when `currentSession` is null.
|
||||
|
||||
- [x] **Push subscriptions: no per-device list/revoke and no forced recreate (#841)** — fixed in f6f26f7 — `P2` — `missing` (changelog 1.9.0 "Per-device revoke for push subscriptions (#841)", "Recreate the push subscription on re-register (#841)")
|
||||
- What WEB does: lists relay devices with a "this device" marker and per-device revoke, and Re-register passes `forceRecreate` so a stale subscription is recreated rather than its expiry refreshed (`components/settings/notification-settings.tsx:95, 115-131, 169`).
|
||||
- What RN does: Enable / Re-register / Disable for this device only (`src/components/settings/NotificationSettings.tsx:81, 186-198`); no device list, and whether Re-register recreates the JMAP `PushSubscription` was not verified here (push audit).
|
||||
- Fix hint: call the relay device-list endpoint the web uses, render rows with revoke (`PushSubscription/set destroy` + relay unregister), and thread a `forceRecreate` flag through `setupPushNotifications`.
|
||||
|
||||
- [x] **Public keys (S/MIME/PGP) and encryption-at-rest configuration are missing** — fixed in ddae1e6 — `P3` — `missing` (changelog 1.8.1 "Manage S/MIME and PGP public keys and configure Stalwart encryption at rest from account security settings")
|
||||
- What WEB does: `PublicKeysSection` with `x:PublicKey/query|get|set` and `x:AccountSettings/set encryptionAtRest {@type, publicKey, encryptOnAppend, allowSpamTraining}` (`components/settings/account-security-settings.tsx:573-840`; `stores/account-security-store.ts:397-455, 625-700`).
|
||||
- What RN does: read-only `EncryptionSection` showing the `@type` only (`AccountSecuritySettings.tsx:539-555`; `src/api/account-security.ts:150-155`).
|
||||
- Fix hint: port the four store methods to `src/api/account-security.ts` and add a key list + encryption picker.
|
||||
|
||||
- [x] **OAuth `state` is generated with `Math.random`** — `P3` — `rn-only-bug` — fixed in 2c0dbd1
|
||||
- What WEB does: `crypto.getRandomValues` for verifier/state (`lib/oauth/pkce.ts:11-27`).
|
||||
- What RN does: `randomState()` uses `Math.random` (`src/lib/oauth.ts:41-46`) although a CSPRNG helper with `getRandomValues`/`randomUUID` fallbacks already exists in `src/lib/totp.ts:16-46`. The state is the only guard against a forged `bulwarkmobile://` redirect delivering foreign credentials.
|
||||
- Fix hint: export `randomBytes` from `totp.ts` (or a shared `random.ts`) and use it here.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows NOT to redo)
|
||||
- QR login payloads: WEB emits `bulwarkmail://pair?server=<webmailBase>&code=…` (`account-security-settings.tsx:971`); RN parses it plus a `connect` variant and bare URLs (`src/lib/oauth.ts:135-162`) and redeems at `/api/auth/pair/redeem` (`:165-207`) into the same OAuth bundle the browser handoff yields.
|
||||
- Webmail handoff (`mobile_redirect_uri`/`mobile_state`, fragment transport, state check, password/oauth flows) matches `login/page.tsx:115-130, 282, 638-650` and `auth/callback/page.tsx` mobile branch.
|
||||
- Token refresh: coalesced per refresh token (`src/lib/oauth.ts:210-262`, WEB `refreshPromises`); cached access token reused until 60 s before expiry (#552; `jmap-client.ts:195-209`); client id always carried in the bundle so #873 does not apply; rotated refresh tokens persisted.
|
||||
- Session restore: waits for AsyncStorage hydration, restores active → default account, keeps credentials and shows cached data on `NetworkError` (mirrors 1.7.6 "keep the session when the auth server is briefly unreachable"), evicts on definitive `AuthenticationError` (mirrors 1.6.0 "evict unrecoverable basic-auth accounts"), legacy single-slot migration.
|
||||
- Logout: revokes this account's push subscription first, clears only that account's caches, switches to default/next account or falls back to the login screen; "Sign out all" wipes everything.
|
||||
- Switch: email store is swapped before the network round-trip so the cached list shows immediately (same intent as 1.7.8 "eliminate the full-screen flash when switching accounts").
|
||||
- Shared/group discovery: `getSharedMailAccounts` applies the WEB rule (non-personal OR advertises mail); unified inbox namespaces by `sourceAccountId`/`jmapAccountId`.
|
||||
- Account security API (password change, display name, TOTP enrol/disable with `notUpdated` surfaced — 1.7.5 "Surface server errors on password change and TOTP toggle", app passwords, API keys with expiry/allowed IPs, principal/encryption reads, `Promise.allSettled` for forbidden principal reads) mirrors `stores/account-security-store.ts`; password/TOTP/display-name sections hidden for bearer sessions like WEB `isOAuth`.
|
||||
- Server discovery by email domain (`/.well-known/jmap` probe on bare/`mail.`/`webmail.` hosts, 401 counts as a hit, known servers trusted) is RN's equivalent of WEB's admin server list / auto-pick-by-domain (#799); neither side does `_jmap._tcp` SRV.
|
||||
- Login error → session-expired banner: RN's `ChooseStep` shows the store error ("Session expired") like WEB's `session_expired` banner.
|
||||
- `AuthenticationError` on 401, `RateLimitError` on 429 with Retry-After parsing in `request()`.
|
||||
|
||||
## N/A on mobile
|
||||
- "Remember me" (RN always stores credentials in the device keychain), `rememberMeEnabled`/`SESSION_SECRET` cookie logic, per-slot cookie handling (`cookieSlot`, `oauth_cookie_slot`), orphan-cookie adoption, `serverIdentifiers`/`classifySessionMatch` slot→token desync guard (RN keys credentials per registry id, no shared slot).
|
||||
- Server-configured login branding/notice (`LOGIN_SHOW_TOTP`, `LOGIN_SHOW_VERSION`, logos, imprint/privacy links, company name), OAuth-only mode / auto-SSO / iframe embedded SSO (#69), server picker dropdown (#799), custom JMAP endpoint toggle, demo mode, dev-mode login, CORS error classification.
|
||||
- Impersonation (`/api/auth/impersonate`, `%master` suffix stripping, impersonation reconciler), admin login.
|
||||
- TOTP re-auth dialog (`totp-reauth-store`/`totp-reauth-dialog`): only used by WEB's legacy `password$totp` basic-auth path for pre-0.16 Stalwart; once RN gains TOTP via the token exchange no re-auth is needed.
|
||||
- "Link device" QR generator (RN is the device being linked); TOTP enrolment QR image (same-device — RN's `otpauth://` deep link + manual secret is the right shape).
|
||||
- 30 s keep-alive ping / `connectionLost` state (RN uses NetInfo + `retrySession`), per-tab SSE budget (#702), settings-sync enable/disable on login (settings audit), Stalwart passthrough auth context (`/api/auth/stalwart-context`), plugin logout hooks.
|
||||
@@ -0,0 +1,310 @@
|
||||
# Mail list, mailboxes/folders, unified views, search, tags, batch actions
|
||||
|
||||
## Summary
|
||||
RN covers the core single-folder loop well (folder tree incl. shared/group accounts with owner-routed mutations, incremental Email/queryChanges refresh, offline seed, swipe actions with reveal mode, multi-select with archive/move/delete/tag and an undo snackbar WEB does not even have). The biggest gaps are: (1) tags are invisible in the list and there is no tag view/tag counts; (2) spam/not-spam only moves the message and never flips `$junk`/`$notjunk` (#850), and there is no not-spam in the list at all; (3) search is always folder-scoped (WEB defaults to all folders, #788) with no history/suggestions; (4) the unified inbox is a one-shot, inbox-only, cross-account-only screen with no actions, paging, counts, Unread/Starred/All-mail views or per-role views; (5) folder management is create/rename/delete only (no subfolders, move, reorder, empty folder, mark folder read); (6) several WEB bugfixes are still live in RN: #771 substring dedup hides folders, permanent delete has no confirmation, Sent/Drafts show sender instead of recipient, "Never" mark-as-read marks read instantly, pins are written as `$important` instead of `$pinned`. One P1 RN-only bug: opening a message from the unified inbox that is not in the active folder page renders another message's body in the pager.
|
||||
|
||||
## Findings
|
||||
|
||||
### Folder tree / mailboxes
|
||||
|
||||
- [x] **Folders whose name contains a system folder name disappear (#771)** — `P2` — `bugfix-parity` — fixed in 516c9c9
|
||||
- What WEB does: dedup only drops a root folder whose name is an *exact* (trimmed, case-insensitive) match of a role folder in the same account (`lib/utils.ts:270-333`, comment cites #771; changelog 1.9.0).
|
||||
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
|
||||
- Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`.
|
||||
|
||||
- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `localizeMailboxName(role, name, t)` maps inbox/sent/drafts/trash/archive/junk/important/flagged/all to translated labels (`lib/mailbox-label.ts:35`); used by sidebar (`components/layout/sidebar.tsx:520`), move menu, context menus.
|
||||
- What RN does: raw server name everywhere: drawer `src/components/SidebarDrawer.tsx:491`, list header `src/screens/EmailListScreen.tsx:637-641`, `MoveSheet.tsx:120`, `FolderSettings.tsx:26-29` (English-only pill).
|
||||
- Fix hint: add `src/lib/mailbox-label.ts` (port of WEB) using `useLocaleStore().t` keys `sidebar.mailboxes.*` (already present in `locales/*/common.json`), apply in the four places.
|
||||
|
||||
- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `showFolderTotalCount` setting (`stores/settings-store.ts`, `components/settings/layout-settings.tsx:236`) hides the `/ total` part in `SidebarRowCounts` (`components/layout/sidebar.tsx:207-209`).
|
||||
- What RN does: `RowCounts` always shows unread/total (`src/components/SidebarDrawer.tsx:68-79`).
|
||||
- Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`.
|
||||
|
||||
- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: "New subfolder" in the folder context menu and `subfolder_of` in settings (`components/layout/mailbox-context-menu.tsx:187-193`, `components/settings/folder-settings.tsx:408-423`); store `createMailbox(client, name, parentId, accountId)` (`stores/email-store.ts:3753`), incl. creating in a shared account.
|
||||
- What RN does: `FolderSettings.saveDraft` always calls `createMailbox({ name })` (`src/components/settings/FolderSettings.tsx:96`) although `createMailbox` in `src/api/email.ts:156` already accepts `parentId` and `accountIdOverride`.
|
||||
- Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`.
|
||||
|
||||
- [ ] **Move folder to another parent (#855) and reorder folders (sortOrder)** — `P3` — `missing` — partial in 6ae21d5: "Move under" picker + parent re-fetch done; deferred: sortOrder reorder UI (no drag-and-drop on mobile yet)
|
||||
- What WEB does: drag & drop in folder settings and sidebar reparents (`moveMailbox` `stores/email-store.ts:3898-3956`, refetches after reparent per #855) and reorders via `sortOrder` (`reorderMailboxes` `:3859`); folder-settings DnD plan `components/settings/folder-settings.tsx:241-262`.
|
||||
- What RN does: no UI; `updateMailbox` in `src/api/email.ts:185` accepts `parentId` but not `sortOrder`. Tree sorts by `sortOrder` already (`src/lib/mailbox-tree.ts:70-81`).
|
||||
- Fix hint: in the rename editor add a "Move under…" picker (own-account folders, excluding descendants) → `updateMailbox(id, { parentId })` then `fetchMailboxes()`; optionally up/down buttons writing `sortOrder`.
|
||||
|
||||
- [ ] **Assign/clear a folder role, custom folder icons, colorful-icon toggle** — `P3` — `missing` — partial in 6ae21d5: role picker + #288 role icons done; deferred: per-folder custom icons and the colorful-icon toggle (cosmetic)
|
||||
- What WEB does: role dropdown per folder (`components/settings/folder-settings.tsx:366-380`, store `setMailboxRole` `stores/email-store.ts:3830`), per-folder icon picker (`folder-settings.tsx:77`, `folderIcons` setting), `colorfulSidebarIcons` toggle (`layout-settings.tsx:222`). Distinct icons for shared/important/memos/scheduled/snoozed roles (#288, `components/layout/sidebar.tsx:131-160`).
|
||||
- What RN does: fixed role colours always on, `iconFor` knows only inbox/sent/drafts/trash/junk/archive/star (`src/components/SidebarDrawer.tsx:36-66`); no role assignment.
|
||||
- Fix hint: low priority; at least extend `iconFor` with the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings using `updateMailbox(id, { role })` (needs `role` added to the `changes` type).
|
||||
|
||||
- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
|
||||
- What WEB does: filters `role === 'scheduled'` nodes out of the own tree while the virtual row is rendered (`components/layout/sidebar.tsx:986-990`).
|
||||
- What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder.
|
||||
- Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`.
|
||||
|
||||
- [x] **Empty folder (Trash/Junk) — with #711 pagination** — `P2` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: "Empty folder" banner/button in Trash & Junk and in the folder context menu (`components/email/email-list.tsx:457-479`, `components/layout/mailbox-context-menu.tsx:213-219`); `client.emptyMailbox` loops `Email/query` + back-referenced `Email/set destroy` in batches of `min(500, maxObjectsInSet)` and never gates on `total` (`lib/jmap/client.ts:2288-2325`, changelog 1.8.1 #711); store zeroes counters (`stores/email-store.ts:3958`).
|
||||
- What RN does: nothing — the only way is select-all-on-page + delete, page by page.
|
||||
- Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`.
|
||||
|
||||
- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: folder context menu → `markMailboxAsRead` (paged `Email/query notKeyword $seen` + `Email/set`, `lib/jmap/client.ts:2327-2364`), "mark folder tree read" and "mark all folders read" with confirm (`components/mail/mail-app.tsx:2571-2645`, `client.markAllAsRead` `:2366`); store zeroes unread counters (`stores/email-store.ts:4003-4043`).
|
||||
- What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`).
|
||||
- Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`.
|
||||
|
||||
- [x] **Retry button after a failed first load does not retry the mailbox fetch** — `P2` — `rn-only-bug` — fixed in 3194b5e
|
||||
- What WEB does: retries mailbox fetch on first login for lazy provisioning (#217) and keeps the tree on later failures (#780).
|
||||
- What RN does: when `fetchMailboxes` fails with zero mailboxes it sets `error` (`src/stores/email-store.ts:610-612`); the list shows the error with a "Retry" that calls `refreshEmails()` (`src/screens/EmailListScreen.tsx:812`), which returns immediately because `currentMailboxId` is null (`email-store.ts:773`). The mount effect only re-runs when `mailboxes.length` changes, so the user is stuck until an app restart.
|
||||
- Fix hint: Retry should call `fetchMailboxes()` when `mailboxes.length === 0` (then the inbox-select effect kicks in); consider one automatic retry after ~2 s on first login.
|
||||
|
||||
- [x] **No coalescing of concurrent refreshes (maxConcurrentRequests / 429)** — `P3` — `partial` — fixed in f07e039
|
||||
- What WEB does: `coalesceRefresh` shares an in-flight `fetchMailboxes`/`refreshCurrentMailbox`/tag-count run and queues at most one re-run (`stores/email-store.ts:383-425`, #780).
|
||||
- What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss.
|
||||
- Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`.
|
||||
|
||||
- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: unread badge is a button that toggles `isUnread: true` on that folder (`components/layout/sidebar.tsx:570`, `components/mail/mail-app.tsx:2533-2567`).
|
||||
- What RN does: counts are static text (`SidebarDrawer.tsx:68-79`).
|
||||
- Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`.
|
||||
|
||||
- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: right-click on a shared account header offers "New folder" in that account (`components/layout/sidebar.tsx:handleSharedAccountContextMenu`, `mailbox-context-menu.tsx:119-147`; changelog 1.9.0 "route shared-folder management to the owner account").
|
||||
- What RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`).
|
||||
- Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`.
|
||||
|
||||
### Unified mailbox / cross-account views
|
||||
|
||||
- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in 6ae21d5; the drawer only shows the Unified section when it can hold more than one account's mail since af6290f (#843)
|
||||
- What WEB does: unified views stay inside the active account (own + shared/group folders) unless `unifiedCrossAccount` is on *and* the admin gate allows it (`components/mail/mail-app.tsx:465-500`, `stores/settings-store.ts:405-431`); the section is shown with one account as soon as `includeGroupInUnified && hasGroupInboxes` (`components/layout/sidebar.tsx:905-907`).
|
||||
- What RN does: "All inboxes" is shown only when `accounts.length > 1` (`src/components/SidebarDrawer.tsx:438-446`) and always spans every account (`src/api/unified-inbox.ts:236-257`). A single account with group inboxes gets no unified view even with "Include Group Inboxes" on (`ReadingSettings.tsx:257`). Admin policy gates do not exist in RN (see N/A).
|
||||
- Fix hint: show the row when `accounts.length > 1 || (includeGroupInUnified && mailboxes.some(m => m.isShared))`; add a `unifiedCrossAccount` toggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default for `includeGroupInUnified` is `true` (`settings-store.ts:639`) while RN defaults to `false` (`src/stores/settings-store.ts:235`).
|
||||
|
||||
- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: one row per role present in any account with summed unread/total (`lib/unified-mailbox.ts:295-320`, sidebar rows `components/layout/sidebar.tsx` "unified_*"), counts projected from live mailbox lists.
|
||||
- What RN does: inbox only (`src/api/unified-inbox.ts:149`), no counts on the "All inboxes" row.
|
||||
- Fix hint: after the per-account mailbox fetch, pick `role` instead of hard-coding inbox; sum `unreadEmails` of each account's inbox for the drawer badge.
|
||||
|
||||
- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `buildCrossFilter` (OR of included mailboxes AND `notKeyword $seen` / `hasKeyword $flagged`) fanned out per account (`lib/unified-mailbox.ts:338-492`); default = inbox + custom folders (`CROSS_EXCLUDED_ROLES`), narrowed by `allMailFolderIds[accountId]` chosen in Layout settings (`components/settings/layout-settings.tsx:291-340`); rows show the source folder chip (`components/email/thread-list-item.tsx:385`, `resolveSourceFolderName` `lib/unified-mailbox.ts:63`).
|
||||
- What RN does: none.
|
||||
- Fix hint: reuse `fetchInboxEmailsForJmapAccount` with a filter built like `buildCrossFilter` over `Mailbox/get` results minus excluded roles; add a `sourceFolder` label to `UnifiedEmail` rows; folder picker can come later.
|
||||
|
||||
- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`).
|
||||
- What RN does: fixed `perAccountLimit = 25`, no `position`, no `onEndReached` (`src/api/unified-inbox.ts:236-241`, `src/screens/UnifiedInboxScreen.tsx:175-182`).
|
||||
- Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges.
|
||||
|
||||
- [x] **Unified search** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: text + advanced filter fan-out in all unified/cross views (`lib/unified-mailbox.ts:177-207`, `455-492`; changelog 1.7.8).
|
||||
- What RN does: no search box on `UnifiedInboxScreen`.
|
||||
- Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query.
|
||||
|
||||
- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: every list action resolves the email's own client + owner account via `resolveEmailActionContext` (`stores/email-store.ts:581-640`), incl. batch actions grouped by `sourceAccountId` (`:2634-2660`, `:2740-2760`), archive into the owner's archive (`components/mail/mail-app.tsx:2141-2160`); changelog 1.7.5/1.7.7 "route counter/keyword updates to the email's own account in aggregate views".
|
||||
- What RN does: rows are plain `Pressable`s (`src/screens/UnifiedInboxScreen.tsx:95-138`); nothing but open.
|
||||
- Fix hint: wrap rows in `SwipeableRow`; because the JMAP client is single-account, actions on a *different registry account* need the detached `jmapPost` path (`unified-inbox.ts:116-131`) with `Email/set` against `email.jmapAccountId`; for the active account and its group accounts route through `setEmailKeywords(..., email.jmapAccountId)` / `moveEmail(...)` overrides. (`setEmailKeywords` is gone since 5041897; keyword changes now go through `patchKeywordsForEmails` in `src/api/email.ts` or the store's `setKeywordForEmails`.)
|
||||
|
||||
- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: aggregate views refresh through the fan-out on push (`refreshCurrentMailbox`, #791) and keep just-read/unstarred rows in the Unread/Starred views until re-opened (`retainedInViewIds`, `stores/email-store.ts:1007-1050`).
|
||||
- What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup.
|
||||
- Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics.
|
||||
|
||||
- [x] **Opening a unified-inbox message that is not in the active folder page shows another message's body** — `P1` — `rn-only-bug` — fixed in c8be383; the viewer's actions still hit the open folder's account and followed the live list until 5c7301b and 9c31de2 (audit B3, B5)
|
||||
- What WEB does: opens the clicked row object itself and fetches by source client/account (#847, `components/mail/mail-app.tsx:3111-3190`).
|
||||
- What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847).
|
||||
- Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account.
|
||||
|
||||
- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in 6ae21d5
|
||||
- What RN does: `fetchInboxForAccount` does `/.well-known/jmap` + `Mailbox/get` + `Email/query` + `Email/get` per account per load (`src/api/unified-inbox.ts:175-234`); the active account is also fetched detached instead of via the live client/snapshot.
|
||||
- Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account.
|
||||
|
||||
### List rendering and behaviour
|
||||
|
||||
- [x] **Sent/Drafts rows show the sender ("me") instead of the recipient (1.4.12)** — `P2` — `bugfix-parity` — fixed in 3194b5e
|
||||
- What WEB does: `showRecipient = role === 'sent' || role === 'drafts'` → uses `email.to[0]` for name/avatar (`components/email/thread-list-item.tsx:128-131`, `579-583`).
|
||||
- What RN does: `getSenderName` always reads `from` (`src/screens/EmailListScreen.tsx:31-37`, row `:80-81`, avatar `:118`).
|
||||
- Fix hint: pass `currentMailbox?.role` into `EmailRow` and pick `to[0] ?? from[0]` for sent/drafts (also in `UnifiedInboxScreen` if role views are added).
|
||||
|
||||
- [x] **Tags are not rendered on list rows (and no tag row tint)** — `P2` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: `TagBadge`s per row from `getEmailTagIds` (both `$label:` and legacy `$color:`), thread rows union all messages' tags, optional row tint by first tag (`components/email/thread-list-item.tsx:152-155`, `609-611`, `lib/thread-utils.ts:187-244`, `hooks/use-tag-display.ts`).
|
||||
- What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu.
|
||||
- Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired.
|
||||
|
||||
- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in 6ae21d5; the view and the badges cover shared accounts since 8d5bced (#1038), and the counts are no longer re-fetched on every drawer open since d097ae9
|
||||
- What WEB does: "Tags" sidebar section with unread/total per tag (`fetchTagCounts`, `stores/email-store.ts:1192`), visibility rules (`components/layout/sidebar.tsx:1002-1013`), `selectKeyword` → `getEmails(undefined, …, '$label:<id>')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8).
|
||||
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
|
||||
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
|
||||
|
||||
- [x] **Answered / forwarded status icons (1.4.8)** — `P3` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: `Reply`/`Forward` icons from `$answered`/`$forwarded` (`components/email/thread-list-item.tsx:120-121`, `396-412`).
|
||||
- What RN does: none (no reference to `$answered` anywhere in `src/`).
|
||||
- Fix hint: add the two icons next to the star/paperclip in `EmailRow`.
|
||||
|
||||
- [x] **Pin uses `$important` instead of `$pinned`; no pinned-first order or pin icon** — `P2` — `bug` — fixed in 3194b5e
|
||||
- What WEB does: pin toggles the `$pinned` keyword (`components/mail/mail-app.tsx:2274-2300`), rows show a `Pin` icon (`thread-list-item.tsx:119`), and the query puts `$pinned` first (`lib/jmap/client.ts:1433-1444`, `buildEmailSort` `lib/message-list-order.ts:165-195`), thread groups too (`lib/thread-utils.ts:82-100`).
|
||||
- What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa.
|
||||
- Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
|
||||
|
||||
- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in bea3fc4
|
||||
- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`).
|
||||
- What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else.
|
||||
- Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change.
|
||||
|
||||
- [x] **Threading: counts only within the loaded page, no Thread/get counts, no conversation open, representative message wrong in ascending sort** — `P2` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: groups by `threadId`, `emailCount` from `Thread/get` across folders (`fetchThreadEmailCounts` `stores/email-store.ts:3731-3751`, `lib/thread-utils.ts:14-72`), representative = latest email, mobile tap opens the whole conversation via `getThreadEmails` routed to the owner (#814, `components/mail/mail-app.tsx:3240-3275`), "collapse all threads".
|
||||
- What RN does: collapses same-`threadId` rows within the page and counts within the page (`src/screens/EmailListScreen.tsx:224-248`); keeps the *first* row encountered, i.e. the oldest when `mailSortAscending` is on; tapping opens only that single message (`EmailThreadScreen` pages over `emails`, no `Thread/get` anywhere).
|
||||
- Fix hint: `Thread/get` for the visible thread ids (chunked) to get real counts; pick the newest message as representative; a conversation screen belongs to the viewer area but the list should hand over `threadId` + owner account.
|
||||
|
||||
- [x] **Drafts do not open in the composer / no "Edit draft"** — `P2` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: a `$draft` message opens the composer (`handleEditDraft` `components/mail/mail-app.tsx:1812`, list `onEditDraft` `components/email/email-list.tsx:47,616`, context menu `components/email/email-context-menu.tsx:225-233`); viewer shows a draft banner.
|
||||
- What RN does: tapping a draft opens the read-only `EmailThreadScreen`; `Compose` params have no draft/edit mode (`src/navigation/types.ts:7-24`), locale keys `email_viewer.draft_banner`/`edit_draft` exist but are unused.
|
||||
- Fix hint: in `handleRowPress`, if `item.keywords.$draft` (or folder role is drafts) navigate to `Compose` with a `draft: { id, to, cc, bcc, subject, body, blobId }` param; on send, destroy the old draft (composer area).
|
||||
|
||||
- [x] **Permanent delete without confirmation (Trash, `deleteAction: permanent`, junk auto-permanent)** — `P2` — `bugfix-parity` — list side fixed in 3194b5e (single, batch, swipe via `src/lib/delete-confirm.ts`); viewer side done in d2ed27f (`src/screens/EmailThreadScreen.tsx`)
|
||||
- What WEB does: confirm dialog before any permanent destroy, single and batch (`components/mail/mail-app.tsx:2086-2100`, `components/email/email-list.tsx:229-247`).
|
||||
- What RN does: `deleteEmail`/`deleteEmailsBatch` destroy immediately when in Trash, when `deleteAction === 'permanent'`, or junk + `permanentlyDeleteJunk` (`src/stores/email-store.ts:1286-1298`, `1449-1458`); the swipe fires without any prompt (`EmailListScreen.tsx:369-378`), and no undo is offered (correctly, `:1322-1324`).
|
||||
- Fix hint: compute `destroy` in the screen before calling the store (same rule) and show `Alert.alert` with Cancel/Delete; same for the thread screen `onDelete`.
|
||||
|
||||
- [x] **"Never" mark-as-read (`markAsReadDelay === -1`) marks read instantly** — `P2` — `rn-only-bug` — fixed in d2ed27f
|
||||
- What WEB does: `-1` → never, `0` → instant, else timer (`components/mail/mail-app.tsx:1511-1528`).
|
||||
- What RN does: `if (markAsReadDelay > 0) { timer } else { markRead() }` (`src/screens/EmailThreadScreen.tsx:236-245`) so the "Never" option in `ReadingSettings.tsx:171` behaves like "Instant".
|
||||
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
|
||||
|
||||
- [x] **Load-more appends duplicates when new mail shifts positions** — `P3` — `rn-only-bug` — fixed in f07e039
|
||||
- What WEB does: filters `newEmails` by existing ids before appending (`stores/email-store.ts:1620-1625`).
|
||||
- What RN does: `merged = [...emails, ...newEmails]` with no dedup (`src/stores/email-store.ts:714-716`); a message that arrived between pages appears twice (duplicate FlatList keys, double rows).
|
||||
- Fix hint: `newEmails.filter(e => !existingIds.has(e.id))`.
|
||||
|
||||
- [x] **Incremental-refresh window "cap" is a no-op** — `P3` — `rn-only-bug` — fixed in f07e039
|
||||
- What RN does: `out.slice(0, Math.max(limit, out.length))` (`src/stores/email-store.ts:885`) never trims although the comment says it caps the window to the page size; harmless but misleading (the snapshot can grow past `emailsPerPage`).
|
||||
- Fix hint: either drop the line and comment, or use `Math.max(limit, baseEmails.length)` if the intent is "at least the previous window".
|
||||
|
||||
- [x] **Swipe "spam" available in Sent/Drafts and no "not spam" in Junk** — `P3` — `rn-only-bug` — fixed in 3194b5e
|
||||
- What WEB does: spam action hidden for sent/drafts/scheduled and flips to "not spam" inside Junk (`components/email/thread-list-item.tsx:523`, `components/email/email-hover-actions.tsx:117-140`, changelog 1.7.7).
|
||||
- What RN does: `handleSwipeAction 'spam'` only checks `currentMailboxId !== junkMailboxId` (`src/screens/EmailListScreen.tsx:379-388`), so a swipe in Sent moves your own mail to Junk; in Junk the swipe silently does nothing.
|
||||
- Fix hint: skip when `currentMailbox.role` is sent/drafts; in junk call an `undoSpam` (see next item) and change the band label to "Not spam".
|
||||
|
||||
- [x] **Spam / not-spam do not flip `$junk`/`$notjunk` (#850) nor honour "trash-and-read"** — `P2` — `bugfix-parity` — fixed in 3194b5e; the viewer's Spam and Not spam stayed a plain move until 5e42c67 (#695)
|
||||
- What WEB does: `markAsSpam` patches `mailboxIds` + `keywords/$junk: true` + `keywords/$notjunk: null` (+ `$seen` when `deleteAction === 'trash-and-read'`); `undoSpam` restores the original mailbox with `$junk: null`, `$notjunk: true` (`lib/jmap/client.ts:2422-2475`, store `stores/email-store.ts:2947-3100`, undo toast in `components/mail/mail-app.tsx:2228-2270`).
|
||||
- What RN does: swipe and thread-screen spam are plain `moveToMailbox` calls (`src/screens/EmailListScreen.tsx:381`, `src/screens/EmailThreadScreen.tsx:344-356`); the keywords stay untouched, so other clients/Stalwart's classifier never learn; undo label reads "Email moved to Junk".
|
||||
- Fix hint: add `markAsSpam(ids, junkRawId, accountId, alsoMarkRead)` / `undoSpam(ids, targetRawId, accountId)` to `src/api/email.ts` writing the keyword pointers; store actions with `pendingUndo.kind = 'spam'` (type already exists, `src/stores/email-store.ts:155`) whose `undoLast` also restores the keywords; queue-safe via an outbox `keywords` op.
|
||||
|
||||
- [x] **Batch spam / batch not-spam** — `P3` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: `batchMarkAsSpam`/`batchUndoSpam` (`stores/email-store.ts:3104-3236`), "Not spam" button in the Junk selection toolbar (`components/email/email-list.tsx:396-411`, changelog 1.7.8).
|
||||
- What RN does: selection header has star/read/tag/move/archive/delete only (`src/screens/EmailListScreen.tsx:572-630`).
|
||||
- Fix hint: add a shield button that calls the batch spam/undo-spam from the previous item depending on `currentMailbox.role === 'junk'`.
|
||||
|
||||
- [x] **Selecting a collapsed thread row selects/acts on only its newest message** — `P2` — `bug` — fixed in 3194b5e
|
||||
- What WEB does: the thread checkbox toggles every message of the thread (`toggleThreadSelection`, `components/email/thread-list-item.tsx:640-660`); single delete/archive on a thread row uses `moveThreadToMailbox` for archive (`components/mail/mail-app.tsx:2141-2180`).
|
||||
- What RN does: `selectedIds` holds the representative id only (`src/screens/EmailListScreen.tsx:336-352`, `436-442`), so "3 selected" may be 3 conversations but batch delete/move/archive/tag touch one message each (`deleteEmailsBatch` filters `emails` by id, `email-store.ts:1443`); swipe actions on a thread row likewise act on one message.
|
||||
- Fix hint: when threading is on, expand each selected representative to all `emails` with the same `threadId` before calling the batch store actions (and for swipe archive/delete/move on rows with `threadCount > 1`).
|
||||
|
||||
- [x] **Keep search/filters when switching folders (#553)** — `P3` — `bugfix-parity` — fixed in f07e039
|
||||
- What WEB does: re-runs the active text/advanced search in the newly selected folder (`components/mail/mail-app.tsx:2501-2512`).
|
||||
- What RN does: `selectMailbox` resets `searchQuery` and `filters` (`src/stores/email-store.ts:676-677`) and the list input follows (`EmailListScreen.tsx:504-511`).
|
||||
- Fix hint: keep `searchQuery`/`filters` in `selectMailbox` (seed the base snapshot only when they are empty) so the search re-runs in the new folder; offer "Clear" as today.
|
||||
|
||||
- [x] **Just-read mail dropped from an open Unread-filtered list on the next push refresh** — `P3` — `bugfix-parity` — fixed in f07e039
|
||||
- What WEB does: retains rows the user just read/unstarred in the Unread/Starred views until the view is re-opened (`stores/email-store.ts:1007-1050`, `mergeRetainedRows`; changelog 1.9.0).
|
||||
- What RN does: `markRead` patches the row in place, but the next `refreshEmails` (push or pull) re-runs the `notKeyword $seen` query (`src/stores/email-store.ts:279`, `917-944`) and the row vanishes while the user is looking at it.
|
||||
- Fix hint: keep a `retainedIds` set per filter session; after a full re-query, splice retained rows (from the previous `emails`) back at their old index.
|
||||
|
||||
- [x] **Search runs on every keystroke** — `P3` — `rn-only-bug` — fixed in 3194b5e
|
||||
- What WEB does: searches on submit / suggestion pick (`components/search/search-box.tsx:108-115`).
|
||||
- What RN does: 300 ms debounce into `setSearchQuery` → full `Email/query` + `Email/get` per pause (`src/screens/EmailListScreen.tsx:512-516`); with the `*` wildcard a single letter matches the whole mailbox.
|
||||
- Fix hint: search on `onSubmitEditing` (keep the clear button live), or debounce ≥ 600 ms with a minimum of 2 characters.
|
||||
|
||||
- [ ] **Date format: no regional date-locale setting; relative strings hard-coded** — `P3` — `partial` — partly: 0205aa0 localized the relative strings; there is still no `dateLocale` setting (see area 08, "Language list not localized")
|
||||
- What WEB does: user-selectable regional format (`dateLocale`, changelog 1.7.7) and a preset picker (#331); strings localized.
|
||||
- What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`).
|
||||
- Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted.
|
||||
|
||||
- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in bea3fc4; contact photos win over favicons and initials since eca3871 (#54)
|
||||
- What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`).
|
||||
- What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup.
|
||||
- Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional.
|
||||
|
||||
- [x] **Per-message actions from the list (mobile long-press menu)** — `P3` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: long-press on mobile opens the context menu (reply/forward/move/tag/pin/spam/mark read/copy link) (`components/email/thread-list-item.tsx:181-190`, `components/email/email-context-menu.tsx`).
|
||||
- What RN does: long-press enters selection (`EmailListScreen.tsx:98,425`); single-message move/tag/spam need a swipe (two configurable directions) or opening the message.
|
||||
- Fix hint: optional — selection mode already exposes most actions; add "spam" to the selection header (see above) and this is close enough.
|
||||
|
||||
- [x] **Own → shared/group folder move (cross-account move, 1.7.2)** — `P3` — `missing` — fixed in 0205aa0
|
||||
- What WEB does: copy+delete via `crossAccountMoveEmails`, drop into shared mailboxes allowed (`stores/email-store.ts:1980-2026`, `2169`).
|
||||
- What RN does: refuses with "Messages can only be moved within the same account" (`src/stores/email-store.ts:1183-1186`, `1396-1399`); `MoveSheet` is scoped to the same account so the message is only reachable by a future picker. `MoveSheet` also allows Drafts as a target (`MoveSheet.tsx:102-103`) which WEB excludes (`email-context-menu.tsx:163`).
|
||||
- Fix hint: exclude `role === 'drafts'` targets; cross-account move = `Email/get` blob → `Blob/upload` to the owner → `Email/import` → destroy (import helper already exists in `src/api/email.ts:423`).
|
||||
|
||||
- [x] **Undo snackbar / toast strings not localized; move toast lacks folder path** — `P3` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: toasts localized, full folder path in move toast (changelog 1.5.0).
|
||||
- What RN does: hard-coded English labels in the store (`src/stores/email-store.ts:1202, 1252, 1328, 1379, 1425, 1520`), "UNDO" (`UndoSnackbar.tsx:73`), list empty/loading strings (`EmailListScreen.tsx:805-830`), filter modal labels, `MoveSheet`/`TagSheet` titles.
|
||||
- Fix hint: route through `useLocaleStore().t` (keys already exist for many: `email_list.*`, `notifications.*`).
|
||||
|
||||
### Search
|
||||
|
||||
- [x] **Search folder scope: always the current folder; no all-folders default (#788) and no folder picker** — `P2` — `bugfix-parity` — fixed in 3194b5e; "All folders" only searched the open folder's account until ee50418 (#1082)
|
||||
- What WEB does: `searchMailboxId` defaults to `""` = all folders, is a separate, persisted choice in the filter panel (`stores/email-store.ts:95-101`, `2344-2378`, changelog 1.9.0 #788); cross-mailbox queries.
|
||||
- What RN does: `buildJmapFilter` always starts with `{ inMailbox: current }` (`src/stores/email-store.ts:255`); the filter modal has no folder field.
|
||||
- Fix hint: add `folder?: string | 'all'` to `EmailFilters` (default `'all'` while a text query or filter is active, i.e. omit `inMailbox`); expose a folder select in the modal; the base-view snapshot logic already keys off `isBaseView`.
|
||||
|
||||
- [x] **Body filter, per-chip removal** — `P3` — `partial` — fixed in 3194b5e
|
||||
- What WEB does: `body` condition (`lib/jmap/search-utils.ts:62-64`); each chip has an X (`components/search/search-chips.tsx:260-266`).
|
||||
- What RN does: no `body` field; chips are read-only, only "Clear" all (`src/screens/EmailListScreen.tsx:725-796`).
|
||||
- Fix hint: add `body` to `EmailFilters`/`buildJmapFilter`; make each chip's X call `setFilterField(key, undefined)`.
|
||||
|
||||
- [x] **Search suggestions and recent-search history (#845)** — `P3` — `missing` — fixed in 3194b5e
|
||||
- What WEB does: recent searches (max 10, persisted) + contact/sender matches under the search box, keyboard navigable, pick a contact → `from:`/`to:` filter (`stores/search-history-store.ts`, `lib/search-suggestions.ts:183`, `components/search/search-box.tsx:34`, `components/mail/mail-app.tsx:2807-2826`).
|
||||
- What RN does: plain `TextInput` (`EmailListScreen.tsx:677-698`).
|
||||
- Fix hint: small zustand store persisted in AsyncStorage; render a dropdown under the input when focused (recent + `useContactsStore` matches); tapping a contact sets `filters.from`.
|
||||
|
||||
### Tags / keywords
|
||||
|
||||
- [ ] **Tag definition features: visibility, nesting/parent, reorder, rename with migration, unknown tags** — `P3` — `partial` — partial in 3194b5e: unknown `$label:` ids listed/removable in TagSheet; deferred: visibility, nesting, reorder, rename migration (device-local tag model, see native issue #1)
|
||||
- What WEB does: `visibility: show|unread|hide` (`stores/settings-store.ts:180-190`, `components/settings/keyword-settings.tsx:98-132`), nested ids `parent/child` (`lib/keyword-nesting.ts`, `keyword-settings.tsx:213-243`), drag reorder (atomic, changelog 1.9.0), rename keyword migrates messages (`renameKeyword`/`client.migrateKeyword` `lib/jmap/client.ts:1963`), tag picker lists unknown ids present on a message so they can be removed (`components/email/tag-picker.tsx:484-491`).
|
||||
- What RN does: `{ id, label, color }` only (`src/stores/keywords-store.ts:9-13`); `TagSheet` lists defined tags only (`src/components/TagSheet.tsx:76-95`), so a `$label:work/clients` set by WEB can neither be seen nor removed on RN. Tag definitions are device-local (see native issue #1).
|
||||
- Fix hint: show unknown `$label:*` ids found on the selected emails at the bottom of `TagSheet`; add `visibility` when the drawer tag section lands; nesting/reorder are lower priority.
|
||||
|
||||
- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in 6ae21d5
|
||||
- What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes).
|
||||
- What RN does: `KeywordSettings.tsx:94-97` → `resetDefaults()` immediately (`src/stores/keywords-store.ts:85-88`).
|
||||
- Fix hint: remove the button or guard with `Alert.alert` confirm.
|
||||
|
||||
- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in 6ae21d5
|
||||
- What WEB does: `discoverKeywords` pages `Email/query`+`Email/get keywords` and proposes label/colour for unknown `$label:`/`$color:` ids (`lib/jmap/client.ts:1616-1676`, `lib/keyword-discovery.ts:140`, `components/settings/keyword-settings.tsx:374-490`).
|
||||
- What RN does: none; after a reinstall all tags must be re-typed with the exact id.
|
||||
- Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`.
|
||||
|
||||
- [x] **Keyword palette parity** — `P3` — `partial` — fixed in 6ae21d5
|
||||
- What WEB does: `KEYWORD_PALETTE` keys (`stores/settings-store.ts:226-280`) are the tag ids of the default tags and the colour names used by `suggestKeywordColor`.
|
||||
- What RN does: `colors.tags` keys in `src/theme/tokens.ts`; defaults are `important/work/personal/todo` (`src/stores/keywords-store.ts:15-20`) whereas WEB's `DEFAULT_KEYWORDS` (`settings-store.ts:281`) are colour-named. A message tagged on one client with a default tag shows as unknown on the other.
|
||||
- Fix hint: verify the key sets match; consider aligning the default ids with WEB's.
|
||||
|
||||
### Offline cache (RN only, sanity check)
|
||||
|
||||
- [x] **Offline sync covers the primary account only; shared folders never cached** — `P3` — `rn-only-bug` — fixed in 0205aa0
|
||||
- What RN does: `runOfflineSync` discovers via `queryEmailsByFilter` which hard-codes `jmapClient.accountId` (`src/lib/offline-sync.ts:55`, `src/api/email.ts:733-747`), so opening a group-folder message offline always fails; `selectMailbox` seeding for a shared folder therefore always yields nothing (`src/stores/email-store.ts:647-666` looks up by raw id, which is correct).
|
||||
- Fix hint: iterate `jmapClient.getSharedMailAccounts()` in the sync with `accountIdOverride`, storing the account id in the cache index (`getFullEmails(ids, accountId)` already exists).
|
||||
|
||||
## Verified at parity (do not redo)
|
||||
- Folder tree: nesting, per-account grouping of shared/group accounts with unread roll-up, expanded state persisted, role priority sort (`src/lib/mailbox-tree.ts`, `SidebarDrawer.tsx`); create/rename/delete own folders with server error surfaced (`FolderSettings.tsx`); delete-with-emails confirm.
|
||||
- Shared/group folders: queries and every mutation (read/star/pin/tag/move/archive/delete/undo/import) routed to the owner account with the unprefixed id (`refFor`, `src/stores/email-store.ts:64-99`); thread screen fetches by owner (`EmailThreadScreen.tsx:90-96`); state-change handling per account (`email-store.ts:981-1015`).
|
||||
- Folder tree preserved when a refresh fails (#780 equivalent, `email-store.ts:604-612`), shared-account failure isolated (`:595-601`); Mailbox/changes incremental sync with drain.
|
||||
- Missing Archive/Trash/Junk → error alert (`EmailListScreen.tsx:363-387`), never silently destroys when Trash is missing (#195 equivalent).
|
||||
- Delete semantics: trash / trash-and-read (#323) / permanent / junk auto-permanent, single and batch (`email-store.ts:1268-1335`, `1434-1527`); undo snackbar for archive/delete/move (single + batch) — RN is ahead of WEB here.
|
||||
- Archive single / by year / by month with folder creation in one request, batch archive, "Reorganize existing archive" (`src/api/email.ts:579-692`, `ReadingSettings.tsx:105-159`).
|
||||
- Swipe actions: configurable per direction, instant and reveal modes, pure gesture module with tests, stale-props fix (`SwipeableRow.tsx`, `swipe-gesture.ts`); more actions than WEB (pin, move). RTL: ar, he and fa ship since 990cd84, but swipe directions are not mirrored yet (area 08, RTL finding).
|
||||
- Multi-select: long-press, select-all-visible/indeterminate, batch star/read/tag/move/archive/delete (`EmailListScreen.tsx:320-500`).
|
||||
- Pagination via `onEndReached`, pull-to-refresh, incremental `Email/queryChanges` + `Email/changes` refresh with per-account state tokens, base-view restore after search (#10), sort change invalidates snapshots (#5), offline seed and fallback.
|
||||
- Search: full-text with wildcard suffix (`src/lib/search-utils.ts` = WEB `toWildcardQuery`), from/to/subject/date/attachment/unread/starred tri-state filters, chips row, search inside a shared folder routed to its owner.
|
||||
- Keyword writes send RFC 6901-escaped `keywords/<name>` patch pointers since 5041897 (`src/api/patch-pointer.ts`; the whole-map writes erased other keywords, audit B4), with `null` instead of `false` — not affected by 5c484af1 / be97c8bf; tag ids normalized exactly like WEB `normalizeKeywordLevel` (`KeywordSettings.tsx:118-123`); batch tag toggle via `TagSheet` with "all have it" semantics.
|
||||
- Unread dot (#27), date formats smart/relative/full with 12/24h, preview toggle, density-aware rows, sender favicon avatars with failure cache, `.eml`/`.zip` import into the open folder, Scheduled quick view, "Include group inboxes" setting and shared badge/account dot in the unified list.
|
||||
- Mark-as-read delay (0/3s/5s) in the thread screen; both clients still display `receivedAt` in the list (WEB `lib/email-date.ts` is not wired into the list yet).
|
||||
|
||||
## N/A on mobile
|
||||
- Category tabs / message-list tabs (`stores/message-list-tabs-store.ts`) — plugin-registered; RN has no plugin runtime.
|
||||
- Hover quick actions, keyboard navigation/shortcuts, right-click context menus, drag-and-drop of mail onto folders/tags and folder-tab drag (Pro) — desktop interactions; RN has swipe + selection instead.
|
||||
- Admin policy gates for unified/cross views (`usePolicyStore` feature flags) — RN has no admin policy channel; only the user-side toggles apply.
|
||||
- Return-to-list-after-action setting — the RN thread screen always returns after delete/archive/spam.
|
||||
- "Refresh" toolbar button and F5/Ctrl+R interception — pull-to-refresh covers it.
|
||||
- Favicon unread badge (its native counterpart, the app-icon badge, landed in 25c0883, iOS only), sidebar collapse/resize, Pro multi-pane shell, a folder subscription UI (neither client shows `isSubscribed`; both subscribe the folders they create, native since 30aea63, #951).
|
||||
- Plugin search hooks / external search results, `onEmailsFetched` transforms.
|
||||
- Source-folder chip on rows is N/A *until* All-mail/cross views exist in RN.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Email viewer / thread view / message rendering / attachments
|
||||
|
||||
## Summary
|
||||
RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pinch zoom, external-content gate with trusted senders, cid inlining, attachment open/share/save with templated filenames, view source, .eml export, calendar-invitation banner, shared-folder routing for body/attachments/source) but it is a *single-message* viewer, not a thread view, and it never fetches `headers`/`messageId`/`inReplyTo`/`references`, so everything the WEB viewer derives from headers (SPF/DKIM/DMARC, List-Unsubscribe, read receipts, Reply-To, identity badge, correct `In-Reply-To`) is absent or wrong. The biggest gaps: (1) older messages of a thread are unreachable and the pager shows the wrong message when opened from the unified inbox / contact activity; (2) replies write the JMAP id into `In-Reply-To`, ignore `Reply-To`, do not strip the user's own address on reply-all, and forwards drop every attachment; (3) HTML-only messages (same part in `textBody` and `htmlBody`) are rendered and quoted as raw HTML source, and `<style>` blocks are stripped wholesale, which is the most likely root of native issues #46 ("email preview shows css") and #49 ("Unreadable text in dark-mode emails"); (4) external-content blocking only recognises `<img src=http…>`, `background=` and inline `url()`, so srcset/`<source>`/poster/whitespace-obfuscated pixels still load.
|
||||
|
||||
## Findings
|
||||
|
||||
### Thread view / navigation
|
||||
|
||||
- [x] **Thread messages other than the newest are unreachable — fixed in d2ed27f** — `P1` — `missing`
|
||||
- What WEB does: opening a thread renders every message of the thread as expandable cards (newest + all unread auto-expanded), each with its own body, attachments, reply/forward and mark-read-on-expand (`components/email/thread-conversation-view.tsx:98-115`, `171-200`, store `fetchThreadEmails` via `Thread/get` + `Email/get` in `stores/email-store.ts:3574-3640`, `lib/jmap/client.ts:2763-2800`).
|
||||
- What RN does: the list collapses same-thread rows to the newest message with a count badge (`src/screens/EmailListScreen.tsx:224-238`, default `disableThreading: false` at `src/stores/settings-store.ts:266`), the row opens `EmailThreadScreen` with a single `emailId` (`App.tsx:145-151`), and the screen only ever shows that one message (`src/screens/EmailThreadScreen.tsx:113-120`, `685-840`). `getThread` exists in `src/api/email.ts:448` but is only used by a test. Older messages of a thread cannot be opened at all unless the user turns on "Disable Thread Grouping".
|
||||
- Fix hint: on open, call `getThread(threadId, ownerAccountId)` + `getFullEmails(ids)` and render a vertical list of collapsible message cards inside the pane (newest + unread expanded), reusing `EmailBodyView` per card; keep the horizontal pager for thread-to-thread navigation.
|
||||
|
||||
- [x] **Pager shows the wrong message (or nothing) when the opened email is not in the store list — fixed in c8be383** — `P1` — `rn-only-bug` — the body was right after c8be383, but delete, archive, move and spam hit the open folder's account, and new mail moved the page, until 5c7301b and 9c31de2 (audit B3, B5)
|
||||
- What WEB does: the viewer renders `selectedEmail` directly; prev/next are derived from the current list (`components/email/email-viewer.tsx:5220-5279`).
|
||||
- What RN does: the pager is a `FlatList` over `useEmailStore(s => s.emails)` and the initial index is `Math.max(0, emails.findIndex(id))` (`src/screens/EmailThreadScreen.tsx:128-133`, `535-572`). `UnifiedInboxScreen` keeps its own local list and never writes the store (`src/screens/UnifiedInboxScreen.tsx:35-50`, `61-84`), `ContactActivity` navigates with ids from a contact search (`src/components/contacts/ContactActivity.tsx:170-177`). In both cases the opened id is absent from `emails`, so the visible pane is `emails[0]` (a different mailbox's first message, or blank when the list is empty) while the toolbar/mark-read/delete act on `activeEmailId`.
|
||||
- Fix hint: when `route.params.emailId` is not in `emails`, render a single-item pager (`data=[{id: emailId}]`) and disable prev/next; or have the unified inbox / contact activity pass their own id list through route params.
|
||||
|
||||
- [x] **Mark-as-read "Never" (-1) marks the message read instantly — fixed in d2ed27f** — `P2` — `rn-only-bug`
|
||||
- What WEB does: `-1` means never auto-mark, `0` instant, `>0` delayed (`components/email/email-viewer.tsx:1218-1238`).
|
||||
- What RN does: `ReadingSettings` offers `Never = -1` (`src/components/settings/ReadingSettings.tsx:167-173`) but the viewer does `if (markAsReadDelay > 0) {timer} else { markRead() }` (`src/screens/EmailThreadScreen.tsx:237-245`), so `-1` behaves like instant.
|
||||
- Fix hint: add `if (markAsReadDelay === -1) return;` before the branch.
|
||||
|
||||
- [x] **Skeleton / loading parity — verified, no action** — `P3` — `verified` (see "Verified at parity"); no action.
|
||||
|
||||
### Body rendering & sanitisation
|
||||
|
||||
- [x] **HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css" — fixed in 06742ef** — `P1` — `bug`
|
||||
- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in `textBody` and `htmlBody`; WEB checks `hasDistinctTextBody` (different partIds) before preferring the text alternative, and routes by the part's `type` (`components/email/email-viewer.tsx:1727-1747`, `components/email/thread-conversation-view.tsx:332-346`; changelog 1.6.3 "Render HTML-only emails", "Render plain-text-only emails as text, not HTML").
|
||||
- What RN does: `extractTextBody` returns the html part's value for HTML-only mail, then `rawHtml = html && (!text || hasMeaningfulHtmlBody(html)) ? html : null` (`src/components/EmailBodyView.tsx:43-57`, `504-511`). For any HTML-only message whose markup fails `MEANINGFUL_HTML_RE` (`src/lib/email-html.ts:353-362`, e.g. a `<div>`/`<p>` body without `style=`), `rawHtml` is null and the raw source — tags, `<head>`, CSS — is shown as plain text. The same value feeds the reply quote: `plainTextBody()` (`src/screens/EmailThreadScreen.tsx:59-65`, `377`) so replies to HTML-only mail quote escaped HTML source.
|
||||
- Fix hint: compare `textBody[0].partId !== htmlBody[0].partId` (and `part.type === 'text/html'`) before treating `text` as an alternative; in `navigateCompose` pass `htmlBody` (and let `buildInitialHtml` use it) or convert with `htmlToPlainText` (`src/lib/compose-html.ts:108`) when the only body is HTML.
|
||||
|
||||
- [x] **`<style>` blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails" — fixed in 06742ef** — `P2` — `bugfix-parity`
|
||||
- What WEB does: iframe sanitiser keeps `<style>` (`lib/email-sanitization.ts:60-78`), and when external content is blocked it neutralises `url()`/`@import`/`@font-face` inside the sheet (`stripExternalStyleSheetCss`, `lib/email-sanitization.ts:474-509`, changelog 1.7.8 #457) while the strict iframe CSP is the network backstop (`components/email/email-viewer.tsx:2265-2267`).
|
||||
- What RN does: `FORBID_TAGS` includes `'style'` (and `svg`) so every stylesheet is removed before render (`src/lib/email-html.ts:16-28`, `50-68`). Class-based colours vanish: a newsletter with white text via `.hero { color:#fff }` on a `bgcolor` cell renders white-on-white (light) or, after the invert filter, black-on-black. Also `hasNativeDarkMode` is evaluated on the *stripped* HTML inside `wrapEmailHtml` (`src/lib/email-html.ts:226-229`) but on the raw HTML in the component (`src/components/EmailBodyView.tsx:612-613`), so for emails with `@media (prefers-color-scheme: dark)` the CSS inversion is applied but the DOM re-invert pass is skipped.
|
||||
- Fix hint: keep `<style>` (the WebView is an isolated document; CSP already has `style-src 'unsafe-inline'`), port `stripExternalStyleSheetCss` + `decodeCssEscapes` for the blocked case, and evaluate `hasNativeDarkMode` once on the same input.
|
||||
|
||||
- [x] **Dark-mode inversion CSS is an older revision of WEB's — fixed in d2ed27f** — `P2` — `bugfix-parity`
|
||||
- What WEB does (1.7.4 "Correct dark-mode background-image inversion and height clipping", 1.6.3 emoji/nested bgcolor, 1.7.7 `height:100%` wrappers): background-*image* containers are re-inverted unconditionally and their media get `filter:none`; only bgcolor/`background:` containers use the `:not(:has(media))` guard; nested guard is limited to colour containers; `[style*="height:100%"]` is neutralised; Word/Outlook HTML gets `line-height:1.15` + `MsoNormal` margins and a fallback gutter; `messageSpacing` auto/edge gutter (`components/email/email-viewer.tsx:2200-2253`, `2287-2305`, `2438-2461`).
|
||||
- What RN does: `DARK_INVERSION` applies the `:not(:has(...))` guard to background-image containers too and includes them in the nested `filter:none` rule (`src/lib/email-html.ts:167-185`), the DOM pass only re-inverts containers without media children (`src/components/EmailBodyView.tsx:72-88`); no Word/Outlook padding, no per-message light/dark toggle (WEB toolbar Sun/Moon, `email-viewer.tsx:3199-3212`, changelog 1.6.0), no `messageSpacing`.
|
||||
- Fix hint: copy the current `darkModeCSS` block and the DOM re-invert loop verbatim into `email-html.ts` / `DARK_REINVERT_SCRIPT`; add a "View in light/dark mode" entry to the More sheet that overrides `renderAsDark` for the current message.
|
||||
|
||||
- [x] **External-content detection misses most tracking vectors (privacy leak) — fixed in 06742ef** — `P2` — `bugfix-parity`
|
||||
- What WEB does: `isExternalResourceUrl` strips C0/space so `"\nhttps://x"` and `h\ttps://` count; blocks `<img srcset>`, `<picture><source>`, `<video poster>`, media `src`, `background=`, inline `url()` incl. CSS escapes, `<style>` url()/@import, and forbids `<link>` in blocking mode; the banner is driven by "something was actually blocked" (`lib/email-sanitization.ts:399-406`, `511-615`, `108-150`; changelog 1.7.8).
|
||||
- What RN does: `hasRemoteContent` only matches `<img src="http…">`, `background="http…"` and `style="...url(http…)"` (`src/lib/email-html.ts:364-369`); when it returns false `shouldBlock` is false and the CSP becomes `img-src data: cid: https: http:` (`src/lib/email-html.ts:235`), so a pixel expressed as `srcset=`, `<source>`, `poster=`, `src=" https://…"` (leading whitespace/newline), or a CSS-escaped `\68ttps://` loads unconditionally under policy `ask`/`block`. `blockRemoteImageSrcs` (`316-347`) is likewise src/srcset/background/style only, and has no `data-blocked-*` restore.
|
||||
- Fix hint: make the gate policy-driven rather than detection-driven (block = strict CSP `img-src data: cid:` + `media-src`/`font-src` none whenever policy is `block`/`ask`-not-allowed and sender untrusted, like WEB's `externalBlocked`), port `isExternalResourceUrl` normalisation, add `source/video/audio/poster/link` handling, and show the banner when the strict CSP is in effect and the HTML contains any `http(s)://` resource reference.
|
||||
|
||||
- [x] **Blocked images leave broken-image icons / alt text; empty containers not collapsed — fixed in 06742ef** — `P3` — `bugfix-parity`
|
||||
- What WEB does: swaps `src` for a transparent 1x1 GIF, empties `alt`, `display:none`, and collapses empty `<td>/<div>` wrappers (`lib/email-sanitization.ts:319-320`, `537-546`, `678-704`); hides images that fail to load (`components/email/email-viewer.tsx:2393-2411`, changelog 1.7.7).
|
||||
- What RN does: sets `src=""` and `alt="[remote image blocked]"` (`src/lib/email-html.ts:319-323`), so blocked newsletters show rows of broken-image glyphs with English alt text; no failed-image hiding.
|
||||
- Fix hint: use the same transparent-GIF + `display:none` swap and port `collapseBlockedImageContainers` as a regex/DOM pass in the injected script; add an `img` `error` listener that hides the element.
|
||||
|
||||
- [x] **mailto: links inside the body open the OS mail handler instead of the app composer — fixed in 06742ef** — `P2` — `missing`
|
||||
- What WEB does: `mailto:` opens the built-in composer with parsed to/subject/body (changelog 1.8.1 "Open mailto: links in the built-in composer"; iframe click handler leaves `mailto:` to the app's protocol handling, `components/email/email-viewer.tsx:2420-2436`).
|
||||
- What RN does: `onShouldStartLoadWithRequest` hands `mailto:` to `Linking.openURL` (`src/components/EmailBodyView.tsx:715-724`), so tapping an address in an email opens Gmail/Apple Mail instead of Bulwark.
|
||||
- Fix hint: parse `mailto:` (address, `subject`, `body`, `cc`) and `navigation.navigate('Compose', { prefillTo, ... })`; the `prefillTo` param already exists (`src/screens/ComposeScreen.tsx:292-298`).
|
||||
|
||||
- [x] **`data:` URL navigations replace the email body — fixed in 06742ef** — `P3` — `rn-only-bug`
|
||||
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:717-719`), and `safeUri` keeps `href="data:image/…"` (`src/lib/email-html.ts:109-113`); tapping such a link navigates the WebView to the image with no way back. Only the initial `about:blank` load should be allowed.
|
||||
- Fix hint: allow `data:` only when `request.navigationType === 'other' && !request.isTopFrame`-style initial loads, otherwise return false (optionally open images externally).
|
||||
|
||||
- [x] **Regex sanitiser + `script-src 'unsafe-inline'`: keep the data-URI/SVG allowlist in sync with WEB — fixed in 06742ef** — `P3` — `partial`
|
||||
- What WEB does: allows only raster `data:image/(png|jpe?g|gif|webp|bmp|avif|x-icon|vnd.microsoft.icon)` on media tags, re-checks `srcset` candidates individually, forbids SVG data URIs and `<svg>` (`lib/email-sanitization.ts:24`, `330-388`), and the iframe has no scripting at all.
|
||||
- What RN does: `safeUri` allows any `data:image/*` including `image/svg+xml` (`src/lib/email-html.ts:109-113`), `srcset` values are never scheme-checked except when blocking (`326-329`), and the page CSP must allow `'unsafe-inline'` scripts for the injected bridge (`236-245`), so a parser-differential bypass of the regex stripper would execute script in the WebView (limited blast radius: the bridge only accepts height/swipe/zoom strings).
|
||||
- Fix hint: restrict `data:` to the raster list, scheme-check every `srcset` candidate, and consider parsing with a real HTML parser (e.g. `htmlparser2`/`parse5` on the JS side) instead of regexes; document the residual risk.
|
||||
|
||||
- [x] **Quoted reply text is not collapsed (#480) — fixed in 06742ef** — `P3` — `missing`
|
||||
- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed `<blockquote>`, `>`-lines in plain text) behind a "•••" toggle (`lib/quote-collapse.ts:143-197`, `219-259`; changelog 1.7.8).
|
||||
- What RN does: nothing; long threads show every quoted original inline.
|
||||
- Fix hint: port `setupQuoteCollapse` into the injected WebView script (runs on the parsed DOM, uses inline styles only) and `collapsePlainTextQuotes` into `wrapPlainTextEmail`.
|
||||
|
||||
- [x] **Plain-text bodies are always monospace (#830) — fixed in 06742ef** — `P3` — `bugfix-parity`
|
||||
- What WEB does: renders text/plain in the app font by default with a `plainTextFont: 'sans' | 'mono'` setting (`stores/settings-store.ts:329`, `565`; `components/email/email-viewer.tsx:5050-5060`; changelog 1.9.0).
|
||||
- What RN does: `wrapPlainTextEmail` hard-codes `ui-monospace, Menlo, …` (`src/lib/email-html.ts:290`); no setting (`src/stores/settings-store.ts` has no `plainTextFont`).
|
||||
- Fix hint: add the setting (Reading settings) and switch the font-family in `wrapPlainTextEmail`.
|
||||
|
||||
- [x] **Plain-text linkifier only catches http(s) — verified, no action** — `P3` — `verified` (same as WEB `plainTextToSafeHtml`); no action.
|
||||
|
||||
- [x] **cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified — fixed in 06742ef** — `P3` — `partial`
|
||||
- What WEB does: fetches every cid part as an authenticated `blob:` URL regardless of size/type; the browser sniffs `application/octet-stream` image bytes (`components/email/email-viewer.tsx:1509-1575`; changelog 1.8.1 #543).
|
||||
- What RN does: skips parts over `MAX_INLINE_IMAGE_BYTES` (`src/components/EmailBodyView.tsx:15`, `556-559`) and builds `data:${att.type || 'application/octet-stream'};base64,…` (`573`), which Chromium/WebKit may refuse to decode as an image for `application/octet-stream`.
|
||||
- Fix hint: sniff magic bytes (PNG/JPEG/GIF/WebP) to pick the MIME for the data URI; for >2 MB parts encode off the JS thread (e.g. `File.base64()` from expo-file-system after `downloadInto`) instead of dropping them.
|
||||
|
||||
### Header / meta
|
||||
|
||||
- [x] **`headers`, `messageId`, `inReplyTo`, `references` are never fetched — fixed in ccbe67c** — `P2` — `missing` (root cause for the next five items)
|
||||
- What WEB does: `Email/get` requests `messageId, inReplyTo, references, headers, bodyStructure` and parses `Authentication-Results`, `X-Spam-*`, `X-Spam-LLM` into `email.authenticationResults/spamScore/spamLLM` (`lib/jmap/client.ts:1775-1870`).
|
||||
- What RN does: `EMAIL_FULL_PROPERTIES` stops at `bodyStructure, textBody, htmlBody, bodyValues, attachments, blobId, bcc, replyTo, sentAt` (`src/api/email.ts:11-15`); the `Email` type has no `headers`/`messageId` (`src/api/types.ts:65-87`).
|
||||
- Fix hint: add the four properties to `EMAIL_FULL_PROPERTIES`, extend the type, and port `parseAuthenticationResults`/`parseSpamScore`/`extractListHeaders` (`lib/email-headers.ts`) to `src/lib/email-headers.ts`.
|
||||
|
||||
- [x] **No "Show details" panel: CC/BCC/Reply-To/sent vs received/size/MIME/Message-ID/thread id — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: header shows To/CC/BCC (first 2 + "+N", "me" substitution), a "Show details" toggle with recipients & routing, delivery delta, identifiers/threading, message properties, mailing-list section (`components/email/email-viewer.tsx:3892-3944`, `4187-4542`).
|
||||
- What RN does: one line `to <names>` (`src/screens/EmailThreadScreen.tsx:806-809`); CC/BCC/Reply-To are fetched but never shown; date/size only (`811-817`).
|
||||
- Fix hint: add CC/BCC rows and a collapsible details block under the sender row; reuse `formatSize`.
|
||||
|
||||
- [x] **SPF/DKIM/DMARC/spam-score chips and spoof-aware identity badge — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: auth chips with severity-ranked SPF (HELO vs MAIL FROM, #650), DMARC policy, iprev, spam score/LLM verdict (`components/email/email-viewer.tsx:4353-4438`, `lib/email-headers.ts:13-115`); "via <identity>" / sub-address tag badge hidden when spoofed (`components/email/email-identity-badge.tsx:33-68`, changelog 1.7.3).
|
||||
- What RN does: none.
|
||||
- Fix hint: after the headers finding, render chips in the details block; port `isAuthenticationSpoofed` for the badge.
|
||||
|
||||
- [x] **List-Unsubscribe (RFC 2369) banner — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: parses `List-Unsubscribe` (http preferred, else mailto), confirm dialog, sends the mailto unsubscribe through the account itself, remembers dismissals per Message-ID (`components/email/unsubscribe-banner.tsx:64-106`, `components/email/email-viewer.tsx:753-773`, `2672-2680`; changelog 1.7.7).
|
||||
- What RN does: none.
|
||||
- Fix hint: port `parseUnsubscribeUrls`/`isValidUnsubscribeUrl` from `lib/validation.ts`; for mailto call `sendEmail` with the parsed fields; open http via `expo-web-browser`.
|
||||
|
||||
- [x] **Read receipts (MDN, RFC 8098): request banner, send, "always/ask/never", `$mdnsent` — fixed in d2ed27f** — `P2` — `missing` — the receipt headers were not sanitized (GHSA-w38p) and "Always send" answered the unseen neighbour pages until cb6ddd6 and 5f3f584 (audit S1, B7)
|
||||
- What WEB does: detects `Disposition-Notification-To`, offers Send/Ignore (or auto-sends in "always" mode) only in received folders, builds the multipart/report with `lib/mdn.ts`, uploads → `Email/import` into Sent → `EmailSubmission/set` with explicit envelope, then sets `$mdnsent` (`components/email/email-viewer.tsx:2686-2784`, `lib/jmap/client.ts:4080-4130`, `components/email/read-receipt-banner.tsx`).
|
||||
- What RN does: none; no `readReceiptResponse` setting.
|
||||
- Fix hint: port `lib/mdn.ts` verbatim (pure string builder), reuse `uploadBytes` (`src/api/blob.ts:67`) + `importEmailBlob` (`src/api/email.ts:423`) + an `EmailSubmission/set` helper; add the setting.
|
||||
|
||||
- [x] **Reply-To shown / used — fixed in 7f956d6** — folded into the reply-recipients finding below.
|
||||
|
||||
- [x] **Sender / recipient tap actions (popover, view/add contact, copy address) — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: every address is a `RecipientPopover` (contact lookup, extra emails/phones, copy, email, view/add contact); tapping the avatar opens the contact sidebar or, on mobile, the contact page (`components/email/recipient-popover.tsx`, `components/email/email-viewer.tsx:1140-1170`, `3841-3866`).
|
||||
- What RN does: names are static `Text` (`src/screens/EmailThreadScreen.tsx:794-810`).
|
||||
- Fix hint: wrap sender/recipients in `Pressable` opening a small action sheet (copy, compose to, open `ContactDetail`/`ContactForm` prefilled).
|
||||
|
||||
- [x] **Displayed date uses `receivedAt` and device locale, ignoring the app's time-format setting — fixed in d2ed27f** — `P3` — `bugfix-parity`
|
||||
- What WEB does: `emailDisplayDate` prefers `sentAt` (falls back when missing or >24 h in the future, #891) and formats with the user's `timeFormat`/locale (`lib/email-date.ts:33-45`, `components/email/email-viewer.tsx:3811`).
|
||||
- What RN does: `formatHeaderDate(email.receivedAt)` with `toLocaleDateString(undefined, …)` (`src/screens/EmailThreadScreen.tsx:39-50`, `812-816`), so imported/migrated mail shows the import date and 12/24 h ignores the setting.
|
||||
- Fix hint: port `emailDisplayDate` (already in `src/lib/date-format.ts`?—if not, 15 lines) and use `useSettingsStore(s => s.timeFormat)` + locale from `useLocaleStore`.
|
||||
|
||||
- [x] **Tags, `$important` badge and answered/forwarded state in the header — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: tag badges (removable) under the subject, "Important" pill (`components/email/email-viewer.tsx:3789-3806`); `$answered`/`$forwarded` set after send (`components/mail/mail-app.tsx:1663-1667`).
|
||||
- What RN does: Tag sheet exists but no badges are rendered in the pane; `$answered`/`$forwarded` are never set after a reply/forward (no hit for `$answered` in `src/`).
|
||||
- Fix hint: render `keywordDefs` matches from `email.keywords` under the subject; after a successful send in `ComposeScreen` call `setEmailKeywords(originalId, {...keywords, $answered|$forwarded: true}, accountId)`. (`setEmailKeywords` is gone since 5041897: set `keywords/$answered` or `keywords/$forwarded` through `patchKeywordsForEmails` or the store's `setKeywordForEmails`.)
|
||||
|
||||
### Calendar invitation banner
|
||||
|
||||
- [x] **Invitation parsing is not routed to the owning account (#847, #867) — fixed in 8206893** — `P2` — `bugfix-parity`
|
||||
- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (`components/email/calendar-invitation-banner.tsx:373-388`, `412-432`; changelog 1.9.0).
|
||||
- What RN does: `parseCalendarBlob(attachment.blobId)` uses `jmapClient.accountId` (`src/components/email/CalendarInvitationBanner.tsx:54`, `src/api/calendar.ts:355-360`) even though `EmailPane` knows `jmapAccountId`; Stalwart answers `CalendarEvent/parse` with an error for a blob in another account, so invitations in group/shared mailboxes show nothing (state `error` → banner hidden).
|
||||
- Fix hint: pass `jmapAccountId` from `EmailPane` into the banner and through to `parseCalendarBlob(blobId, accountId)`.
|
||||
|
||||
- [x] **iMIP method detection ignores the raw ICS `METHOD` and Content-Type params — fixed in 8206893** — `P2` — `partial`
|
||||
- What WEB does: reads `method=` from the part/attachment/header Content-Type, then fetches the raw ICS and reads `METHOD:` (`lib/calendar-invitation.ts:319-339`, `components/email/calendar-invitation-banner.tsx:419-447`), falling back to participant heuristics; `CANCEL`, `REPLY`, `COUNTER`, `REFRESH`, `DECLINECOUNTER`, `PUBLISH`, `ADD` each get their own title/info/actions.
|
||||
- What RN does: `extractMethodFromRawIcs` exists (`src/lib/calendar-invitation.ts:103-106`) but is never called; only `inferInvitationMethod` heuristics are used (`CalendarInvitationBanner.tsx:62`), so a cancellation whose event status is not `cancelled`, or a REPLY from a single attendee, is shown as a plain invitation with RSVP buttons.
|
||||
- Fix hint: fetch the blob text (`getDownloadUrl` + `secureFetch`, as `fetchRawEmail` does) in parallel with parse and prefer `extractMethodFromRawIcs`.
|
||||
|
||||
- [ ] **Banner lacks WEB's trust assessment, actor summary, existing-event / "already in calendar" state, calendar picker, "View in calendar", collapse, sequence badge, counter-proposal review** — `P3` — `partial` — deferred: trust assessment, iTIP method and calendar picker landed in 8206893 (calendar agent); the existing-event state followed in c0582ab, which looks the event up on the server by `uid` (also so Accept sends the RSVP, audit B22); "View in calendar", collapse, sequence badge and counter-proposal review remain open in the calendar area
|
||||
- What WEB does: `getInvitationTrustAssessment` (sender vs organizer + auth results) warning, actor line ("X accepted"), `queryCalendarEvents({uid})` to show "already in calendar" and current RSVP, picker when >1 calendar, "View in calendar" navigation, collapsible card, `sequence` "updated" pill, apply counter proposal for organizers (`components/email/calendar-invitation-banner.tsx:392-474`, `502-553`, `657-672`, `721-752`, `802-1140`).
|
||||
- What RN does: title/date/location/video/organizer rows, Yes/Maybe/No, Add to first writable calendar (`src/components/email/CalendarInvitationBanner.tsx:83-207`); no dedupe check before offering "Add", no picker, no trust warning, no navigation to the event.
|
||||
- Fix hint: port `getInvitationTrustAssessment` (needs the headers finding for auth), look up `useCalendarStore.events` by `uid` to switch to "already in calendar"/current response, add a calendar picker sheet when `calendars.length > 1`, and a "View in calendar" button that navigates to `CalendarScreen` with the start date.
|
||||
|
||||
- [x] **The `.ics` part stays in the attachment list while the banner is shown — fixed in d2ed27f** — `P3` — `bugfix-parity`
|
||||
- What WEB does: hides calendar MIME parts when the banner renders (`components/email/email-viewer.tsx:1591-1600`; changelog 1.4.14).
|
||||
- What RN does: `renderAttachments` filters only inline images (`src/screens/EmailThreadScreen.tsx:713-722`).
|
||||
- Fix hint: filter `isCalendarType(att.type) || name.endsWith('.ics')` when `calendarInvitationParsingEnabled && findCalendarAttachment(email)`.
|
||||
|
||||
### Attachments
|
||||
|
||||
- [x] **Forwarding drops every attachment — fixed in 7f956d6** — `P1` — `bug`
|
||||
- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (`components/email/email-composer.tsx:598-616`, #543).
|
||||
- What RN does: `navigateCompose('forward')` passes only from/to/cc/subject/plain body (`src/screens/EmailThreadScreen.tsx:366-382`); the `Compose.replyTo` param has no `attachments` field (`src/navigation/types.ts:7-19`) and `ComposeScreen` never seeds `attachments` from the original (`src/screens/ComposeScreen.tsx:354`). A forwarded invoice arrives without the PDF.
|
||||
- Fix hint: add `attachments?: Attachment[]` to the route param, and in `ComposeScreen` initialise `attachments` with `{blobId,name,type,size}` entries (blobs are account-scoped so no re-upload is needed; for shared-folder messages the blob belongs to the owner account — either re-upload via `getDownloadUrl(..., ownerAccountId)` + `uploadBytes`, or block forward there).
|
||||
|
||||
- [x] **message/rfc822 attachments: no inline unwrap, no `.eml` preview — fixed in d2ed27f** — `P2` — `missing`
|
||||
- What WEB does: when the outer body is empty (Outlook "forward as attachment") it parses the embedded message with postal-mime and renders its body + attachments, keeping the `.eml` chip; `.eml` attachments preview like an email (`components/email/email-viewer.tsx:1433-1507`, `1625-1636`; changelog 1.7.3, 1.8.1).
|
||||
- What RN does: the message renders "(empty message)" and the `.eml` chip can only be shared.
|
||||
- Fix hint: add `postal-mime` (pure JS, works in RN with a `TextDecoder` polyfill) and mirror the unwrap effect; render the parsed HTML through `EmailBodyView`.
|
||||
|
||||
- [x] **TNEF `winmail.dat` is not decoded — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: parses winmail.dat (body + embedded attachments) and hides the container (`lib/tnef.ts`, `components/email/email-viewer.tsx:1344-1431`).
|
||||
- What RN does: shows `winmail.dat` as an opaque attachment; the real files inside are unreachable.
|
||||
- Fix hint: `lib/tnef.ts` is pure `Uint8Array` code; copy it and feed `jmapClient.fetchBlobArrayBuffer`.
|
||||
|
||||
- [x] **Attachment chip list: no MIME-based fallback names, MDN/DSN report parts and non-inline cid images mishandled — fixed in d2ed27f** — `P3` — `partial` — body-embedded `cid:` parts typed octet-stream are hidden from the chips since 1d1d6a5
|
||||
- What WEB does: unnamed parts get `Document.pdf`/`Email.eml`/`Attachment.<sub>` (`components/email/email-viewer.tsx:191-225`); `message/disposition-notification` and `message/delivery-status` parts are hidden (`1604-1606`); inline hiding requires `disposition === 'inline'` (`1601-1603`).
|
||||
- What RN does: unnamed → `'attachment'` (`src/screens/EmailThreadScreen.tsx:745`); report parts are listed; any `cid` image is hidden regardless of disposition (`719-721`), so an image attached with a Content-ID but `disposition: attachment` (common from some clients) disappears from the list even when the body does not reference it.
|
||||
- Fix hint: port `getAttachmentDisplayName`, add the two report-type filters, and check `disposition === 'inline'` (or that the body actually references the cid via `extractCidRefs`).
|
||||
|
||||
- [x] **No image thumbnails on chips, no "Download all" zip, no per-chip Preview vs Download choice — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: image chips show the actual image (≤10 MB), a "Download all" bundles into a zip named by template, hover reveals separate Download/Preview buttons (`components/email/email-viewer.tsx:2024-2102`, `2104-2161`, `3949-4085`; changelog 1.6.0, 1.7.5).
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
- [x] **Post-export action (archive/trash after "Export .eml") missing — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: `postExportAction` setting runs archive/trash after export (`components/email/email-viewer.tsx:2559-2571`).
|
||||
- What RN does: export only (`src/screens/EmailThreadScreen.tsx:632-640`).
|
||||
- Fix hint: add the setting and call `onArchive`/`onDelete` after `shareEmailEml` resolves.
|
||||
|
||||
### Actions from the viewer
|
||||
|
||||
- [x] **`In-Reply-To`/`References` are written with the JMAP email id (#234 bugfix-parity) — fixed in 7f956d6** — `P1` — `bug`
|
||||
- What WEB does: passes the original's RFC `messageId` and `references`, stripped of brackets, as JMAP `inReplyTo`/`references` arrays (`lib/jmap/client.ts:3124-3194`; changelog 1.5.4 #234).
|
||||
- What RN does: `navigateCompose` sets `inReplyTo: email.id` (`src/screens/EmailThreadScreen.tsx:379`), and `sendEmail` writes it verbatim into `header:In-Reply-To:asText` / `header:References:asText` (`src/api/email.ts:823-826`). The outgoing header is a bare Stalwart object id, not a msg-id, so recipients' clients cannot thread the reply and `References` is never accumulated. `messageId`/`references` are not even fetched (see headers finding).
|
||||
- Fix hint: fetch `messageId`/`references`, pass `inReplyTo: email.messageId?.[0]` and `references: [...(email.references ?? []), email.messageId[0]]`, and send them as JMAP `inReplyTo`/`references` arrays (or `<…>`-wrapped `header:…:asMessageIds`).
|
||||
|
||||
- [x] **Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0) — fixed in 7f956d6** — `P1` — `bugfix-parity`
|
||||
- What WEB does: `buildReplyRecipients` — reply goes to `Reply-To` if present else `From`; reply-all adds original To/CC minus the user's own identities (exact and `+tag`-stripped); replying to your own message continues to its original recipients; an external Reply-To on a self-sent message still wins (`lib/reply-recipients.ts:74-111`, `components/mail/mail-app.tsx:920`, `2940-2944`).
|
||||
- What RN does: `initialTo` = From (+ all original To on reply-all, including the user's own address), `initialCc` = all CC; `email.replyTo` is fetched but never passed (`src/screens/ComposeScreen.tsx:299-319`, `src/screens/EmailThreadScreen.tsx:366-382`). Replying to a newsletter with `Reply-To: support@…` mails the no-reply sender; reply-all sends the user a copy; replying to your own sent message addresses yourself.
|
||||
- Fix hint: copy `lib/reply-recipients.ts` (pure), pass `replyToAddresses: email.replyTo` in the route param and the identities' emails as `ownEmails`.
|
||||
|
||||
- [x] **Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543) — fixed in 7f956d6** — `P2` — `bugfix-parity`
|
||||
- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with `Name <email>` (`components/email/email-composer.tsx:462-500`, `lib/quote-header.ts:54-91`; changelog 1.7.1, 1.7.3, 1.8.1).
|
||||
- What RN does: `buildInitialHtml` supports `htmlBody` (`src/lib/compose-html.ts:79-83`) but the viewer only passes `body: plainTextBody(email)` (`src/screens/EmailThreadScreen.tsx:377`), and the header uses display name only (`compose-html.ts:31-35`) rather than WEB's `Name <email>` (#482).
|
||||
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`, cid refs rewritten to fetched data URIs or re-attached with the same `cid`) and switch `senderName` to the `Name <email>` form.
|
||||
|
||||
- [x] **Forward as attachment (.eml) missing — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: More menu "Forward as attachment" builds a `message/rfc822` attachment referencing the original blobId with a `{date}-{subject}.eml` name (`lib/forward-as-attachment.ts:38-58`, `components/mail/mail-app.tsx:2022-2060`; changelog 1.8.1).
|
||||
- What RN does: none.
|
||||
- Fix hint: add a More-sheet entry that opens `Compose` with `attachments: [{blobId: email.blobId, name, type: 'message/rfc822', size: email.size}]` (depends on the forward-attachments fix).
|
||||
|
||||
- [x] **Reply identity selection: exact match only, no `+tag` or catch-all domain override (#246) — fixed in 5b72c4d** — `P3` — `partial`
|
||||
- What WEB does: `resolveReplyFrom` — exact, then `+tag`-stripped, then same-domain catch-all with a From override (`lib/reply-identity.ts:197-256`).
|
||||
- What RN does: `identities.find(i => i.email === candidate.email)` (`src/screens/ComposeScreen.tsx:431-437`).
|
||||
- Fix hint: port `resolveReplyFrom`; the override needs a From override in `sendEmail`.
|
||||
|
||||
- [x] **Quick reply box under the message — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: inline textarea with Send / "More options" (opens composer prefilled), Ctrl+Enter (`components/email/email-viewer.tsx:5066-5154`).
|
||||
- What RN does: none.
|
||||
- Fix hint: optional; a bottom input that calls `sendEmail` with `buildReplyRecipients` output and the plain-text quote.
|
||||
|
||||
- [x] **View source: no copy-to-clipboard; whole raw message loaded into a `<Text>` — fixed in d2ed27f** — `P3` — `partial`
|
||||
- What WEB does: synthetic source from the JMAP object with a Copy button (`components/email/email-viewer.tsx:5159-5208`, `lib/email-source.ts`). RN's raw RFC 822 view is actually more faithful.
|
||||
- What RN does: fetches the full blob as text and renders it in one selectable `Text` (`src/screens/EmailSourceScreen.tsx:24-35`, `68-70`); a 30 MB message will stall the JS thread.
|
||||
- Fix hint: add `Clipboard.setStringAsync`; cap display at e.g. 1 MB with a "Share full source" fallback.
|
||||
|
||||
- [x] **Add sender to contacts / edit contact from the viewer — fixed in d2ed27f** — `P3` — `missing`
|
||||
- What WEB does: contact sidebar with "Add to contacts"/"Edit" (`components/email/email-viewer.tsx:552-568`, `5336-5368`; changelog 1.7.4).
|
||||
- What RN does: none (see popover finding).
|
||||
- Fix hint: sheet entry navigating to `ContactForm` with prefilled name/email.
|
||||
|
||||
### Trusted senders / settings
|
||||
|
||||
- [x] **"Trust sender" writes to the Trusted Senders address book even when the setting is off — fixed in 06742ef** — `P3` — `rn-only-bug`
|
||||
- What WEB does: writes to the book only when `trustedSendersAddressBook` is on, else to the local list (`components/email/email-viewer.tsx:4646-4655`, `components/trusted-senders-modal.tsx:136-140`).
|
||||
- What RN does: always does both (`src/components/EmailBodyView.tsx:644-653`), and `addToTrustedSendersBook` creates the book on first add (`src/stores/contacts-store.ts:308-321`), so a user who left the setting off (default `false`, `src/stores/settings-store.ts:239`) still gets a server-side "Trusted Senders" address book; the settings modal then lists only the local entries (`src/components/settings/ContentSendersSettings.tsx:19-21`, `123-144`), so book-trusted senders are invisible/unremovable there.
|
||||
- Fix hint: gate the book write on the setting (or, matching WEB 1.9.0, default the setting to on for contacts-capable accounts) and show `trustedSenderEmails` in the settings modal with remove support.
|
||||
|
||||
- [x] **S/MIME settings screen is a dead stub — fixed in 3d0d440** — `P3` — `rn-only-bug`
|
||||
- What WEB does: built-in S/MIME was removed from core in 1.7.6 and lives in a privileged plugin; the viewer has no S/MIME state (`components/email/email-viewer.tsx:923-937`).
|
||||
- What RN does: `SmimeSettings` renders empty mock arrays and buttons with no handlers (`src/components/settings/SmimeSettings.tsx:32-33`, `118-121`, `163-166`); there is no verification/decryption in the viewer.
|
||||
- Fix hint: hide the screen (or show a "not available in the mobile app" note) until a crypto path exists; treat viewer-side S/MIME as N/A.
|
||||
|
||||
## Verified at parity (brief list, so the fixer knows what NOT to redo)
|
||||
- Body isolation: sandboxed WebView with `default-src 'none'` CSP, `originWhitelist` about:blank, links opened externally, no cookies/storage/file access (`src/components/EmailBodyView.tsx:675-724`, `src/lib/email-html.ts:236-256`) — matches WEB's srcDoc iframe + CSP approach (1.6.7).
|
||||
- `hasMeaningfulHtmlBody` text-alternative preference (`src/lib/email-html.ts:353-362`) — same regex as WEB `lib/signature-utils.ts` (only the same-partId guard is missing, see P1 finding).
|
||||
- Dark mode: invert+hue-rotate with media re-invert, native-dark-mode detection, emoji re-invert pass, "Always view emails in light mode" setting (`src/lib/email-html.ts:167-185`, `197-199`, `src/components/EmailBodyView.tsx:64-157`, `499-502`) — only the CSS revision differs (see finding).
|
||||
- Plain-text path: escaped + linkified, native dark colours without inversion (`src/lib/email-html.ts:120-126`, `263-311`).
|
||||
- External content policy ask/block/allow, per-message "Load external content", "Trust sender" persisting locally and to the address book, trusted list from the address book and contacts (`src/components/EmailBodyView.tsx:480-535`, `643-672`).
|
||||
- cid inline images fetched with auth and injected as data URIs, transparent placeholder otherwise (`src/components/EmailBodyView.tsx:546-584`, `src/lib/email-html.ts:213-218`).
|
||||
- Shrink-to-fit of fixed-width emails + in-page pinch zoom/pan with pager/scroll locking (`src/components/EmailBodyView.tsx:159-460`) — RN-only, better than WEB's horizontal scroll (#409).
|
||||
- Mark read on open with delay setting (apart from the `-1` bug), optimistic star/unread/tag toggles, delete/archive/spam/not-spam/move scoped to the message's account, `no trash folder` alert (`src/screens/EmailThreadScreen.tsx:221-364`). The account scoping was wrong for messages opened from the Unified Inbox, a notification or a deep link until 5c7301b (audit B3); star, tag and unread are queued offline since ddff2f7.
|
||||
- Prev/next navigation with prefetched neighbour panes and swipe (`src/screens/EmailThreadScreen.tsx:122-181`, `535-572`) — RN-only pager.
|
||||
- Skeleton while loading (`src/screens/EmailThreadScreen.tsx:845-886`).
|
||||
- Attachments: chips with size, show-all toggle, `hideInlineImageAttachments` setting, `attachmentPosition` setting, `mailAttachmentAction` preview/download, templated filenames + transforms identical to WEB (`src/lib/download-filename.ts` diff vs `lib/download-filename.ts` is whitespace/quotes only, minus `DEFAULT_BUNDLE_TEMPLATE`/`BUNDLE_TOKENS`), Downloads settings screen with live preview (`src/components/settings/DownloadsSettings.tsx`).
|
||||
- Attachment/blob/source/.eml fetches routed by owner account for shared/group messages (`src/screens/EmailThreadScreen.tsx:87-96`, `198-215`, `src/api/blob.ts:119-135`, `src/lib/email-export.ts`) — #847/#867 parity except the calendar banner.
|
||||
- Client-cert-aware downloads via `secureFetch`, streaming download via `File.downloadFileAsync` (`src/lib/email-export.ts:85-113`).
|
||||
- View source (raw RFC 822, shareable) and Export .eml with the email filename template (`src/screens/EmailSourceScreen.tsx`, `src/lib/email-export.ts:173-206`).
|
||||
- Calendar invitation: detection by MIME/extension, parse via `CalendarEvent/parse`, RSVP via import-then-`rsvpEvent` with `replyTo`/`organizerCalendarAddress`, cancelled notice, "no writable calendar" warning, `calendarInvitationParsingEnabled` setting (`src/components/email/CalendarInvitationBanner.tsx`, `src/lib/calendar-invitation.ts`).
|
||||
- Tag sheet from the viewer with keyword definitions (`src/screens/EmailThreadScreen.tsx:1033-1100`).
|
||||
|
||||
## N/A on mobile
|
||||
- Print (WEB `handlePrint`, `components/email/email-viewer.tsx:2621-2670`) — could be done with `expo-print` later, but not a parity requirement.
|
||||
- Copy permalink to a message (#733) — RN opens incoming message links since 5802cae (`src/navigation/linking.ts`) but has no "copy link" action.
|
||||
- Fullscreen reading toggle, Pro split panes, toolbar overflow measurement, plugin slots/hooks (`email-banner`, `email-detail-sidebar`, `onRenderEmailBody`, `onBeforeExternalLink`, `ui.rerenderEmail`), keyboard shortcuts, drag attachments out to the file system (#267), drag emails out as `.eml`, import `.eml` from the viewer menu (RN imports from the list screen instead).
|
||||
- Iframe-specific fixes: "Render the email body on DOM parse instead of iframe load" (#635), iframe flash/flicker fixes (1.5.4, 1.6.7), `blob:` in CSP `object-src`/`frame-src` for PDF previews (#253), pdf.js bytes vs `blob:` fetch (#871), RSC 307 loop (#919), iOS Safari viewport zoom on input focus (#838).
|
||||
- Demo-mode welcome pane, tour, contact sidebar panel layout (desktop only; the actions themselves are listed above).
|
||||
- S/MIME plugin UI (WEB core has none since 1.7.6); listed above only because RN ships a misleading stub.
|
||||