Compare commits

..
675 Commits
Author SHA1 Message Date
Linus Rath ec94ec5305 test: tap continue again when the sign-in link alert is still up in the ios flows 2026-10-31 23:46:19 +01:00
Linus Rath f27bbcb345 test: split the ios settings flow in two so each half stays under the flow time limit 2026-10-31 23:46:18 +01:00
Linus Rath 8d2117c39c test: find the selection more button by its full label in the ios move flow 2026-10-31 23:46:18 +01:00
Linus Rath 0b1698d078 test: retype the settings search up to three times when the simulator garbles it 2026-10-31 22:25:53 +01:00
Linus Rath b1d1b5f94d test: open the more menu for star when the tablet selection bar has no room in the ios move flow 2026-10-31 22:25:53 +01:00
Linus Rath 5666b11ecf test: expect the device-neutral wording in the sign-in code error 2026-10-31 20:52:14 +01:00
Linus Rath 76c256dd8e fix: say this device instead of this phone in two sign-in fallbacks 2026-10-31 20:48:55 +01:00
Linus Rath 9caed413d7 Merge branch 'cu-fixsettings' into catch-up-2026-11 2026-10-31 20:47:32 +01:00
Linus Rath 10f0066ed1 fix: name the server when two accounts with one address sync to this device 2026-10-31 20:46:09 +01:00
Linus Rath 3c95bba0d2 fix: give the folder name its own line next to the docked file preview 2026-10-31 20:46:09 +01:00
Linus Rath 34c65765ba fix: call a sharee without a name unknown person in every share sheet 2026-10-31 20:46:09 +01:00
Linus Rath a832f6ca3b fix: say this device instead of this phone, also on a tablet 2026-10-31 20:46:08 +01:00
Linus Rath 2c40bc6709 fix: forget an account's saved sender identities when it is signed out 2026-10-31 20:36:40 +01:00
Linus Rath 5d91243451 Merge branch 'cu-fixmail' into catch-up-2026-11 2026-10-31 20:34:10 +01:00
Linus Rath 2ebe5fc728 fix: open the soft keyboard for the reply body on android 2026-10-31 20:33:11 +01:00
Linus Rath 6a1c40e7eb fix: make the remove cross on a participant chip easier to tap 2026-10-31 20:31:23 +01:00
Linus Rath 81b2a7c485 fix: float the notification prompt in the list column on a tablet, clear of the reader 2026-10-31 20:31:02 +01:00
Linus Rath 3165b40127 fix: toggle a settings switch by tapping anywhere on its row 2026-10-31 20:29:26 +01:00
Linus Rath 1286e6cb82 fix: list the address books after an account switch and mark only the own default 2026-10-31 20:27:02 +01:00
Linus Rath 681e2546f0 fix: lay the global search sheet out from the top 2026-10-31 20:26:34 +01:00
Linus Rath 718fd00c6d fix: hide the pgp/mime control parts of an encrypted message from its attachments 2026-10-31 20:25:33 +01:00
Linus Rath cb0f50198e fix: keep the selection toolbar inside the list column on a tablet 2026-10-31 20:23:05 +01:00
Linus Rath 421fa6592a fix: draw the app text in the chosen reading font in release builds 2026-10-31 20:23:02 +01:00
Linus Rath d20532af91 fix: keep search everything reachable once mail search results load 2026-10-31 20:18:15 +01:00
Linus Rath 67cfe0a97d fix: decrypt and verify signed pgp mail on the device 2026-10-31 20:18:00 +01:00
Linus Rath db1fac4085 fix: read the year on the calendar's month button also in the current year 2026-10-31 20:17:11 +01:00
Linus Rath 93de202b38 fix: let a composer opened offline send from the last known identities 2026-10-31 20:15:59 +01:00
Linus Rath 305b836001 fix: close an untouched all-day event without asking to discard changes 2026-10-31 20:14:38 +01:00
Linus Rath ba67e67339 fix: show a saved reply draft in its conversation without a refresh 2026-10-31 20:12:56 +01:00
Linus Rath 818c45c280 fix: say cancelled when the organizer cancels an event 2026-10-31 20:12:12 +01:00
Linus Rath 3b3a2fc670 fix: show the server where two accounts with one address would read the same 2026-10-31 20:08:58 +01:00
Linus Rath d88d3dfbaf fix: scroll the new tag form above the keyboard in settings 2026-10-31 20:04:47 +01:00
Linus Rath bac51fbc46 fix: tell screen readers which tags the picker has checked 2026-10-31 20:01:07 +01:00
Linus Rath 7c39730616 fix: name the recipient on the composer chip remove button 2026-10-31 19:58:47 +01:00
Linus Rath 62e4806353 fix: save a settings sheet on the first tap while the keyboard is open 2026-10-31 19:57:03 +01:00
Linus Rath b9dcbdea52 test: tap the move menu row by its real label in the e2e move flow 2026-10-31 19:15:52 +01:00
Linus Rath 889e1c02f6 docs: write up the native catch-up with webmail 1.19.1 and stalwart 1.0.1 2026-10-31 19:12:28 +01:00
Linus Rath 835508c0c0 docs: tick the parity rows the catch-up closed 2026-10-31 19:06:32 +01:00
Linus Rath 633574203f feat: translate the key offer and already-sent messages 2026-10-31 18:57:09 +01:00
Linus Rath b188c2dba0 Merge branch 'cu-reviewfix' into catch-up-2026-11 2026-10-31 18:54:03 +01:00
Linus Rath 93c6307027 fix: stop saving pgp keys from messages on their own and offer to save a key only for the sender's address 2026-10-31 18:52:00 +01:00
Linus Rath 2c5d95184e fix: wait for the saved session check instead of loading the account beside it when the network comes back at start 2026-10-31 18:40:49 +01:00
Linus Rath 75213f6a55 fix: send a message queued or retried while the outbox is already sending 2026-10-31 18:32:21 +01:00
Linus Rath ac875ffe1d fix: don't move a queued message to drafts when an earlier attempt already sent it 2026-10-31 18:27:57 +01:00
Linus Rath 1c33f8daef fix: tell the user when a queued message gives up after unconfirmed attempts 2026-10-31 18:24:12 +01:00
Linus Rath e2d4915edc fix: keep a queued message without a drafts folder from passing as sent when its submission failed 2026-10-31 18:24:11 +01:00
Linus Rath b1a0eaf805 fix: turn sender favicons off on upgrade unless they were switched on by choice 2026-10-31 18:15:50 +01:00
Linus Rath 2e90f43181 test: catch the informal "passe an" in german strings 2026-10-31 18:13:53 +01:00
Linus Rath 3c0d7ad505 Merge branch 'cu-i18n' into catch-up-2026-11 2026-10-31 18:11:36 +01:00
Linus Rath a5b40d5a3a feat: translate the remaining app strings into Simplified and Traditional Chinese 2026-10-31 18:10:45 +01:00
Linus Rath 2662ad404e feat: translate the remaining app strings into Russian, Slovak, Thai, Turkish and Ukrainian 2026-10-31 18:10:44 +01:00
Linus Rath d2d6023084 feat: translate the remaining app strings into Norwegian, Dutch, Polish, Portuguese and Romanian 2026-10-31 18:10:43 +01:00
Linus Rath f21f6a126b feat: translate the remaining app strings into Italian, Japanese, Korean, Latvian and Mongolian 2026-10-31 18:10:40 +01:00
Linus Rath 17ed1b2309 feat: translate the remaining app strings into Estonian, Persian, French, Hebrew and Hungarian 2026-10-31 18:10:37 +01:00
Linus Rath cef88b9384 feat: translate the remaining app strings into Arabic, Catalan, Czech, Danish and Spanish 2026-10-31 18:10:35 +01:00
Linus Rath 1194bf284f fix: keep ios push renewal from reporting an extension the server refused 2026-10-31 18:10:20 +01:00
Linus Rath d2d44e0dec fix: stop the app from crashing at launch on android 14 and later 2026-10-31 18:06:42 +01:00
Linus Rath 1aba571ac3 fix: keep a signed or encrypted reply-to name with a comma as one address 2026-10-31 18:05:48 +01:00
Linus Rath 8345be43ef feat: translate the remaining app strings into German 2026-10-31 17:34:49 +01:00
Linus Rath 43bf8da760 test: decrypt pkcs#1 v1.5 in the node stand-in on node releases that refuse it 2026-10-31 17:32:40 +01:00
Linus Rath 9192311df8 fix: drop the composer's second policy import left by the merge 2026-10-31 17:26:57 +01:00
Linus Rath 5ead7a8164 chore: declare the end-to-end encryption for app store export compliance #58 2026-10-31 17:23:19 +01:00
Linus Rath e5027c45aa Merge branch 'cu-pgp' into catch-up-2026-11 2026-10-31 17:20:36 +01:00
Linus Rath 7a06ab03c0 fix: list a signed message's attachments once in the reader #58 2026-10-31 17:19:34 +01:00
Linus Rath be98acdc8b test: send and read pgp and s/mime mail between two accounts on live stalwart #58 2026-10-31 17:13:25 +01:00
Linus Rath b0997c0a13 feat: sign and encrypt mail with pgp or s/mime from the composer options #58 2026-10-31 17:10:01 +01:00
Linus Rath 8aa1d8e03c feat: read pgp and s/mime mail and manage the keys under settings, encryption #58 2026-10-31 17:10:01 +01:00
Linus Rath d9d7a46667 Merge branch 'cu-e2e' into catch-up-2026-11 2026-10-31 16:56:15 +01:00
Linus Rath 69f277e1ce test: move to the webmail password field with safari's next key in the ios sign-in flows 2026-10-31 16:53:40 +01:00
Linus Rath aabfe3f6a7 test: accept the camera pre-prompt wording in the ios scanner flow 2026-10-31 16:52:57 +01:00
Linus Rath da41b3f060 test: scroll sign out clear of the tab bar before tapping it in the ios flows 2026-10-31 16:52:29 +01:00
Linus Rath 6a52715704 test: search settings pages by words that keep their row above the keyboard in the ios settings flow 2026-10-31 16:52:07 +01:00
Linus Rath b6d364f732 test: expect delete to move the folder to the trash without a question in the ios files flow 2026-10-31 16:51:43 +01:00
Linus Rath 1b6384edec test: tap allow on the ios notification prompt once and start the calendar flow's event today 2026-10-31 16:51:23 +01:00
Linus Rath 1769a4aa62 test: customize the default reminders before adding one in the ios calendar flow 2026-10-31 16:50:46 +01:00
Linus Rath 897d4ae32d test: confirm cancel send under the new back-to-drafts message in the ios flow 2026-10-31 16:50:21 +01:00
Linus Rath b75b4faa69 test: expect the upload and attach from files options in the ios compose flow 2026-10-31 16:50:06 +01:00
Linus Rath 8fae4e6398 test: swipe the message row, not its avatar, in the ios swipe flow 2026-10-31 16:49:50 +01:00
Linus Rath 2acce326b7 test: open and select messages by their list row, not the avatar checkbox, in the ios flows 2026-10-31 16:49:40 +01:00
Linus Rath 05bc0f567b fix: give the widget and the notification extension their own copy of the shared keychain code so pod install succeeds 2026-10-31 16:49:09 +01:00
Linus Rath 4c818d64ea chore: provide webcrypto from react-native-quick-crypto instead of webcrypto-liner #58 2026-10-31 16:33:18 +01:00
Linus Rath 22893d327f test: follow the mention target setting in the composer wiring check 2026-10-31 16:30:59 +01:00
Linus Rath 48e427bfa1 Merge branch 'cu-followup3' into catch-up-2026-11 2026-10-31 16:25:05 +01:00
Linus Rath 48a0590a93 fix: describe the client certificate the iOS way on iPhone and iPad 2026-10-31 16:24:09 +01:00
Linus Rath d254d7b5ab fix: block remote content when the webmail allows no third-party traffic and hide address matching without custom From 2026-10-31 16:22:11 +01:00
Linus Rath c66e6209ed fix: keep accessibility presets off settings the organisation locks or limits 2026-10-31 16:13:07 +01:00
Linus Rath bc47c6a1af fix: follow the organisation policy for delegates and recovering deleted mail 2026-10-31 16:10:35 +01:00
Linus Rath ab226b03b4 feat: let @ mentions be turned off and choose where a mentioned contact is added 2026-10-31 16:05:14 +01:00
Linus Rath 051f6bf2d5 chore: add the openpgp and pkijs crypto libraries for pgp and s/mime #58 2026-10-31 16:03:54 +01:00
Linus Rath cfca6b9c24 feat: use the reading fonts for the app's own text on ios 2026-10-31 16:03:54 +01:00
Linus Rath 0ae33f3a07 feat: add a setting to block screenshots and hide the app in recent apps 2026-10-31 16:03:54 +01:00
Linus Rath f9a3b76174 chore: move to the latest expo sdk 54 patch release and refresh the lockfile 2026-10-31 16:02:02 +01:00
Linus Rath b2cd83b188 chore: update zustand, jszip and vitest to their latest patch releases 2026-10-31 16:02:02 +01:00
Linus Rath 38257f81c6 chore: update react navigation to 7.5 and drop its vulnerable url and id helpers 2026-10-31 16:02:01 +01:00
Linus Rath 368c90ce3e fix: stop crafted .eml headers from stalling the parser by moving to postal-mime 4.2 2026-10-31 16:02:01 +01:00
Linus Rath fe3f860e73 feat: add the organisation's signature and disclaimer to new mail and hide templates when they are off 2026-10-31 16:01:16 +01:00
Linus Rath 7e60d32449 fix: let screen readers reach the folder share sheet's loading state 2026-10-31 15:57:10 +01:00
Linus Rath 13dcbdc086 fix: hide every archive action when the organisation turns archiving off 2026-10-31 15:52:37 +01:00
Linus Rath 0865c1d6e7 Merge branch 'cu-tagsx' into catch-up-2026-11 2026-10-31 15:51:50 +01:00
Linus Rath e4295dab22 test: read the search-the-server row from the shared suggestion list 2026-10-31 15:51:50 +01:00
Linus Rath b43472dda4 fix: skip saved accounts without a server when picking an account id 2026-10-31 15:51:50 +01:00
Linus Rath 6b7e752c4b feat: show the sender's BIMI logo in the list, the unified inbox and the reader 2026-10-31 15:50:02 +01:00
Linus Rath f15b7bb3cf feat: search mail, contacts, events and files of every account at once 2026-10-31 15:50:02 +01:00
Linus Rath ff01246c92 Merge branch 'cu-policy2' into catch-up-2026-11 2026-10-31 15:46:19 +01:00
Linus Rath 83ddd13726 feat: show the sender's BIMI logo from the webmail when the message passes DMARC 2026-10-31 15:44:35 +01:00
Linus Rath 609d36b0d3 Merge branch 'cu-compx' into catch-up-2026-11 2026-10-31 15:42:13 +01:00
Linus Rath 44366848d1 test: clean up the live send tests without a subject search 2026-10-31 15:41:17 +01:00
Linus Rath f27e2cebb4 feat: lock or hide the settings the organisation pins or hides 2026-10-31 15:40:05 +01:00
Linus Rath 409ada9428 fix: keep delivery notifications and encrypted delivery when a scheduled send is moved 2026-10-31 15:39:13 +01:00
Linus Rath efb92593b2 feat: take archive out of the swipe and quick action settings when the organisation turns it off 2026-10-31 15:34:06 +01:00
Linus Rath 0ec809eb49 feat: keep mail sent offline in an Outbox and send it once back online 2026-10-31 15:34:02 +01:00
Linus Rath df1e211780 feat: hide settings export and import when the organisation turns them off 2026-10-31 15:31:27 +01:00
Linus Rath 62a6c22f10 Merge branch 'cu-native' into catch-up-2026-11 2026-10-31 15:31:09 +01:00
Linus Rath 1d654bbeaf feat: leave out the settings pages the organisation turns off 2026-10-31 15:30:15 +01:00
Linus Rath fbe5457604 feat: add an iOS inbox widget 2026-10-31 15:29:52 +01:00
Linus Rath 417f1843a2 feat: share a mail folder with other people from its menu 2026-10-31 15:29:35 +01:00
Linus Rath 83981e8b8d feat: hide or lock the external content option as the organisation sets it 2026-10-31 15:26:45 +01:00
Linus Rath 8ba2f27af2 feat: hide calendar tasks in settings when the organisation turns them off 2026-10-31 15:25:37 +01:00
Linus Rath a69bc071d1 feat: hide the themes the organisation turns off and keep a locked theme in place 2026-10-31 15:24:16 +01:00
Linus Rath ba150060cf feat: use TLS client certificates on iOS #3 2026-10-31 15:22:32 +01:00
Linus Rath b7b3dd4c4d feat: show the colour mode as set by the organisation when its policy locks it 2026-10-31 15:22:15 +01:00
Linus Rath 585c70a5db feat: nest tags under other tags and choose which tags the sidebar shows 2026-10-31 15:20:57 +01:00
Linus Rath c704c7e6af fix: keep iOS push alive when the app isn't opened for a week 2026-10-31 15:17:14 +01:00
Linus Rath e1ad5c0945 feat: pick recipients full screen in phone landscape, where suggestions had no room 2026-10-31 15:16:07 +01:00
Linus Rath 2b3e84a5a0 Merge branch 'cu-policy' into catch-up-2026-11 2026-10-31 15:13:58 +01:00
Linus Rath 9d9777738e feat: apply the organisation policy of the webmail an account signed in through 2026-10-31 15:13:02 +01:00
Linus Rath 6a27c59672 feat: play the Involved sound for new mail 2026-10-31 15:10:44 +01:00
Linus Rath 8389db8fcb feat: suggest recipients and contacts when typing @ in the message 2026-10-31 15:08:21 +01:00
Linus Rath 21dd5a2405 feat: draw calendar colours in the colour-blind-safe palette when that setting is on 2026-10-31 15:05:48 +01:00
Linus Rath 6e5980e4e0 Merge branch 'cu-readx' into catch-up-2026-11 2026-10-31 14:59:37 +01:00
Linus Rath 97b71e22f5 feat: offer plain language with simpler English and German wording 2026-10-31 14:58:04 +01:00
Linus Rath 649d3adc0f feat: let the composer set message priority and ask for delivery notifications 2026-10-31 14:57:54 +01:00
Linus Rath ab2e5a60f8 feat: redeem a one-time code from the webmail instead of reading the sign-in from the link 2026-10-31 14:53:57 +01:00
Linus Rath 10fb7ab758 fix: keep a saturday week start after a restart 2026-10-31 14:52:40 +01:00
Linus Rath c6501ec886 feat: draw tag colours in a colour-blind-safe palette when that setting is on 2026-10-31 14:52:39 +01:00
Linus Rath 66b4305e6d Merge branch 'cu-calx' into catch-up-2026-11 2026-10-31 14:51:48 +01:00
Linus Rath 7b249ab9d9 fix: keep a new default-port account apart from a kept host-only one 2026-10-31 14:50:25 +01:00
Linus Rath b4f1082915 feat: play the Involved sound for calendar reminders 2026-10-31 14:49:56 +01:00
Linus Rath 4724c28f53 feat: offer the reading fonts, including OpenDyslexic, for the app and plain-text mail 2026-10-31 14:49:21 +01:00
Linus Rath 77914b0a46 test: follow the drawer's unread label helper and the sheet inset hook 2026-10-31 14:46:40 +01:00
Linus Rath f1297b6be8 feat: show calendar months, days and dates in the Jalali calendar for Persian 2026-10-31 14:45:24 +01:00
Linus Rath 36cc49b706 Merge branch 'cu-iosperf' into catch-up-2026-11 2026-10-31 14:37:35 +01:00
Linus Rath 3704c66637 Merge remote-tracking branch 'origin/ios-perf' into cu-iosperf
# Conflicts:
#	src/api/jmap-client.ts
2026-10-31 14:36:41 +01:00
Linus Rath 23b2bf9732 feat: recolour HTML mail for dark mode instead of inverting it 2026-10-31 14:34:01 +01:00
Linus Rath 172d0f63e8 fix: let TalkBack read the unified inbox row as its name and unread count 2026-10-31 14:31:54 +01:00
Linus Rath ce950430ea fix: keep the new id token when push or the unified inbox refreshes a login 2026-10-31 14:28:59 +01:00
Linus Rath d663a5e282 feat: show when someone invites you to, changes or cancels a calendar event 2026-10-31 14:28:18 +01:00
Linus Rath 268b1105b8 fix: keep the unified views from reading an old account on another port through the live one 2026-10-31 14:27:41 +01:00
Linus Rath 9669e07e95 fix: let a notification's delete skip the unlock when the mail goes to trash #85 2026-10-31 14:25:21 +01:00
Linus Rath b803da5df5 fix: stop showing a delegate's server id under Unknown person 2026-10-31 14:23:19 +01:00
Linus Rath e6a89ad07a feat: show invited participants' free/busy in the event editor 2026-10-31 14:22:59 +01:00
Linus Rath 423e2780d5 fix: name a shared address book or calendar once when its name already has the account 2026-10-31 14:22:16 +01:00
Linus Rath 58c8054339 fix: show how many emails were moved instead of {count} 2026-10-31 14:18:40 +01:00
Linus Rath 350b0e8e74 fix: stop TalkBack reading a button as busy after it finishes loading 2026-10-31 14:14:42 +01:00
Linus Rath 9b0bba7c15 fix: keep folder, calendar and share sheets and compose prompts above the taskbar and keyboard 2026-10-31 14:12:58 +01:00
Linus Rath 5ecf6f3256 Merge branch 'pr-66' into catch-up-2026-11
# Conflicts:
#	locales/rn/en.json
#	src/components/SidebarDrawer.tsx
2026-10-31 14:07:30 +01:00
Linus Rath f606866dd1 Merge branch 'cu-mail' into catch-up-2026-11 2026-10-31 14:04:54 +01:00
Linus Rath 14ec671c4e Merge branch 'cu-accounts' into catch-up-2026-11 2026-10-31 14:04:53 +01:00
Linus Rath 73823292e2 Merge branch 'cu-sheets' into catch-up-2026-11 2026-10-31 14:04:53 +01:00
Linus Rath e69bfc502b fix: lay out the reader's pages at the pane's width so a tablet pane is not left empty 2026-10-31 13:56:46 +01:00
Linus Rath 5a301d62dc feat: sign out of the identity provider too when signing out of a browser sign-in 2026-10-31 13:55:47 +01:00
Linus Rath e0184cfb46 fix: keep the notification prompt clear of the reader's bottom bar on tablets 2026-10-31 13:54:48 +01:00
Linus Rath 56c8b98bb6 fix: show localized system folder names in the filter rule folder picker 2026-10-31 13:54:39 +01:00
Linus Rath ce21f273a3 fix: scale the reader's header and toolbar with the text size setting 2026-10-31 13:52:38 +01:00
Linus Rath f2513de025 fix: say a message deleted elsewhere is gone instead of failing to load it 2026-10-31 13:52:38 +01:00
Linus Rath 9bc7b4a5b8 fix: keep the reader's read state when the conversation loads after marking it read 2026-10-31 13:52:38 +01:00
Linus Rath 461e4da0b2 fix: open the keyboard in the body when replying on Android 2026-10-31 13:52:38 +01:00
Linus Rath 66cdc07d31 fix: let the search filter sheet scroll above the keyboard and clear fully 2026-10-31 13:52:38 +01:00
Linus Rath ff878fc6bf fix: show the sender on search hits from other folders when searching from Sent 2026-10-31 13:52:38 +01:00
Linus Rath ccf93ed667 fix: stop announcing the select all checkbox as mixed with nothing selected 2026-10-31 13:52:38 +01:00
Linus Rath 9f64f8abb3 fix: hide reply and forward on drafts in a conversation 2026-10-31 13:52:38 +01:00
Linus Rath 9763036c70 feat: offer undo after changing tags in the tag sheet and the reader 2026-10-31 13:52:37 +01:00
Linus Rath ecf814b66e fix: make undo after a star take back the star, not the tag set before it 2026-10-31 13:52:37 +01:00
Linus Rath d1c44f52ba fix: count down the seconds left on the undo-send bar 2026-10-31 13:52:37 +01:00
Linus Rath 7eb7289472 fix: stop a settings search for "recover" from offering Reading 2026-10-31 13:52:07 +01:00
Linus Rath b17d4d4069 fix: say in Composing that "Plain text only" also shows received mail as plain text 2026-10-31 13:49:05 +01:00
Linus Rath 47b922cab0 Merge branch 'cu-calcon' into catch-up-2026-11 2026-10-31 13:48:41 +01:00
Linus Rath 2fe8366938 fix: let the notification Delete skip the unlock when the mail goes to Trash #85 2026-10-31 13:47:05 +01:00
Linus Rath 9ce89e2672 fix: say "1 email selected" in the tag picker header 2026-10-31 13:47:05 +01:00
Linus Rath d6b9b342e9 fix: name the tag or person on each row's delete and remove-access button 2026-10-31 13:44:54 +01:00
Linus Rath 72b41eabde fix: look up a typed address in the share sheet and say why the server won't find it 2026-10-31 13:43:58 +01:00
Linus Rath 149e5bcee5 fix: ask to sign in again when a password change ends the session 2026-10-31 13:42:37 +01:00
Linus Rath 236e9117a8 fix: stop showing a share's principal id as its address when the server can't name the person 2026-10-31 13:42:08 +01:00
Linus Rath e7fd665bb4 fix: tell screen readers which undo-send delay, sort order and language is picked 2026-10-31 13:41:43 +01:00
Linus Rath d00605492a fix: keep an account whose saved sign-in stops working and ask to sign in again 2026-10-31 13:39:44 +01:00
Linus Rath 9781b30478 fix: show a scheduled out of office as scheduled, mark its placeholders and stop Save reading as busy 2026-10-31 13:38:46 +01:00
Linus Rath fe6781745f fix: open the app from a widget tap without asking which build to use 2026-10-31 13:37:10 +01:00
Linus Rath bd5c54d654 fix: stop repeating the account in a shared address book's name 2026-10-31 13:33:58 +01:00
Linus Rath 8e0b447d8f fix: name the month button by its month and the participant remove button by the person 2026-10-31 13:32:21 +01:00
Linus Rath e9816809f2 fix: name the filter rule fields by their labels for screen readers 2026-10-31 13:31:10 +01:00
Linus Rath 11fa0365f9 fix: clear the new event slot marker when the editor closes or the tab is left 2026-10-31 13:29:19 +01:00
Linus Rath 65a8ac3469 fix: show the file name in the tablet split view's preview header 2026-10-31 13:28:30 +01:00
Linus Rath 826d5ad2be fix: accept a calendar hex colour with or without # and show it with # after typing 2026-10-31 13:26:36 +01:00
Linus Rath dcca32a780 fix: give the Files recent and starred menus a Cancel and let Back close them 2026-10-31 13:25:53 +01:00
Linus Rath f9a36b2892 fix: open a notification or link for another account over that account's inbox 2026-10-31 13:24:56 +01:00
Linus Rath 8626cd11ef fix: name the calendar description field plainly instead of the desktop help text 2026-10-31 13:24:22 +01:00
Linus Rath e4eda0a840 fix: label the new folder parent picker "Parent folder" instead of "Inside {name}" 2026-10-31 13:22:56 +01:00
Linus Rath 75513969ef fix: keep all-day events all-day on Stalwart 0.16 2026-10-31 13:21:11 +01:00
Linus Rath 688e8bef3b fix: keep two accounts on the same host apart by port 2026-10-31 13:21:01 +01:00
Linus Rath 980243469c fix: keep sheet and dialog fields and buttons above the keyboard 2026-10-31 13:19:56 +01:00
Linus Rath 9ec58be4fb fix: add a typed participant once and show suggestions above the keyboard 2026-10-31 13:17:18 +01:00
Linus Rath 426f740359 fix: keep bottom sheet buttons above the tablet taskbar 2026-10-31 13:15:21 +01:00
Linus Rath db717dcd51 fix: stop the app from crashing when switching to an account left in Trash 2026-10-31 13:08:27 +01:00
Linus Rath 1e21540c6a docs: record the catch-up decisions 2026-10-31 13:06:12 +01:00
Linus Rath 7139390a85 Merge branch 'cu-calendar2' into catch-up-2026-11
# Conflicts:
#	docs/parity/10-webmail-1.10-to-1.18.md
#	docs/parity/11-webmail-1.19.md
2026-10-31 05:58:19 +01:00
Linus Rath 62867f3f3d docs: tick the rows the second calendar pass fixed 2026-10-31 05:57:18 +01:00
Linus Rath 6452b47ca2 test: follow the event editor's save into the offer to save without invitations 2026-10-31 05:56:22 +01:00
Linus Rath 50b91d67d5 test: check address book sharing and the contacts and files account lists on live servers 2026-10-31 05:54:25 +01:00
Linus Rath 7ba00be60e fix: give the contact, event and files fields a name for screen readers 2026-10-31 05:51:52 +01:00
Linus Rath 01578ac2fb feat: show calendar events in light tinted colours on request 2026-10-31 05:48:55 +01:00
Linus Rath a4e5f81f37 Merge branch 'cu-mailbits' into catch-up-2026-11 2026-10-31 05:45:17 +01:00
Linus Rath 90ce7c1b3c feat: size the week all-day area from the days in view and fold it to three rows (bulwarkmail/webmail#1122) 2026-10-31 05:44:28 +01:00
Linus Rath 9fd55d94d3 docs: note the trash link and list row fixes 2026-10-31 05:44:18 +01:00
Linus Rath 7467764e43 feat: lead from the trash to recover deleted items when the archive has some 2026-10-31 05:43:55 +01:00
Linus Rath b35d15cada fix: show own IDN domains as written in the mail list rows (bulwarkmail/webmail#1100) 2026-10-31 05:40:27 +01:00
Linus Rath ecf1f3ecd9 fix: forward out-of-office mail to a unicode domain in its ascii form (bulwarkmail/webmail#1100) 2026-10-31 05:38:43 +01:00
Linus Rath 254d192709 test: check the sharing and recovery panes against the settings search results 2026-10-31 05:37:59 +01:00
Linus Rath 96b163a117 feat: share an address book with other people 2026-10-31 05:36:52 +01:00
Linus Rath bc9680b014 docs: note the card mount and the consent step 2026-10-31 05:36:00 +01:00
Linus Rath cec68f0d91 Merge branch 'cu-followup' into catch-up-2026-11
# Conflicts:
#	docs/parity/11-webmail-1.19.md
2026-10-31 05:34:13 +01:00
Linus Rath beae63e144 docs: tick the rows the follow-up pass fixed 2026-10-31 05:33:10 +01:00
Linus Rath 287b0e58ac refactor: draw the booking and parcel card icons from the generated icon set 2026-10-31 05:31:34 +01:00
Linus Rath 3048525d9b Merge branch 'cu-recovery' into catch-up-2026-11 2026-10-31 05:30:58 +01:00
Linus Rath dae154431f docs: tick the recover deleted and delegate rows 2026-10-31 05:30:09 +01:00
Linus Rath 8a5bb72823 feat: give a delegate access to mail, calendars and contacts in one step 2026-10-31 05:29:50 +01:00
Linus Rath d684b00e9b feat: open a files link on the folder and file it names 2026-10-31 05:29:46 +01:00
Linus Rath 98b962a8d4 fix: name the composer, quick reply, folder and template search fields for screen readers 2026-10-31 05:29:38 +01:00
Linus Rath 6c1cb0f9ef docs: tick the search rows 2026-10-31 05:29:35 +01:00
Linus Rath 12b798c0c8 test: check search highlights and the size filter on stalwart 0.16 and 1.0 2026-10-31 05:29:01 +01:00
Linus Rath cad9296a84 feat: list starred folders and recent files at the top of files 2026-10-31 05:28:20 +01:00
Linus Rath a7a3757378 feat: suggest directory people and search the sent folder for recipients in the composer 2026-10-31 05:27:34 +01:00
Linus Rath 3c3274e4b7 fix: name the search and filter fields for screen readers 2026-10-31 05:27:19 +01:00
Linus Rath aac8a40367 feat: filter search results by message size 2026-10-31 05:26:03 +01:00
Linus Rath e3fc67e771 feat: step to the previous or next file in the files preview 2026-10-31 05:24:31 +01:00
Linus Rath 113e96bdec feat: recover mail deleted for good from the server's archive (bulwarkmail/webmail#1319) 2026-10-31 05:23:28 +01:00
Linus Rath 1342e2a0f8 feat: show where a search matched in each result 2026-10-31 05:22:58 +01:00
Linus Rath 8ac1997149 feat: delete filter rules, tags and trusted senders with undo and warn about the forward limit (bulwarkmail/webmail#1208) 2026-10-31 05:21:32 +01:00
Linus Rath ecefd1cbcc feat: open new-contact and edit-contact links in the contact form 2026-10-31 05:16:34 +01:00
Linus Rath 4932764a66 fix: send to addresses on internationalized domains (bulwarkmail/webmail#1100) 2026-10-31 05:14:55 +01:00
Linus Rath fe45537037 feat: open a calendar link on the view and day it names 2026-10-31 05:13:57 +01:00
Linus Rath 7a15c0c6ac fix: list contacts and files only for shared accounts that support them (bulwarkmail/webmail#1239) 2026-10-31 05:10:32 +01:00
Linus Rath 97173d4af0 fix: lead with the address when two accounts share a name in the sidebar account switcher 2026-10-31 05:10:14 +01:00
Linus Rath bb45198f23 feat: add a switch for the quick reply box under composing settings 2026-10-31 05:09:12 +01:00
Linus Rath 6da985a225 fix: keep sender favicons off and style sheets out of notification previews (bulwarkmail/webmail#1136) 2026-10-31 05:08:11 +01:00
Linus Rath 90e4e230af feat: show the booking, parcel and order cards above the message body 2026-10-31 05:03:28 +01:00
Linus Rath f643df8164 Merge branch 'cu-reader' into catch-up-2026-11
# Conflicts:
#	docs/native-sync-2026-11.md
#	docs/parity/11-webmail-1.19.md
#	locales/rn/en.json
#	src/stores/settings-store.ts
2026-10-31 05:02:08 +01:00
Linus Rath 3769b24b3a test: tap Allow on stalwart 1.0.1's consent page in the ios sign-in flows 2026-10-31 04:59:38 +01:00
Linus Rath 53f95945b1 docs: tick the parity rows the reader pass fixed 2026-10-31 04:59:33 +01:00
Linus Rath 30e0631582 Merge branch 'cu-cards' into catch-up-2026-11 2026-10-31 04:57:58 +01:00
Linus Rath 470f12d232 Merge branch 'cu-platform' into catch-up-2026-11
# Conflicts:
#	locales/rn/en.json
#	src/stores/settings-store.ts
2026-10-31 04:57:53 +01:00
Linus Rath 693c1c8c73 chore: harvest the reader strings 2026-10-31 04:56:57 +01:00
Linus Rath beff87440f fix: name each sort order with its own string so it gets translated 2026-10-31 04:56:54 +01:00
Linus Rath c6ad3fe1d2 chore: harvest the reader strings 2026-10-31 04:56:09 +01:00
Linus Rath d9a759b78e Merge branch 'cu-calendar' into catch-up-2026-11 2026-10-31 04:55:30 +01:00
Linus Rath e5c9433a0e fix: offer every folder as a tree in the search folder picker instead of the first twelve 2026-10-31 04:55:30 +01:00
Linus Rath 446a2eef38 docs: tick the booking cards parity row 2026-10-31 04:55:12 +01:00
Linus Rath 88d19db6ef docs: tick the parity rows the platform pass fixed 2026-10-31 04:54:57 +01:00
Linus Rath 5a5f708d82 docs: tick the parity rows the calendar pass fixed 2026-10-31 04:54:34 +01:00
Linus Rath c90a35c403 chore: harvest the card strings 2026-10-31 04:54:16 +01:00
Linus Rath 982a30effc feat: show booking cards for Apple Wallet passes attached to verified mail 2026-10-31 04:53:55 +01:00
Linus Rath 56e4a1e051 feat: let the quick reply under a conversation be turned off (bulwarkmail/webmail#1370) 2026-10-31 04:53:48 +01:00
Linus Rath 01dd7461f5 fix: keep toasts working where the accessibility bridge is missing 2026-10-31 04:52:44 +01:00
Linus Rath 542fff8872 chore: harvest the calendar strings 2026-10-31 04:52:02 +01:00
Linus Rath c7cb3f60b2 fix: spell the custom calendar colour strings like the rest of the app 2026-10-31 04:52:02 +01:00
Linus Rath fac54b415f fix: show the via badge only when the identity's name adds something to the sender 2026-10-31 04:51:26 +01:00
Linus Rath a0f586f9c2 feat: duplicate a folder in Files with everything in it 2026-10-31 04:51:01 +01:00
Linus Rath ed490b7188 fix: open the folder a mail folder link names instead of only the mail tab 2026-10-31 04:50:08 +01:00
Linus Rath 2055df8c21 feat: add, update and remove bookings from mail in the calendar 2026-10-31 04:49:41 +01:00
Linus Rath b943246373 chore: harvest the platform strings 2026-10-31 04:48:24 +01:00
Linus Rath e0f0db5d67 test: pin the error answer the push lookup fallback relies on (bulwarkmail/webmail#1041) 2026-10-31 04:48:14 +01:00
Linus Rath aa25d08dcc fix: start another folder, tag or search at the top of its list (bulwarkmail/webmail#1138) 2026-10-31 04:48:04 +01:00
Linus Rath 327b2cae1b fix: check push again when the app comes back to settings (bulwarkmail/webmail#1148) 2026-10-31 04:47:14 +01:00
Linus Rath 9067908fef fix: open the inbox on a cold start instead of the folder the app was left in 2026-10-31 04:46:53 +01:00
Linus Rath 18c6b03fc1 feat: filter contacts by company, job title, location, email domain, birthday month and details 2026-10-31 04:45:41 +01:00
Linus Rath 1821fda728 feat: add an inbox-only switch for push notifications (bulwarkmail/webmail#983) 2026-10-31 04:45:07 +01:00
Linus Rath 4470a4ba82 fix: drop the paperclip from messages with only inline images and keep list rows from growing when chips arrive 2026-10-31 04:45:01 +01:00
Linus Rath 219aef046a fix: drop a leading style sheet and padding from message previews (bulwarkmail/webmail#1136) 2026-10-31 04:42:37 +01:00
Linus Rath 65ffef7108 feat: delete a contact category with an undo 2026-10-31 04:41:51 +01:00
Linus Rath 04a962a69e fix: sort merged lists by date sent instead of falling back to the received date (bulwarkmail/webmail#1219) 2026-10-31 04:41:02 +01:00
Linus Rath 43bb5e474a fix: leave a server's own all mailbox out of all mail and unread (bulwarkmail/webmail#1241) 2026-10-31 04:41:02 +01:00
Linus Rath cd677e7787 feat: show a card with times and tracking above booking, shipping and order mail 2026-10-31 04:40:16 +01:00
Linus Rath 6715df7f44 feat: select a message by tapping its avatar in the list #91 2026-10-31 04:39:14 +01:00
Linus Rath 67732348c2 fix: offer filters and auto-reply only for shared accounts that support them (bulwarkmail/webmail#1239) 2026-10-31 04:38:56 +01:00
Linus Rath e988dc2928 feat: suggest contact groups and recent recipients when inviting people to an event 2026-10-31 04:38:20 +01:00
Linus Rath eb6bf6d38e fix: leave trash and spam out of tag views and tag counts, and count tags with Keyword/get where the server can (bulwarkmail/webmail#1156) 2026-10-31 04:37:42 +01:00
Linus Rath af86339bf1 fix: count storage use against the soft limit when the server sets one (bulwarkmail/webmail#1242) 2026-10-31 04:35:41 +01:00
Linus Rath baa3ff5956 feat: copy an event's title and add a note to it from the event details 2026-10-31 04:35:30 +01:00
Linus Rath f5875f87b1 feat: sign in with addresses on internationalized domains (bulwarkmail/webmail#1100) 2026-10-31 04:31:46 +01:00
Linus Rath 574db42652 feat: change the colour of the birthdays calendar 2026-10-31 04:31:14 +01:00
Linus Rath a6066a092c feat: read schema.org booking, parcel and order markup from verified mail 2026-10-31 04:31:01 +01:00
Linus Rath 5f2ef4c1c3 feat: read booking markup from mail HTML without a browser DOM 2026-10-31 04:30:14 +01:00
Linus Rath b6995bb638 fix: move a task into or within a shared calendar (bulwarkmail/webmail#1204) 2026-10-31 04:29:28 +01:00
Linus Rath 37e72c129f fix: explain a refused token exchange on a two-factor sign-in 2026-10-31 04:27:27 +01:00
Linus Rath e3a34eb112 fix: tell screen readers which folder is open and how many are unread in it 2026-10-31 04:25:47 +01:00
Linus Rath 9b5692a8ab feat: offer to save an event without invitations when the server refuses to send them 2026-10-31 04:25:10 +01:00
Linus Rath 7ecac4ab43 fix: ask before the identity editor drops unsaved changes 2026-10-31 04:25:06 +01:00
Linus Rath 1cbe28b18c feat: open the sort orders from the list header's sort button 2026-10-31 04:24:00 +01:00
Linus Rath c8f417dd8f fix: hide preview lines in the density sample when previews are off 2026-10-31 04:23:15 +01:00
Linus Rath d3f6d6aea3 feat: pick any calendar colour with a hex field and more shades #90 2026-10-31 04:22:48 +01:00
Linus Rath 505fbc59ad fix: tell two accounts with the same name apart in settings 2026-10-31 04:21:37 +01:00
Linus Rath da1da0ec6b feat: offer undo after marking read or unread, starring or tagging from the list 2026-10-31 04:21:00 +01:00
Linus Rath 64eb97f4c3 fix: keep a shared calendar's own colour instead of a random one (bulwarkmail/webmail#1414) 2026-10-31 04:19:56 +01:00
Linus Rath 294023e2b2 fix: read month days with their date and event count and events with title, date and time to screen readers 2026-10-31 04:18:17 +01:00
Linus Rath b205c13a6e fix: show the two-factor code prompt as a hint, not a red error (bulwarkmail/webmail#1422) 2026-10-31 04:18:10 +01:00
Linus Rath e09cd395a2 feat: make a new tag from the tag picker and put it on the message right away 2026-10-31 04:16:56 +01:00
Linus Rath 9bd4674c9b chore: harvest the platform strings 2026-10-31 04:15:56 +01:00
Linus Rath c9b3bdfea0 feat: add an accessibility page with presets, larger text sizes and a notification duration 2026-10-31 04:15:55 +01:00
Linus Rath 9a4515f905 fix: let search results from other folders move into the open folder (bulwarkmail/webmail#1452) 2026-10-31 04:13:42 +01:00
Linus Rath 6c6991ace8 fix: keep system autofill out of the event participant field 2026-10-31 04:13:13 +01:00
Linus Rath b95f2c296d feat: delete a filter rule at once with an undo in the filter store 2026-10-31 04:11:36 +01:00
Linus Rath 4849cf233e feat: show typed, coloured file icons on the reader's attachment chips 2026-10-31 04:11:17 +01:00
Linus Rath 73a6adf27c docs: tick the parity rows the compose pass fixed 2026-10-31 04:09:43 +01:00
Linus Rath 6d701c4b06 feat: stop animations and pause animated images in mail when motion is reduced 2026-10-31 04:09:39 +01:00
Linus Rath 813be0c10f test: follow the text part options in the composer wiring check 2026-10-31 04:08:58 +01:00
Linus Rath 2951672548 chore: harvest the compose strings 2026-10-31 04:08:15 +01:00
Linus Rath 357a03c062 docs: correct the composer's note on sending from a shared account 2026-10-31 04:07:49 +01:00
Linus Rath 10ad3a40c7 test: check the composed body and scheduled preset times on both Stalwarts 2026-10-31 04:07:41 +01:00
Linus Rath 6c3046c8dc fix: keep the text typed in the Sieve editor when the script refreshes (bulwarkmail/webmail#1162) 2026-10-31 04:07:12 +01:00
Linus Rath dcbfc907fa feat: warn about the server's forward limit and an auto-reply too long for it (bulwarkmail/webmail#1155, bulwarkmail/webmail#1208) 2026-10-31 04:05:57 +01:00
Linus Rath 43973608d6 fix: show plain-text mail in the dark theme's own colours instead of a fixed black 2026-10-31 04:05:45 +01:00
Linus Rath 25f9b810f2 fix: address the user formally in the german phone strings 2026-10-31 04:05:39 +01:00
Linus Rath 303dea01e9 feat: ask for a description when an image goes into a message 2026-10-31 04:04:35 +01:00
Linus Rath e8bc54ebaa feat: add thai and estonian 2026-10-31 04:03:57 +01:00
Linus Rath 6f66fa065a fix: draw avatar initials in black or white, whichever reads better on the colour 2026-10-31 04:03:46 +01:00
Linus Rath a4d3891140 fix: give user folders in the move sheet a folder icon even when their name looks like a system folder (bulwarkmail/webmail#1235) 2026-10-31 04:01:54 +01:00
Linus Rath d23af55e4b chore: sync the webmail translations 2026-10-31 04:01:44 +01:00
Linus Rath 2c1f266fba feat: open the template manager from the composer's template picker 2026-10-31 04:00:30 +01:00
Linus Rath 7772da8463 fix: leave bounce and receipt report parts out of the list's attachment chips 2026-10-31 04:00:21 +01:00
Linus Rath 352973793f fix: hide reply on drafts and scheduled messages in a conversation and quick reply to the newest real message (bulwarkmail/webmail#1404) 2026-10-31 03:59:16 +01:00
Linus Rath 9fd8099369 feat: fold the quoted history of a reply behind a toggle in the composer (bulwarkmail/webmail#1405) 2026-10-31 03:57:26 +01:00
Linus Rath f6e6f8f570 fix: show a new mail notification when the push lookup fails (bulwarkmail/webmail#1041) 2026-10-31 03:56:49 +01:00
Linus Rath 6e697baa46 fix: keep the recurrence id of a daily series in another zone on the device's DST day 2026-10-31 03:54:52 +01:00
Linus Rath 9794dc03f7 feat: ask before deleting or archiving when the confirm settings are on 2026-10-31 03:54:33 +01:00
Linus Rath a3650ebf34 test: give the mail text size test a valid message spacing 2026-10-31 03:54:33 +01:00
Linus Rath 853cb5d052 docs: note that stalwart 1.0.1 keeps drafts and sent mail unencrypted at rest 2026-10-31 03:53:25 +01:00
Linus Rath 1bef47199c fix: sign in when stalwart 1.0.1 asks for consent instead of reporting a wrong password 2026-10-31 03:53:25 +01:00
Linus Rath 482e9a2014 fix: leave bounce and read receipt reports out of a forward 2026-10-31 03:53:02 +01:00
Linus Rath a43b55ec25 fix: say that a send failed because the device is offline and the message is still there 2026-10-31 03:52:14 +01:00
Linus Rath 7b551fc3e0 feat: scale the mail body with the app text size and the system font size 2026-10-31 03:51:04 +01:00
Linus Rath ae2eb46b29 fix: keep an escaped quote in a pasted display name from splitting off a recipient 2026-10-31 03:49:55 +01:00
Linus Rath d812caf025 fix: read toasts, the undo bar and the offline banner out on ios 2026-10-31 03:49:09 +01:00
Linus Rath 8bb7b60728 fix: open a reply with the caret in the body and keep system autofill out of the address fields 2026-10-31 03:48:53 +01:00
Linus Rath 10e9f81dc1 fix: ask before the event and task editors drop typed changes on close or back 2026-10-31 03:48:23 +01:00
Linus Rath 7dc457780d feat: read mail as plain text when plain text only is on, or per message from the menu 2026-10-31 03:48:14 +01:00
Linus Rath 8fe5798bf5 fix: show scheduled times rounded to the minute and offer this morning, this afternoon and Monday presets (bulwarkmail/webmail#1390) 2026-10-31 03:46:43 +01:00
Linus Rath ff5a8c91d6 fix: give the text fields in settings and sign-in a name for screen readers 2026-10-31 03:45:35 +01:00
Linus Rath a19afe9b48 feat: offer to remove a cancelled meeting from the calendar in the cancellation mail (bulwarkmail/webmail#1425) 2026-10-31 03:44:02 +01:00
Linus Rath 1cc4ccca8e fix: ask about a missing subject once, close an autosaved draft without asking and add Discard 2026-10-31 03:43:26 +01:00
Linus Rath a930d17786 fix: drop direction-override characters from attachment names so a sender can't fake the file type 2026-10-31 03:43:18 +01:00
Linus Rath 1152299583 fix: raise grey text to 4.5:1 contrast in every built-in theme 2026-10-31 03:42:24 +01:00
Linus Rath 0647cbf171 fix: mark quotes, number lists and keep the signature delimiter in the text part of sent mail 2026-10-31 03:40:56 +01:00
Linus Rath 3243714db9 fix: stop a mail full of blockquotes from freezing the reader 2026-10-31 03:40:34 +01:00
Linus Rath 55d008a2e8 fix: let the user cancel a sign-in that hangs 2026-10-31 03:39:39 +01:00
Linus Rath 8e10e73e29 fix: keep sender favicons off by default so sender domains don't go to duckduckgo 2026-10-31 03:38:44 +01:00
Linus Rath 0b0fc021fb fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439) 2026-10-31 03:37:46 +01:00
Linus Rath 60d0e98fc7 fix: keep a hand-picked sender's own signature and swap it after typing under it 2026-10-31 03:35:30 +01:00
Linus Rath a2344ea0ae fix: keep a new calendar's name and colour until the next refresh 2026-10-31 03:34:36 +01:00
Linus Rath db8eb731f1 fix: run the placeholder, attachment and confirm checks before a quick reply goes out 2026-10-31 03:33:16 +01:00
Linus Rath 49d4372918 fix: count a dkim pass the receiver reports by header.i only (bulwarkmail/webmail#1420) 2026-10-31 03:30:38 +01:00
Linus Rath 0e69c86c51 chore: add the confirm before send, delete and archive settings 2026-10-31 03:24:30 +01:00
Linus Rath 948cad814f docs: inventory the native catch-up with webmail 1.19.1 and stalwart 1.0.1 2026-10-31 03:20:58 +01:00
Linus Rath 7a42a15f8a feat: sign in through the bulwark-mobile client on stalwart 1.0 (bulwarkmail/webmail#1459) 2026-10-31 00:57:06 +01:00
Linus Rath 9d547b9d16 fix: decode escaped colons in imported vcard notes 2026-10-29 19:52:28 +01:00
Linus Rath cd6724f754 chore: update version number to 0.1.87 2026-10-29 12:10:41 +01:00
Linus Rath e6e5e521cf docs: report the native sync with webmail 1.18.0 and Stalwart 1.0.0 2026-10-29 05:27:41 +01:00
Linus Rath fe33dfc53f docs: tick the webmail 1.10 to 1.18 parity items this pass fixed 2026-10-29 05:26:33 +01:00
Linus Rath 595df26e40 Merge branch 'fx/compose2' into feat/stalwart-1.0 2026-10-29 05:03:02 +01:00
Linus Rath c817f978a4 fix: reload Scheduled after a switch of accounts and refuse actions on the other account's rows 2026-10-29 05:01:12 +01:00
Linus Rath 51b3208b3b fix: drop a cancelled scheduled send from the list when it can't go back to Drafts, and say it stayed in Sent 2026-10-29 04:59:32 +01:00
Linus Rath 7594e45937 fix: stop the placeholder check from flagging text inside link addresses 2026-10-29 04:55:40 +01:00
Linus Rath 39e82f99e4 fix: save the attachment itself to Files on Stalwart 0.16, under a name the folder has free 2026-10-29 04:54:11 +01:00
Linus Rath 67407c1119 Merge branch 'fx/cal2' into feat/stalwart-1.0 2026-10-29 04:51:16 +01:00
Linus Rath b767df9fad fix: save a calendar's default reminders when another calendar uses the ids of the ones kept 2026-10-29 04:50:15 +01:00
Linus Rath 6133bcd951 fix: trust an invitation that passed dmarc even when a forwarder broke its first signature 2026-10-29 04:50:15 +01:00
Linus Rath 47f2971899 fix: keep a subscription's events when its feed arrives cut off or drops most of them at once 2026-10-29 04:50:15 +01:00
Linus Rath 98dc13fe9c fix: stop a series edit or delete after an account switch instead of finishing it in the next account 2026-10-29 04:50:15 +01:00
Linus Rath c8600e859a fix: attach a quoted inline image under a plain image type, not the part's raw header 2026-10-29 04:49:31 +01:00
Linus Rath fdf8eece62 fix: keep a rescheduled send from going out twice or not at all when the server's answer is lost 2026-10-29 04:47:20 +01:00
Linus Rath 4e3da668f4 fix: keep the auto-reply preview from navigating to a data: page 2026-10-29 04:45:35 +01:00
Linus Rath ddec876e36 Merge branch 'fx/read2' into feat/stalwart-1.0 2026-10-29 04:44:36 +01:00
Linus Rath 8adef1e13f fix: never count a helo spf pass as verifying the sender 2026-10-29 04:41:02 +01:00
Linus Rath 893f40a21d fix: send group mail through the group's identity on Send now and quick reply 2026-10-29 04:40:19 +01:00
Linus Rath 67cdb4e792 fix: leave a message unread when the server refuses to move it to trash across accounts 2026-10-29 04:38:26 +01:00
Linus Rath 918571c3c6 fix: send a mailto unsubscribe from the own account and only to one decoded address 2026-10-29 04:36:56 +01:00
Linus Rath e9a360d750 Merge branch 'fx/contacts2' into feat/stalwart-1.0 2026-10-29 04:36:23 +01:00
Linus Rath d201ca24f8 test: check if/elsif/else scripts and UID re-imports live on Stalwart 0.16 and 1.0 2026-10-29 04:34:56 +01:00
Linus Rath 3c156673d5 fix: ask before a contact import replaces a card with the same UID 2026-10-29 04:34:17 +01:00
Linus Rath ba67205052 fix: stop re-rendering every message row when the shared tags are re-read unchanged 2026-10-29 04:33:54 +01:00
Linus Rath 7b7236f05d fix: keep a rule made from a message out of a login switched to while the sheet was open 2026-10-29 04:33:16 +01:00
Linus Rath af1f8197d9 fix: keep a login switch during a vacation save out of the other login's filters 2026-10-29 04:32:26 +01:00
Linus Rath 2b5257bf6b fix: batch the default search across accounts into shared requests again 2026-10-29 04:32:20 +01:00
Linus Rath 54aa88425a fix: say so when the filters stay off after turning on the auto-reply 2026-10-29 04:31:18 +01:00
Linus Rath fc9c164d3d fix: keep if/elsif/else chains whole when filters are saved on the phone 2026-10-29 04:29:26 +01:00
Linus Rath 440a2e7ac7 fix: scrub only real stylesheets and keep their css escapes when blocking remote content 2026-10-29 04:29:23 +01:00
Linus Rath 47a1d24999 fix: keep a quoted stylesheet from ending the composer's editor script 2026-10-29 04:25:57 +01:00
Linus Rath e7e40c309e test: give the pairing hand-off tests the redirect-safe fetch the sign-in code now goes through 2026-10-29 04:22:20 +01:00
Linus Rath 07679cbf8c fix: keep a hostile inline image type out of the reader and the composer 2026-10-29 04:22:05 +01:00
Linus Rath 79ee9c34ca Merge branch 'fx/compose' into feat/stalwart-1.0 2026-10-29 04:20:07 +01:00
Linus Rath 91175a3382 Merge branch 'fx/cal' into feat/stalwart-1.0 2026-10-29 04:16:46 +01:00
Linus Rath 6414b0d6a9 fix: send from the own account when a group's identities can't be read, and clear a draft save stopped by an account switch 2026-10-29 04:16:08 +01:00
Linus Rath 1ffce863d9 test: check rescheduling, Cancel send, Files blobs and metadata templates live on Stalwart 0.16 and 1.0 2026-10-29 04:14:29 +01:00
Linus Rath 25425cb940 chore: add the event search texts the catalog lacked 2026-10-29 04:13:00 +01:00
Linus Rath b50270d8f3 feat: search events in the calendar 2026-10-29 04:12:48 +01:00
Linus Rath fac318dc75 feat: keep templates in the account on Stalwart 1.0 and offer team templates in the picker 2026-10-29 04:11:28 +01:00
Linus Rath 7dc9ee24db Merge branch 'fx/read' into feat/stalwart-1.0 2026-10-29 04:10:53 +01:00
Linus Rath 64ce86b372 Merge branch 'fx/contacts' into feat/stalwart-1.0 2026-10-29 04:08:31 +01:00
Linus Rath 9b87100a15 feat: set a calendar's default reminders and start new events with them 2026-10-29 04:02:40 +01:00
Linus Rath 1bce37bb56 fix: read only the receiving server's own authentication results when trusting an invitation 2026-10-29 03:56:54 +01:00
Linus Rath 8e07b7d891 feat: attach files from Files and save attachments to Files 2026-10-29 03:51:33 +01:00
Linus Rath 96c7c0455d fix: give user folders a folder icon even when their name looks like a system folder #80 2026-10-29 03:50:08 +01:00
Linus Rath ada20598d4 fix: keep only the refused rows of a delete or move across accounts and never fail a delete over its read flag 2026-10-29 03:48:04 +01:00
Linus Rath 61654d7c40 fix: keep a sign-in code from following a redirect to another host 2026-10-29 03:44:57 +01:00
Linus Rath 01983a3810 fix: keep the event editor's bottom fields above the keyboard #81 2026-10-29 03:44:43 +01:00
Linus Rath 81061c0d0e test: check contacts and filters live on Stalwart 0.16 and 1.0 2026-10-29 03:44:42 +01:00
Linus Rath 7a1031a990 fix: show the location and meeting link text in dark mode #82 2026-10-29 03:44:01 +01:00
Linus Rath 16bbb97ed2 Merge branch 'fx/auth3' into feat/stalwart-1.0 2026-10-29 03:43:39 +01:00
Linus Rath b7b9c7e076 feat: show the shared tags of group mailboxes, read from their metadata on Stalwart 1.0 2026-10-29 03:43:27 +01:00
Linus Rath 639aa4aa42 fix: drop a signed-out account's calendar feeds, and clear recent searches when an eviction removes the last account 2026-10-29 03:42:07 +01:00
Linus Rath d546d50923 feat: create filter rules from a message's sender, domain or mailing list 2026-10-29 03:41:18 +01:00
Linus Rath 31d8e4a4b7 feat: send and save drafts from a group or shared identity in that account 2026-10-29 03:40:42 +01:00
Linus Rath cfd81737e3 fix: drop tokens refreshed for an account that signed out meanwhile 2026-10-29 03:39:15 +01:00
Linus Rath acbd085ab4 fix: end a zero-length event at its start instead of at midnight 2026-10-29 03:38:25 +01:00
Linus Rath 06d68d73ee fix: keep refresh tokens, sign-in codes and OAuth metadata from following a redirect to another host 2026-10-29 03:36:49 +01:00
Linus Rath 0fd14a837a feat: browse the company directory in Contacts #1253 2026-10-29 03:34:44 +01:00
Linus Rath a8e7a3f49d feat: warn before sending a message with unfilled placeholders 2026-10-29 03:33:49 +01:00
Linus Rath fe4f92112d fix: sign in with password and two-factor code when the phone reports the server address without its default port or in punycode 2026-10-29 03:31:06 +01:00
Linus Rath f83284d7e5 fix: fold exported .ics lines by octets and quote attendee names 2026-10-29 03:30:14 +01:00
Linus Rath 484cac58b7 fix: search Sent, Archive and Drafts from the unified All mail view 2026-10-29 03:30:12 +01:00
Linus Rath 7c94039a7e Merge branch 'fx/auth' into feat/stalwart-1.0 2026-10-29 03:28:51 +01:00
Linus Rath a412b5163a feat: leave Spam and Trash out of search unless All folders is picked 2026-10-29 03:28:34 +01:00
Linus Rath e64f8629da Merge branch 'fx/push2' into feat/stalwart-1.0 2026-10-29 03:28:31 +01:00
Linus Rath 07fd03e284 fix: export contacts as vCard 4.0 that other apps read in full #1407 2026-10-29 03:28:10 +01:00
Linus Rath 32f70c103d fix: save contacts with calendar or scheduling links and re-import exported vCards by UID 2026-10-29 03:28:09 +01:00
Linus Rath 59c3278259 fix: leave blank participant names out of saved events 2026-10-29 03:27:44 +01:00
Linus Rath 152f308966 fix: tear down a removed account's push through its own client, not the open account's 2026-10-29 03:27:40 +01:00
Linus Rath 14d009de32 feat: open scheduled messages in a read-only view, and send cancelled ones back to Drafts #84 2026-10-29 03:26:32 +01:00
Linus Rath 04ff8728da Merge branch 'fx/files' into feat/stalwart-1.0 2026-10-29 03:26:03 +01:00
Linus Rath 0fb7c8cd09 fix: show an invitation's time in the user's time zone, with its name 2026-10-29 03:26:00 +01:00
Linus Rath b352e470c1 fix: keep a push for another login's group account out of the login whose own id it carries 2026-10-29 03:25:05 +01:00
Linus Rath 946304ab76 test: check the Files trash fixes live on Stalwart 0.16 and 1.0 2026-10-29 03:24:43 +01:00
Linus Rath 7a95d43f1a fix: trust an invitation only on checks aligned with its sender and stop flagging colleagues' invitations 2026-10-29 03:22:46 +01:00
Linus Rath 58bc4acac5 fix: keep loading older mail when the server sends no total bulwarkmail/webmail#1364 2026-10-29 03:20:49 +01:00
Linus Rath 22f2ac5b29 fix: cancel the original before scheduling its replacement on reschedule and send now, and put it back when that fails 2026-10-29 03:20:32 +01:00
Linus Rath 90e250d376 fix: go back to the previous push relay whenever a move to a new one fails 2026-10-29 03:20:06 +01:00
Linus Rath 3bfdf8cef9 fix: give a restored file with a clashing WebDAV name a free name the server accepts 2026-10-29 03:19:43 +01:00
Linus Rath 4de856923b fix: keep daily recurring events going past a DST gap 2026-10-29 03:18:00 +01:00
Linus Rath 5ea19a8581 fix: restore a trashed item into its folder when that folder is restored with it 2026-10-29 03:17:48 +01:00
Linus Rath b1550f143b feat: offer the code of sign-in and verification mails for copying in the list and the reader 2026-10-29 03:15:09 +01:00
Linus Rath 6f475c0c52 fix: remind of a task relative to its start when the reminder says so #83 2026-10-29 03:14:58 +01:00
Linus Rath 4a56e825e4 fix: work out where trashed items came from once per listing instead of once per row 2026-10-29 03:13:31 +01:00
Linus Rath 9d1337bf71 Merge branch 'fx/push' into feat/stalwart-1.0 2026-10-29 03:12:56 +01:00
Linus Rath d57bdfaace fix: send writes only once after a dropped connection, retry only batches of reads 2026-10-29 03:12:41 +01:00
Linus Rath 01abe1007c test: check the browser sign-in client, refresh after sign-out and the TOTP code for account changes live on Stalwart 0.16 and 1.0 2026-10-29 03:11:40 +01:00
Linus Rath 6b9552fcb4 fix: keep contacts loaded or saved for one account out of the next after a switch 2026-10-29 03:10:37 +01:00
Linus Rath d908f95fd7 fix: refuse .Trash as a name at the top of Files, where it would become the trash 2026-10-29 03:10:35 +01:00
Linus Rath c6c3d66a9b test: check feed refreshes and a task load overtaken by an account switch on the live servers 2026-10-29 03:10:30 +01:00
Linus Rath cc21317b5a feat: warn about unverified senders in the reader and stop offering Always trust for them 2026-10-29 03:09:04 +01:00
Linus Rath e1e6af3cca fix: offer to delete permanently when a Files delete can't go to the trash 2026-10-29 03:07:40 +01:00
Linus Rath df1a5ab4b6 feat: set up vacation forwarding, reply recipients, rule periods and all-messages rules on the phone #1152 2026-10-29 03:07:09 +01:00
Linus Rath 855d304b4f fix: stop a crafted winmail.dat from freezing the app 2026-10-29 03:05:04 +01:00
Linus Rath 3eac700aa2 fix: keep the draft and say so when the server does not confirm a send 2026-10-29 03:04:14 +01:00
Linus Rath 986216f4d6 fix: clear recent searches and calendar feeds when the last account signs out 2026-10-29 03:03:58 +01:00
Linus Rath 8be9743b80 fix: keep the Files trash out of the Recent files widget 2026-10-29 03:03:57 +01:00
Linus Rath c84c9ddde7 test: check that a range read stops at an account switch 2026-10-29 03:03:21 +01:00
Linus Rath 942f45179c fix: send a mailto unsubscribe to one address only and show what it sends 2026-10-29 03:02:33 +01:00
Linus Rath caeec2bdab fix: let Undo of a Files delete do nothing once another account is signed in 2026-10-29 03:00:35 +01:00
Linus Rath 99a4212804 fix: ask for the current code to change the password or turn off two-factor with TOTP on 2026-10-29 03:00:18 +01:00
Linus Rath a3b8b9fa9f test: check id reuse, the expiry read-back, the quota-before-address order and the Delete patch on live Stalwart 2026-10-29 03:00:16 +01:00
Linus Rath c0c3429c75 fix: forget a shared calendar refusal that arrives after a new sign-in 2026-10-29 02:59:53 +01:00
Linus Rath 4e57b19112 fix: keep attachments, sends and reply flags of an open draft out of a login switched to meanwhile 2026-10-29 02:59:42 +01:00
Linus Rath ef9561f16b fix: stop senders from faking a DKIM or DMARC pass in the authentication chips 2026-10-29 02:59:27 +01:00
Linus Rath d92a2ca835 fix: renew push for every signed-in account and show each account's push state in the notification settings 2026-10-29 02:57:24 +01:00
Linus Rath 323fea7451 fix: undo and restore against the drive as it is now, not the listing from before the delete 2026-10-29 02:57:10 +01:00
Linus Rath 41e8f00509 fix: keep a calendar write that outlasts an account switch out of the next account 2026-10-29 02:57:07 +01:00
Linus Rath 2167a9d700 fix: keep webmail filter periods, forwarding and exact address rules through a phone save #1152 2026-10-29 02:57:00 +01:00
Linus Rath e81f00714c fix: undo and send now of a delayed send only reach the login it was sent from 2026-10-29 02:56:19 +01:00
Linus Rath f5e997ba92 fix: drop event handlers that follow a slash and run no inline handlers in the reader and composer 2026-10-29 02:54:23 +01:00
Linus Rath be04fcb179 fix: delete permanently only what is still in the trash, and read the drive again on returning to Files 2026-10-29 02:53:53 +01:00
Linus Rath 9ccb62286e fix: show a plain new-mail notification instead of another login's message when two logins share a JMAP account id 2026-10-29 02:51:24 +01:00
Linus Rath e47c05c58a fix: choose the reply From by an address the original names only in Bcc #87 2026-10-29 02:50:16 +01:00
Linus Rath a2caa4cca5 fix: register the app's own client on Stalwart 1.0 behind another public address and say when the server can't be reached 2026-10-29 02:44:00 +01:00
Linus Rath e993540ae6 fix: load the calendar again after an account switch overtook its first load 2026-10-29 02:43:21 +01:00
Linus Rath 65f00640ba fix: mark a message read when the notification's Delete moves it to Trash and the delete action says so #86 2026-10-29 02:42:00 +01:00
Linus Rath 77327ea856 fix: never patch or judge another client's push subscription that reuses this device's subscription id 2026-10-29 02:40:40 +01:00
Linus Rath 8a71495e1a fix: apply subscribed feed changes to imported events and stop a feed sync when the account changes 2026-10-29 02:40:13 +01:00
Linus Rath 28d928f6e9 fix: stop a refresh after sign-out from writing the removed account's tokens back 2026-10-29 02:39:31 +01:00
Linus Rath ce47c73f33 fix: keep extending push in the background after the app refreshed the subscription at launch 2026-10-29 02:37:52 +01:00
Linus Rath 388f46b5b9 fix: let the composer send and save again in the account it was opened in 2026-10-29 02:36:09 +01:00
Linus Rath d3d28d551e fix: show the relay push still goes through after the mail server refused a new one 2026-10-29 02:35:36 +01:00
Linus Rath 867ef20e51 Merge branch 's10/mail' into feat/stalwart-1.0 2026-10-29 02:34:22 +01:00
Linus Rath 2c05e7bd00 fix: keep the password sign-in from following a redirect to another server 2026-10-29 02:33:41 +01:00
Linus Rath db47162367 fix: register with the previous push relay again when the mail server refuses a new one 2026-10-29 02:33:11 +01:00
Linus Rath 93ea948045 fix: keep the working push registration when a re-register at the subscription limit moves to another relay 2026-10-29 02:31:06 +01:00
Linus Rath 7c91418336 feat: keep deleted files in a Files trash with Restore, Delete permanently and Empty trash 2026-10-29 02:30:36 +01:00
Linus Rath 80962e3079 Merge branch 's10/cal' into feat/stalwart-1.0 2026-10-29 02:27:44 +01:00
Linus Rath 3047d5c495 Merge branch 's10/push' into feat/stalwart-1.0 2026-10-29 02:22:54 +01:00
Linus Rath 8893a9e21e Merge branch 's10/auth' into feat/stalwart-1.0 2026-10-29 02:22:07 +01:00
Linus Rath 111af93a09 docs: say that Stalwart 1.0.0 serves JMAP Metadata in the folder and Sieve property comments 2026-10-29 02:22:05 +01:00
Linus Rath d76f296587 test: check keywords, submissions, contacts, search, Sieve and identities live on Stalwart 0.16 and 1.0 2026-10-29 02:22:05 +01:00
Linus Rath 547d9e5939 fix: drop the deleted messages from the list when the server refuses others of a bulk delete 2026-10-29 02:20:10 +01:00
Linus Rath 9daa3441c8 fix: keep a composer left open across an account switch from saving or deleting drafts in the other account 2026-10-29 02:15:14 +01:00
Linus Rath f733a54777 test: check the calendar live on Stalwart 1.0.0 and 0.16 with series, overrides, invitations and shares #89 2026-10-29 02:13:04 +01:00
Linus Rath b6c0cf1820 docs: say that Stalwart 1.0.0 answers the Metadata properties the calendar list leaves out 2026-10-29 02:12:16 +01:00
Linus Rath 7c495d03ab fix: let files in a shared folder be renamed and deleted on Stalwart 1.0, which sends rights for folders only 2026-10-29 02:11:28 +01:00
Linus Rath e03a38001d test: check push subscriptions live on Stalwart 0.16 and 1.0 2026-10-29 02:11:09 +01:00
Linus Rath 15171637cd fix: keep the own and a group's scheduled mail apart when their ids are the same 2026-10-29 02:10:26 +01:00
Linus Rath 87472ae937 Merge branch 's10/files' into feat/stalwart-1.0 2026-10-29 02:09:48 +01:00
Linus Rath 2f43adbc07 test: check sign-in, token renewal, sign-out and account security live on Stalwart 0.16 and 1.0 2026-10-29 02:09:21 +01:00
Linus Rath 9804b49979 fix: keep push working when the app isn't opened for a week 2026-10-29 02:08:10 +01:00
Linus Rath 1f190c9402 fix: keep one account's tasks and events out of another's calendar after an account switch 2026-10-29 02:07:52 +01:00
Linus Rath c6eb02d4cd fix: say why account security is unavailable when Stalwart 1.0 limits the app's sign-in 2026-10-29 02:07:47 +01:00
Linus Rath 1456e15d5c fix: keep accounts signed in when Stalwart 1.0 rotates the refresh token 2026-10-29 02:07:46 +01:00
Linus Rath 6ffd0473e5 fix: sign in through the server's own sign-in page on Stalwart 1.0 2026-10-29 02:07:46 +01:00
Linus Rath 96ef85159b fix: sign in with password and two-factor code on Stalwart 1.0 2026-10-29 02:07:36 +01:00
Linus Rath ec7e8e0530 docs: check webmail 1.10.0 to 1.18.0 against the app 2026-10-29 02:07:01 +01:00
Linus Rath 4d545f8f58 fix: show inline images whose part name is a path 2026-10-29 02:06:12 +01:00
Linus Rath 4ec2ccac11 fix: say the mail server refuses push relays that are not on a public domain 2026-10-29 02:03:24 +01:00
Linus Rath 18b8aa096f fix: send writes in shared folders to the account that holds them and offer the ones its rights allow 2026-10-29 02:01:47 +01:00
Linus Rath 20be110537 fix: keep the device sync push types when a subscription write fails for another reason 2026-10-29 02:00:50 +01:00
Linus Rath 7c775571c9 fix: load the calendar on Stalwart 0.16 when a range starts or ends in the hour the clocks change 2026-10-29 01:58:24 +01:00
Linus Rath 7d8e073587 fix: list files on Stalwart 1.0 when the account holds more nodes than one get allows #1385 2026-10-29 01:54:55 +01:00
Linus Rath f3e11d4c7c fix: stop asking an account that shares only mail for its calendars on every load on Stalwart 1.0 2026-10-29 01:54:55 +01:00
Linus Rath 913b37661f fix: send the quoted inline images along with replies and forwards 2026-10-29 01:53:59 +01:00
Linus Rath d2bb479ac6 fix: keep inline images of drafts and sent mail on Stalwart 1.0 2026-10-29 01:53:57 +01:00
Linus Rath 174c3fc8fa fix: create folders and upload files on Stalwart 1.0 and tell its symlinks from folders 2026-10-29 01:53:51 +01:00
Linus Rath 24232bf2e9 test: seed the live Stalwart test's own mail so it runs on any account and on 1.0 2026-10-29 01:38:30 +01:00
Linus Rath df4cb7688d fix: offer only the vacation and favicon settings the app has in the settings search 2026-10-29 01:37:46 +01:00
Linus Rath f1db6b488a Merge branch 'main' into feat/stalwart-1.0 2026-10-29 01:30:03 +01:00
Linus Rath f358d60584 fix: place calendar events at their clock time on DST change days 2026-10-25 14:18:45 +01:00
Linus Rath 9857fe1711 chore: update version number to 0.1.86 2026-10-25 12:14:47 +01:00
Linus Rath b4331a9bf9 feat: sign in with an access token 2026-10-25 12:14:30 +01:00
Linus Rath c08e9d6fa8 chore: update version number to 0.1.85 2026-10-23 20:23:48 +02:00
Linus Rath 470e7fc3ba fix: keep the drawers' account header below the status bar and sheet rows above the home indicator on iPhone 2026-10-23 19:51:48 +02:00
Linus Rath b3bd19cc8c ci: roll every android release out to the internal and closed testing tracks on google play 2026-10-23 16:48:22 +02:00
Linus Rath 95cfa809ef chore: update version number to 0.1.84 2026-10-22 23:09:57 +02:00
Linus Rath 3d3f4aec5a feat: log why the iOS notification extension falls back to the placeholder 2026-10-22 22:53:43 +02:00
Linus Rath 8303ff769f feat: let the iOS notification extension reach the active account without push setup 2026-10-22 22:53:42 +02:00
Linus Rath 7078dfe806 ci: sign the iOS notification extension with its own App Store profile 2026-10-22 22:53:42 +02:00
Linus Rath f62849d1fe feat: turn on push notifications on iOS through the relay's APNs transport 2026-10-22 22:53:42 +02:00
Linus Rath 4bb846b726 feat: add an iOS notification extension that shows sender and subject 2026-10-22 22:53:42 +02:00
Linus Rath 2618680b21 feat: keep iOS credentials where a notification extension can read them 2026-10-22 22:53:42 +02:00
Linus Rath 3424bcf2e9 perf: show the cached inbox at start before the server's session arrives 2026-10-22 22:04:33 +02:00
Linus Rath d13769a9f2 perf: load screens that aren't on the way to the inbox on first visit 2026-10-22 22:04:33 +02:00
Linus Rath 0c8b8179b2 perf: load JS modules on first use instead of at startup 2026-10-22 22:04:33 +02:00
Linus Rath 533f176e88 test: time each module's evaluation at start-up in builds made for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath da72415ef3 test: mark the steps of the start-up restore for the performance harness 2026-10-22 22:04:29 +02:00
Linus Rath 0109273037 fix: start typing in the message body when the empty space under it is tapped 2026-10-22 22:04:29 +02:00
Linus Rath 33eb8f99e5 fix: show the undo button on the undo bar in dark mode 2026-10-22 22:04:29 +02:00
Linus Rath 721c2e34a1 fix: sign out of the last account instead of staying signed in 2026-10-22 22:04:28 +02:00
Linus Rath e06d92d88f fix: open the photo, camera and file pickers from the compose attach sheet on ios 2026-10-22 22:04:28 +02:00
Linus Rath 42e89122bd test: mark where the time goes when a message opens 2026-10-22 22:04:28 +02:00
Linus Rath 33fc33910a test: add test ids to the contact form's name and email fields 2026-10-22 22:04:28 +02:00
Linus Rath dde504f825 fix: let screen readers reach each option in sheets and menus 2026-10-22 22:04:28 +02:00
Linus Rath f5f3637d01 fix: keep full-screen modal headers below the status bar on iPhones with a notch 2026-10-22 22:04:28 +02:00
Linus Rath bdade8503d test: mark how long each persisted cache takes to load 2026-10-22 22:04:28 +02:00
Linus Rath 57ca1c8083 test: log timing marks for the performance harness when launched with -perfMarks 2026-10-22 22:04:28 +02:00
Linus Rath 32a41f42d3 ci: report the slowest modules at ios start-up in the perf leg 2026-10-22 22:04:24 +02:00
Linus Rath ad9db5cbff ci: tap undo at once, search for every settings page and scroll message rows clear of the ios tab bar 2026-10-22 21:57:31 +02:00
Linus Rath 99e4bd9614 ci: let ios runs with different legs on one branch run side by side 2026-10-22 20:19:48 +02:00
Linus Rath 239bf618b5 ci: search for ipad settings pages, tap the attachment chip by name and move to the body with return in the ios flows 2026-10-22 20:12:59 +02:00
Linus Rath 58e52421e9 ci: report the ios start-up timeline and cache load times in the perf leg 2026-10-22 18:16:33 +02:00
Linus Rath 96ffeaa0e2 ci: pick a photo in the ios compose flow, settle settings rows before tapping, answer prompts in portrait and tap undo sooner 2026-10-22 17:11:21 +02:00
Linus Rath 81a0723abd ci: report the first ios launch after install apart from the cold starts after it 2026-10-22 16:19:03 +02:00
Linus Rath 6103df86a5 ci: wait longer for the sign-in link prompt after a sign-out on ios 2026-10-22 14:18:30 +02:00
Linus Rath b97a877fce ci: let the first ios push banner go before tapping the next 2026-10-22 14:04:11 +02:00
Linus Rath 663f6f0007 ci: push and tap the ios banner again when the first tap misses 2026-10-22 13:56:52 +02:00
Linus Rath 5477cc039a ci: wait for the message toolbar instead of reply all when opening a message on ios 2026-10-22 13:51:07 +02:00
Linus Rath ab8e8d7e1e ci: answer the notification prompt, retry server continue and file delete, and accept a starred row in the ios flows 2026-10-22 13:50:35 +02:00
Linus Rath 49e57482cc ci: scroll ios settings pages to their check text and steady the calendar reminder and compose body steps 2026-10-22 13:49:11 +02:00
Linus Rath 5f06c3918c ci: tap the ios push banner right after the push 2026-10-22 13:38:54 +02:00
Linus Rath 96471cf13b ci: scroll to the seeded inbox row when checking the ios sign-in 2026-10-22 13:20:23 +02:00
Linus Rath 3575ed9450 ci: tap the ios notification again until the app opens 2026-10-22 13:04:59 +02:00
Linus Rath f181c73a56 ci: tap the ios push notification in notification center, not the fading banner 2026-10-22 12:56:58 +02:00
Linus Rath c85e32c9f4 ci: test the ios push banner and tap without the service extension under simctl 2026-10-22 11:58:19 +02:00
Linus Rath f2b98c6dba ci: erase and retype web sign-in fields instead of selecting their text 2026-10-22 11:52:00 +02:00
Linus Rath 653d5965ed ci: scroll the ipad settings column to sign out and retry simctl pbcopy 2026-10-22 11:43:34 +02:00
Linus Rath 01d15fd24f ci: retry the ios maestro warm-up once when the driver dies 2026-10-22 11:28:15 +02:00
Linus Rath 35e44318b9 ci: check the ipad message pane and scroll to rows before long-press in the ios flows 2026-10-22 11:27:29 +02:00
Linus Rath 3bdafefb65 ci: close the ios photo picker without losing the composer and focus the body first 2026-10-22 11:26:36 +02:00
Linus Rath 982a23cafd ci: fix the ios settings, calendar and swipe flow steps 2026-10-22 11:22:51 +02:00
Linus Rath c39e977de5 ci: catch the ios push banner and the extension's log 2026-10-22 10:36:14 +02:00
Linus Rath 24948cb717 ci: keep the app's js log and pin the sign-out dialog button 2026-10-22 10:30:39 +02:00
Linus Rath 6b434346ff ci: give the ios test legs more time 2026-10-22 10:17:49 +02:00
Linus Rath 1827f8eb34 ci: run the ios perf flows in one maestro process and report every round 2026-10-22 10:17:17 +02:00
Linus Rath 9c549c9d00 ci: keep the notification logs and settings of the ios push test 2026-10-22 10:16:34 +02:00
Linus Rath f00cd6a259 ci: check and retype web sign-in fields in the ios flows 2026-10-22 09:54:21 +02:00
Linus Rath 62931bcc53 ci: grant notification permission before the ios push test 2026-10-22 09:43:20 +02:00
Linus Rath 5622d732e9 ci: steady the ios flows around transitions, pickers and covered fields 2026-10-22 09:32:11 +02:00
Linus Rath db3c6b5ba1 ci: test ios push notifications on the simulator with simctl push 2026-10-22 09:04:27 +02:00
Linus Rath 680d4745ed ci: run each ios perf measurement as its own flow 2026-10-22 08:29:55 +02:00
Linus Rath 8533bd4f99 ci: fix ios feature flow steps for the drawer, search, selection, calendar and contacts 2026-10-22 08:20:49 +02:00
Linus Rath 52975bd715 ci: test the webmail hand-off with a webmail session left in the browser 2026-10-22 04:45:29 +02:00
Linus Rath bf226c3794 ci: fix ios sign-out, scanner and add-account flow steps 2026-10-22 04:44:23 +02:00
Linus Rath ca09708fd1 ci: match ios drawer rows by their joined label and keep failed-step hierarchies 2026-10-22 04:40:42 +02:00
Linus Rath 96d9d54cc9 ci: type the first character alone in ios flows and answer the webmail hand-over prompt 2026-10-22 03:55:43 +02:00
Linus Rath 02bd714afd ci: add an ios performance leg that compares two simulator builds 2026-10-22 03:11:39 +02:00
Linus Rath e57ee3d446 ci: sign the ios simulator build ad hoc so the keychain has its entitlements 2026-10-22 03:05:51 +02:00
Linus Rath 08e2f47187 ci: sign the ios simulator app ad hoc so the keychain works, and fix flow selectors 2026-10-22 02:54:00 +02:00
Linus Rath de414f7ed6 ci: run each ios flow in its own maestro process and add the feature flows 2026-10-22 02:15:35 +02:00
Linus Rath 273051e5f5 ci: run sign-in flows for every method on iphone and ipad simulators 2026-10-22 01:54:03 +02:00
Linus Rath fa68b2eb53 ci: type a test value in the ios warm-up flow and log what the driver does 2026-10-22 01:28:13 +02:00
Linus Rath 3748a64b10 ci: turn off the ios slide-to-type tip that swallows typed text in simulator tests 2026-10-22 01:15:24 +02:00
Linus Rath 2078388e6f ci: log why the maestro ios driver fails to start and allow reusing a built app 2026-10-22 00:49:38 +02:00
Linus Rath ea9bad7cff ci: run maestro flows on the ios simulator 2026-10-22 00:28:41 +02:00
Linus Rath ae0b76442a test: add stable test ids to sign-in fields, tabs and message rows 2026-10-22 00:28:41 +02:00
Linus Rath 341cdb0e61 feat: build a Google Play bundle alongside the GitHub APK
The app now has two product flavors. `github` is the sideloaded APK as
before, with the same package name and its in-app updater. `play` is a
bundle for Google Play as `org.bulwarkmail.mobile`, without the updater
or the installer permission, since Play forbids apps updating themselves.

- `release-android.yml` builds the Play bundle in a second job, checks
  its signer, package name and permissions, and keeps it as an artifact.
  With `PLAY_SERVICE_ACCOUNT_JSON` set it uploads it to internal testing.
- The Play flavor uses the new Firebase project; the GitHub flavor keeps
  the old one until the app re-registers its token when it changes.
- Widget clicks and the device-sync account type follow the package name.
- Photos come from the system picker without asking for media access,
  and the unused microphone permission from expo-camera is removed.
- A manual workflow run without a tag no longer replaces the APK of the
  latest release.
2026-10-20 12:56:44 +02:00
Linus Rath 1c9bbcc383 chore: update version number to 0.1.83 2026-10-19 13:16:57 +02:00
Linus Rath c7eda4735c feat: trust user-installed CAs and pick the TLS client certificate before the first sign-in #3 2026-10-19 13:16:47 +02:00
Linus Rath 5f5ffdaddb feat: mute notifications for single folders or whole accounts #78 2026-10-19 13:11:12 +02:00
Linus Rath 083ec958a0 chore: sync the locale catalogs from the webmail 2026-10-19 13:03:52 +02:00
Linus Rath 2b486cb1ef fix: show tags with no local definition in the message header, as the list does #79 2026-10-19 13:02:29 +02:00
Linus Rath 86400b124a chore: update version number to 0.1.82 2026-10-16 19:46:04 +02:00
Linus Rath 323797b4ee fix: release a PDF that finishes opening after its preview was closed 2026-10-16 19:42:04 +02:00
Linus Rath 9166b0fb3c docs: note which files the preview shows 2026-10-16 19:40:59 +02:00
Linus Rath 1f253e9488 fix: stop a PDF page rendered after its preview closed from leaving files behind 2026-10-16 19:40:59 +02:00
Linus Rath d22fb8d5d0 feat: open the files inside ZIP archives and attached messages 2026-10-16 19:39:57 +02:00
Linus Rath e0f18713aa chore: update version number to 0.1.81 2026-10-16 19:27:04 +02:00
Linus Rath 56d826c508 test: cover how the PDF preview reports protected and unreadable files 2026-10-16 19:24:19 +02:00
Linus Rath e5492b89c8 feat: show PDFs in the app on Android 2026-10-16 19:23:17 +02:00
Linus Rath b50436b1ba chore: update version number to 0.1.80 2026-10-16 19:12:43 +02:00
Linus Rath 11e74042e2 fix: hand files with a generic type to other apps by the type their name implies 2026-10-16 19:11:50 +02:00
Linus Rath 95580b84ea feat: play video and audio, and show SVG, HTML and Markdown files in the preview 2026-10-16 19:11:24 +02:00
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

The drawer's long-press panel gains Move up / Move down for the user's
own calendars. A move renumbers that account's calendars 1, 2, 3... and
writes sortOrder only where it changes; the panel stays open so a
calendar can be moved several places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).

Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:49:57 +02:00
lucletoffeandClaude Opus 5.5 767d4b9f9f fix: keep a re-opened draft's attachments on live blobs across saves and sends
A draft's attachments and inline images are MIME parts of that draft, and
their blobIds die with the version that holds them. Each save creates the
next version and then destroys the previous one (#849), but the composer kept
sending the blobIds it was seeded with. The first save worked, every later
save failed with "blobId ... does not exist on this server", and so did the
send. This is the second half of webmail #849, which the webmail fixed in
5d2b937a but which was never ported here.

- saveDraftVersion() reads the new version's attachment parts back in the
  same request (Email/get on the creation id "#draft"; Stalwart 0.16 does not
  resolve a result reference into Email/set "created"). createDraft() keeps
  its signature on top of it.
- The composer maps each held blobId to the matching part of the latest
  version (inline by Content-ID, the rest by name and size) and sends that.
  Uploads are re-pointed too, since an unreferenced upload expires. The map
  lives in a ref, so the dirty snapshot does not change on every save.
- Opening a draft with inline images no longer looks like an edit. The
  hydration swapped the editor body for its normalised DOM HTML but compared
  it with the raw server HTML, so the autosave replaced the draft about two
  seconds after it opened. That destroyed the opened version, and a plain
  "open, then Send" failed with the same blobNotFound.

Tests: unit tests for the matching and rebasing, a fake-server test that
reproduces the failure without the rebase, and an opt-in live Stalwart test
(verified against Stalwart 0.16.22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
lucletoffeandClaude Opus 5.5 45b4feeb58 feat(unified): let a single account turn on All mail / All unread / All starred
The drawer hid the whole Unified section unless a second account (with
cross-account on) or a merged group inbox fed it (#843), so a single
account never reached the cross-folder views - although they span the
inbox and every custom folder and repeat no single folder. Sieve-sorted
mail could only be read folder by folder.

Port the webmail's per-view toggles (enableCrossUnreadView,
enableCrossStarredView, enableCrossAllView; off by default) to Settings >
Layout. A turned-on view shows in the Unified section even when the
section would otherwise stay hidden; "All inboxes" and the per-role rows
keep the #843 rule. Alongside the section all three still show, as
before.

The toggles are labelled with the drawer rows' names: their
`label_mobile` in each RN overlay is that language's
`sidebar.unified_all_*` from the webmail catalog, so settings search
lands on the row. Descriptions are the webmail's own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:40:35 +02:00
Linus Rath e420e3d576 fix: keep an occurrence's own change when another one moves onto its day
Changing every occurrence of a series takes its changed and cancelled
occurrences along, and two can still meet on one occurrence (bda118b).
A series on Mondays and Tuesdays that starts a day and an hour later
takes its first Monday's change onto the Tuesday, at the new time,
where the Tuesday's own override lands as well. Only an override that
stayed exactly where it was won such a meeting. Here neither did, so
the order the server listed the overrides in decided, and it could
keep the Monday's change and lose the Tuesday's cancellation.

The override that stays where it is now wins, then the one that stays
on its own day, then the first; the others are left out and logged, as
in the web app. The rest of a series split "this and following" keys
its overrides the same way.
2026-09-26 05:10:27 +02:00
Linus Rath 726e087795 fix: keep offline edits when the app cannot read an account's server record
The app records which server filled each account's caches and checks it
with every new session (a330d77). A read of that record that failed -
a storage error, such as a busy database - answered "none recorded",
so the session counted as the first sight of the account: its queued
offline edits were dropped, with a notice that the mail server had
changed when it had not, and the record was written anew. Had the
server been replaced, that record hid the change from every later
check.

A read that fails now fails the check: nothing is dropped or written,
and the next session checks again. As with any failed check, signing
in goes on.
2026-09-26 05:09:49 +02:00
Linus Rath b307ad1b26 fix: keep a working account when a switch fails during sign-in or start-up
A switch that began while an account was being added, or while a cold
start still brought the last account up, made that sign-in or restore
give up (efd31de). When the switch then failed as well, what it put
back was no working account:

- during an add-account sign-in, the switch fell back on a snapshot of
  the client, which served no account before the new session was up
  and the new account after. The app stayed on the account in use while
  every call failed with "Not connected", the session retry saw a
  session and returned, and tapping the account did nothing; or the
  client served the new account under the one in use, whose calendars,
  contacts and identities then came from it, and whose sends went
  through it.
- a cold start shows the last account's cached mail, drawer and all,
  while its session loads. A switch from there that failed left the
  app on the sign-in screen, with every account still registered.

A switch now falls back on the connection an add-account sign-in under
way saved, and resumes the queue of offline edits the sign-in held, as
a failed sign-in does itself. It waits for a restore under way and
switches from the account restored. A sign-in waits for switches under
way and a restore for neither, so none of them waits on another.
2026-09-26 05:09:12 +02:00
Linus Rath 8918b789e9 fix: keep the organizer and list occurrences once in the rest of a split series
Two faults in the new series "This and following events" makes, found
on 0.16.23 with the app's own code against the test server (1.0 did
not show them):

- The new series named its organizer only when the editor wrote one,
  which it does only for an event that had none. Created without it,
  0.16.23 makes the user who creates the event its organizer, whatever
  the owner participant says: a sharee who split the owner's meeting
  became the organizer of the rest (organizerCalendarAddress
  mailto:usera, isOrigin false, the invitation sent from the sharee),
  and a series organized under an alias would have moved to the user's
  main address. 1.0 takes the owner participant. The new series now
  carries the series' organizer address whenever it has participants.
- The overrides the new series takes over kept the old series' SEQUENCE
  while the new series starts at its own, and 0.16.23 listed each such
  occurrence twice, as changed and as the series has it, for the
  organizer and the attendee alike. The overrides now leave the old
  number behind.
2026-09-26 04:03:48 +02:00
Linus Rath bda118bc55 fix: keep occurrence changes on their days when a series moves or ends
Three faults in how a series' changed and cancelled occurrences follow a
change of the whole series (found in review, live on 1.0 and 0.16.23 for
the web's version of the same code):

- A rule that names its days - a series on Mondays and Tuesdays, one on
  the 5th and the 6th of each month - keeps its occurrences on those days
  when its start moves. Moving every occurrence by a day moved each
  override along anyway, so the Monday's landed on the Tuesday's key and
  one silently overwrote the other: the cancelled Tuesday came back with
  the Monday's title, and the Monday lost its change. Under such a rule
  every override but the first occurrence's now stays on its day, at
  the series' new time; the same holds for the new series of "this and
  following". Two overrides that still meet on one key (an hourly series
  turned all-day) no longer overwrite each other: the one that stays
  where it is, else the first, is kept, and the others are left out and
  logged.
- "Does not repeat" for every occurrence kept the first occurrence's
  override on the single event, because a series' start counts as an
  occurrence of any rule: cancelled, 1.0 left the event out of every
  range query (still stored); retitled, 0.16.23 listed it twice. A series
  made single now keeps no overrides.
- The SEQUENCE a change of every occurrence raises (0.16.6 to 0.16.23
  refuse a lower one) came from the series as the app held it. A series
  opened from a reminder or a link was that copy, possibly older than the
  server's. The store now reads a series from the server every time a
  scope edit asks for it, so the raise, the overrides a split or a move
  rewrites and the rule all start from what the server holds.
2026-09-26 04:03:05 +02:00
Linus Rath e6baa751a9 fix: keep single edits off a series opened as itself and end series west of UTC
More edits of a recurring meeting went wrong at the edges of a series
(0.16 and 1.0 alike, attendees included):

- A series the app shows as itself - opened from a reminder or an
  event link, or one an edit just made recurring (0.16 before 0.16.20
  showed it until the next reload) - took "This event only" as a change
  of the whole series: `CalendarEvent/set` on the series' id with the
  editor's title, start and rule. "Delete this event only" deleted the
  series. The series now stands for its first occurrence: a change
  becomes an override of it, in two writes when the user organizes the
  meeting, sends invitations and an attendee has answered (Stalwart
  sends a new override only to attendees who have not), and a delete
  excludes it. "This and following" from the first occurrence changes,
  or deletes, the whole series instead of ending it before its first
  occurrence. A series opened from a reminder on a calendar shared with
  the user is now known to the store, so the change goes to the owner's
  account under the series' own id.
- An edit that makes an event recurring, or single again, now loads the
  range again, so the grid shows the occurrences instead of the series
  itself.
- 0.16 (0.16.23 checked) reads a rule's UNTIL as the series' wall clock
  taken for UTC, so in a zone behind UTC a split or "delete this and
  following" kept the occurrence it was to end before. The UNTIL is now
  earlier by the zone's offset (the larger of the split's day and the
  day before), which ends the series there on 0.16 and 1.0 alike.
- 0.16.6 to 0.16.23 send a change of one occurrence with its SEQUENCE
  raised but do not keep the raise, so a later change of every
  occurrence - a new title - went out with a lower number for that
  occurrence and the attendee's server refused it. A change of every
  occurrence that the user as organizer sends, of something the
  attendees see, on a series with changed occurrences, now raises the
  SEQUENCE of the series and of each changed occurrence by one. 1.0
  raises and keeps the number itself; the result there is the same. The
  app's update sends `sequence` as it is, so nothing has to keep it.
- An existing override written on the base event (0.16 before 0.16.20,
  and ranges the device expands) gets the series' organizer again when
  it has none: 0.16.19 refuses an answer to such an occurrence. 1.0
  refuses the key inside an override once, and the store writes the
  override again without it.
2026-09-26 04:02:22 +02:00
Linus Rath 9025efee77 fix: move a series' changed and cancelled occurrences along when it moves or splits
A series keeps its changed and cancelled occurrences as overrides keyed
by recurrence id, where the rule puts each occurrence. The app's series
edits left them behind and made more of a series than was asked (0.16
and 1.0 alike, attendees included):

- "All events" that moved the series kept the overrides under the old
  ids: a cancelled occurrence came back, a retitled one showed twice.
  It also wrote everything the editor showed to the series, so a new
  room on an occurrence with a title of its own renamed the series,
  and it measured a move on the calendar's clock, so across a week in
  which only one of two zones changes to or from summer time the series
  moved an hour more or less than picked.
- "This and following events" ended the rule but not the overrides
  after the split, which showed on next to the new series; the new
  series got the whole COUNT again and started at the opened
  occurrence's own time in the calendar's zone. Deleting "this and
  following" left the later overrides too.
- A failed create of the rest of the series put back only the rule, and
  without telling the attendees the truncation had told.

Now, as on the web (`lib/recurrence-scope-edit.ts`, a port of the
webmail module of the same name):

- "All" moves each override with its occurrence in the same update: by
  the series' move, onto the same day at the new time when the series
  turns all-day or timed. An occurrence moved on its own keeps its time,
  except the one the editor was opened on; a new rule drops the
  overrides it no longer has, and overrides that move ask their
  attendees again when the series does. Only the fields the user
  changed go to the series, and the move is measured on the series'
  own clock.
- The split removes the overrides from the split on together with the
  rule and gives them to the new series, re-keyed and with its
  participants (0.16 keeps an override as an event of its own). The new
  series goes on from the split's recurrence id in the series' zone,
  moved only by the editor's move; its COUNT is what is left, an UNTIL
  stays, and a rule the user changed is taken as it is. It takes the
  old series' details, not the opened occurrence's own ones.
- A failed create puts back the rule and the whole overrides map (a
  pointer fails on a series left without overrides), with the
  truncation's scheduling, and fails the save; a store that answers
  with nothing counts as failed.
- The truncation tells the attendees only when the user organizes the
  series and sends invitations; an attendee ends their own copy
  quietly.

The scope handling moved from CalendarScreen into the new module, where
it is tested. The editor hands its dates to the save. An invitation to
one occurrence alone, which has no series, is changed or deleted as
itself for every scope instead of making a second event.
2026-09-26 04:01:39 +02:00
Linus Rath 198e952f61 fix: tell attendees when someone with write access changes the owner's meeting
Someone who writes to a calendar shared with them (read & write) changes
the owner's meeting there on the owner's behalf: Stalwart sends the
change from the addresses of the account the event is in. The app
decided who organizes by the user's own addresses only, so it took the
sharee for an attendee (0.16 and 1.0 alike):

- the editor wrote no participants, hid "Send invitations" and sent
  nothing, so the attendees kept the old time and their answers
- "All events" asked nobody again, and "This and following events"
  made the rest a new series that kept the old answers, which Stalwart
  sends to nobody who had answered

The app now reads `isOrigin`, which both versions compute from the
addresses of the account the event is in: true for the owner's meeting
and every expanded occurrence of it, false for an invitation the owner
got. `userOrganizes` counts such a copy as the organizer's; an
invitation is still changed without messages. "This and following"
asks by the old series, since the new one carries no `isOrigin` yet.

The editor no longer leaves the user's own main address off the
attendees it opens with, only the organizer's (or the user's, for an
event that names none yet): a user who invited their main address to a
meeting organized under an alias lost it on the next save.
2026-09-26 04:00:59 +02:00
Linus Rath 3e6e3d28e1 fix: keep a scheduled message in the app when its new time loses a recipient
"Send now" and "Reschedule" create a replacement submission and then
cancel the original. The server checks the recipients again for the
replacement, and a throttle used up since the original was taken ("452
4.4.5 Rate limit exceeded") can refuse some of them. The app kept the
original only when every recipient was refused; on a partial refusal it
cancelled it, and a recipient the original had taken lost the message.
Port of the webmail's aa99f038.

- the original's delivery status is read with its envelope, in the same
  request; when the replacement refuses a recipient the original took,
  the replacement is withdrawn (cancelled, or only its record destroyed
  when it took nobody) and the original goes at its old time to
  everyone it took
- a recipient the original was refused for as well loses nothing, so
  the reschedule goes ahead and names it; without a delivery status of
  the original, every recipient counts as taken
- a replacement that can't be withdrawn lets the reschedule go ahead,
  naming the refused recipients: "Send now" holds it for a second only,
  so it may be out already, and keeping both would send the others two
  copies; a withdraw whose answer got lost is read back, and when it
  went through the original is kept, since going ahead would reach
  nobody
- the alert in Scheduled and the undo bar's toast lead with "The
  message is still scheduled for ..." and say "Nothing was changed: the
  server refused ..."; they used to open with "Not sent", which invited
  sending the message a second time

Checked live on 0.16.23 and 1.0 with a relay-refused recipient: the
lookup, going ahead, and withdrawing a partly refused replacement while
the original stays at its old time. One new string, with the webmail's
translations in all 27 languages.
2026-09-26 02:51:36 +02:00
Linus Rath 5dcf5991ab fix: open the composer before the session is up without crashing 2026-09-26 01:46:42 +02:00
Linus Rath 7b18063630 fix: stop a start-up crash, and stale views after sign-out or a new server
- The app crashed on every cold start whose cached folders held a
  shared or group folder, which on Stalwart 1.0 every group member has:
  the sidebar works out the accounts to span while it renders, before
  the session is up, and the JMAP client's account id throws until one
  is. The client now also names its account without throwing, for code
  that runs while rendering; the mail list's account helpers (the
  sidebar's accounts, a row's account, the viewer's pages) use it, and
  a read receipt sent by itself waits for the session. API calls still
  throw without a session.
- Signing out of the last account left the app on an empty mail view
  ("No mailboxes found") with nothing to retry: the account list was
  read before the account was removed, so the app switched to the
  account it had just signed out of, which did nothing. It is read
  again now, and the sign-in screen shows. A failed switch to the next
  account no longer puts the signed-out one back: when its server does
  not answer, that account shows offline, as on a cold start, and one
  whose sign-in is refused is skipped.
- After signing in with the same address on another server (another
  port) or on a server reinstalled at the address (0.16 to 1.0), Files
  kept showing the old server's drive without asking the new one, and
  actions on its rows would have used the old server's ids. The local
  account id leaves the port out and stays the same there, so the
  drive now also starts over when the server behind the session
  changes, and is read again with every new session. The account id
  itself is unchanged: it keys the stored credentials and every
  per-account cache, and changing it would sign such accounts out and
  orphan their data.
2026-09-26 01:44:47 +02:00
Linus Rath 1caf73a3e9 fix: never send an occurrence change to the next occurrence
A first change to one occurrence that keeps the attendees' answers goes
out in two writes: the override as the occurrence stands, quietly, then
the change as an update of it. The occurrence is looked up again in
between, because the new override can give it another synthetic id.
When that lookup failed, the change went to the old id. Up to Stalwart
0.16.20 synthetic ids are positions in the series' expansion
(calendar_event/mod.rs), so after the new override the old id can name
the next occurrence, which then got the change and its scheduling
messages. 0.16.21 and 1.0 key the ids by recurrence id, and the id
stays (checked live on 1.0 and 0.16.23).

Now, when the occurrence is not found again, found twice, or the lookup
fails, nothing more is sent and the editor says the change was not
saved. The quiet override changes nothing that shows; the calendar is
read again so a retry uses the ids the series has now, and goes out as
an update of that override, which reaches every attendee. The message
is translated into all 26 languages.
2026-09-26 01:44:05 +02:00
Linus Rath a330d77d6d fix: keep push, queued edits and iCal feeds safe on a replaced server
The app compares the server behind an account with the one it recorded
and drops what came from a replaced one. The released version recorded
none, so on the first start of this one there is nothing to compare
with, and a server replaced before the update (Stalwart 1.0 is a fresh
install) went unnoticed:

- the push subscription expiry the released version recorded made the
  subscription the new server lacks look revoked, and push was turned
  off for good; it is now forgotten on the first sight of an account, so
  the subscription is re-created
- the queue of offline edits the released version left was replayed
  against the new server, where its ids name other messages, so a
  queued delete deleted another message; it is now discarded on the
  first sight, with the notice a replaced server shows. Nothing proves
  a queued edit belongs to the server: the queue records none, a
  reinstalled Stalwart may accept old JMAP states, and a queued move or
  delete took its message out of the cached list, so there is no copy
  to compare the server's message with

iCal subscriptions recognise their mirror calendar by name, but a sync
still imported the feed into whatever calendar the recorded id named:

- the feed now goes only into a calendar that carries the
  subscription's name, and otherwise into a new mirror; when the name
  can't be read, the sync fails and is tried again
- a rename the server refused renamed the subscription anyway, which
  then no longer recognised its mirror: pruning stopped and removing the
  subscription kept the calendar. Now nothing changes and the error is
  shown
- a replaced server claimed subscriptions made before they recorded
  their account for every account with the same JMAP id, another
  server's account included. They now go only when no other signed-in
  account has that id, or may have it, and a sync that finds their
  mirror records the account on them
2026-09-26 01:43:22 +02:00
Linus Rath efd31de051 fix: never send an account's offline edits to another account
Adding an account put the new account's session in the JMAP client as
soon as it was up, while the queue of offline edits (outbox) stayed on
the account in use, and ready to replay, until the new account's server
was checked. A retry, a network flip or the return from the OAuth
browser in that window replayed the queue through the new account, and
edits to the user's own mail took its JMAP account id. Stalwart's ids
repeat across accounts, so a queued move or delete of one account's
message "x" hit the other account's "x". An edit made in that window ran
the same way, and so did the rest of a replay already under way.

Account switches could overlap too: tapping B, then C before B's session
was up, left the mail list on C while B's switch, finishing last, put B
in the client and its queue in the outbox, so edits in C's list went to
B under C's ids. An older switch that failed last put back the account
it had left, under the newer one.

- the outbox replays and runs edits only while the client serves the
  account the queue belongs to, checked before every op, and holds the
  queue while another account signs in; a failed sign-in gives the
  client and the queue back to the account in use
- the outbox resumes only for the account the mail list shows
- every account change (a switch, a sign-in, a sign-out) takes a
  generation, and one a newer change overtook touches neither the client
  nor the stores; the client no longer installs the session of a load or
  sign-in that a newer one overtook
- a failed switch puts back the account the first of overlapping
  switches left, and tapping that account while they are under way goes
  straight back to it
- a session retry gives way to a switch or sign-in under way
- tokens refreshed for an account being left stay with that account;
  they were saved into the credentials of the account loaded meanwhile,
  which then signed in as the other one
2026-09-26 01:42:35 +02:00
Linus Rath 206756e13c fix: stop treating narrow refusals as total ones in the app's sends and exports
Two places took a narrow refusal from the server for a complete one.
Port of the webmail's 7977a6ed.

- Sending at a full submission quota (Stalwart 1.0): when making room
  found nothing, no search ran for five minutes, for any send. Scheduled
  mail and undo-send holds can go once their message is out, and until
  the next search every send failed with "ask your administrator". A
  send the user starts, a read receipt included, now always looks again;
  the pause stays for automatic read receipts.
- vCard export: Stalwart answers Blob/get only in accounts the user is a
  member of, so for an address book shared with the user it came back
  `forbidden` ("You are not an owner of account ...") and the export fell
  back to one photo download after the other. It checks the access to
  each blob by itself, whichever account the request names, so
  `getBlobsAsBase64` now asks again in the user's own account, on 0.16
  and 1.0 alike.
2026-09-26 00:52:43 +02:00
Linus Rath 7326fec611 fix: stop reporting mail as sent in the app when the server refused its recipients
Stalwart runs the SMTP RCPT stage while it creates a submission and
records a refused recipient as `delivered: "no"` with its reply: a relay
it doesn't allow ("550 5.1.2 Relay not allowed.") or a used-up throttle
("452 4.4.5 Rate limit exceeded, try again later."), on 0.16 and 1.0
alike. The create still succeeds and onSuccessUpdateEmail files the
message in Sent. The app read only the new record's id and sendAt, said
"Email sent", and on Stalwart destroyed the record right after, the only
trace of the refusal. The message was lost without a bounce. Port of
the webmail's e0b0dfe0.

- the send reads the new submission's delivery status in the same
  request (`EmailSubmission/get` of `#sub-1`, its creation id; the
  create's own answer has none), also with the retry after a full
  submission quota, before the record is destroyed
- every recipient refused: the send fails with the server's reply per
  address, the copy filed in Sent is removed and the previous draft
  kept, so the composer or the quick reply keeps the text and the
  address can be fixed; held sends (undo send, scheduled) are refused
  when they are created, so this covers them too
- some recipients refused: the others get the message, and a warning
  names the refused ones with their replies
- "Send now" and "Reschedule" check the replacement the same way and
  keep the original, which still goes out at its old time, when every
  recipient of the replacement is refused; a partial refusal there gets
  the warning as well
- the submission prune leaves a shared account that refuses its records
  with `forbidden` alone while the app runs, for that login, with a
  debug log instead of a warning, and the scheduled list skips it:
  0.16.23 lists an account that only shared an address book or a
  calendar with the submission capability

`RecipientsRefusedError` and `RefusedRecipient` are added to
`jmap-result.ts`. Five new strings in all 27 languages, four of them
with the webmail's translations.
2026-09-26 00:51:59 +02:00
Linus Rath 00619751d7 fix: never send a rescheduled message twice and handle partial refusals
"Send now" and "Reschedule" at a full submission quota (Stalwart 1.0)
made room and then created the replacement again. Making room can take
a while - a prune, a walk through the change log - and the original
could go out meanwhile, so the replacement sent it a second time, with
a HOLDFOR that still counted from before the wait. The original is now
read again first: nothing is resubmitted unless it is still pending
("This message was already sent" or "no longer scheduled" otherwise,
and the undo bar lets go of it), a failed read changes nothing, and the
hold is worked out again from the time the user picked. Port of the
webmail's 65eb4b10.

- the Stalwart explanation of a full quota (Bulwark removed what it
  could, an administrator has to raise the limit) needs
  `urn:stalwart:jmap` on the submission account; another server's
  `overQuota` gets a generic sentence with its own description
- when making room finds nothing that can go, the account is not
  searched again for five minutes; each send, reschedule and automatic
  read receipt at a full quota used to run the prune, the change-log
  walk and the gets again
- unsubscribing by mail at a full quota explains the quota instead of
  only saying "Unable to unsubscribe"
- push on Stalwart 1.0 (`principals:owner` on the account) takes the
  emailpush capability as the answer to which accounts notify: a group
  with only mail no longer counts as unreachable, and a refusal
  remembered under 0.16 no longer keeps a group out of the map
- a push re-registration the user asked for at the server's
  subscription limit removes the old record first and tries once more;
  the old record counted against the limit, so it could never be
  replaced
- "Mark folder as read" on a folder and its subfolders goes on past a
  folder the server refuses and reports the refusal at the end; the
  folders and the list refresh either way
- marking, starring or tagging a selection writes every account before
  reporting a refusal and shows only the messages the server updated;
  one refusing account or message used to leave the rest unwritten,
  and what was written looked unchanged
- a new photo for a contact opened from the cache is not saved when the
  card's media can't be read from the server either; it would have
  replaced the card's other media (a logo, more photos)
2026-09-25 23:51:39 +02:00
Linus Rath a562ea59b8 fix: keep attendees' answers on save and deliver occurrence changes
Every save in the event editor rebuilt the participants with new ids
and put every attendee back to needs-action, the user's own answer on
an invitation included. Saves now keep each attendee's id and answer;
new attendees are asked. As on the web, only an organizer who moves an
event (its start, end, all-day or recurrence rule; not a time zone
rewrite, and not a zero-length event) asks the others again.

The series scopes did not fit that:

- "All events" wrote the opened occurrence's start, length, zone and
  participants onto the series: a stretched occurrence stretched every
  one, and one day's decline spread to the whole series. The series now
  keeps its own list and takes only the attendees the user added or
  removed, and its times change only when the occurrence moved (by the
  same delta), its rule only when it changed.
- "This and following" makes a new series, which Stalwart sends only to
  attendees who have not answered: its attendees are asked again.
- Stalwart sends a new occurrence override, the first change to one
  occurrence, only to attendees who have not answered (0.16.6 to 1.0).
  A change that keeps answers now goes out in two writes: the override
  as the occurrence stands without scheduling, then the change as an
  update of it, to the occurrence looked up again. Checked live on 1.0
  and 0.16.23.
- A save of an invitation someone else organizes writes no participants
  while the user left them alone, and never asks for scheduling, so the
  organizer gets no reply for a changed alert. The switch to send
  invitations is hidden there.

Also:

- a reminder change on one occurrence of a series that uses the
  calendar's default alerts turns the defaults off for the series first
  (keeping what it shows under ids of its own); 1.0 kept showing the
  defaults
- all-day and floating occurrences expanded on the device no longer
  carry the master's utcStart/utcEnd, so their reminders are scheduled
  for each day; master instants that are not the master's own (0.16.23's
  year 32548, or the next occurrence's) are ignored
- the editor warns when the attendees and the organizer go over the
  calendars capability's maxParticipantsPerEvent, and a refusal for it
  (Stalwart 1.0 enforces it, answers included) is explained in the
  editor, the series dialogs, the detail sheet and the invitation
  banner, in all languages; a failed save used to fail silently
- refused event creates, updates and answers keep their SetError type
- Calendar/get asks for its properties by name
2026-09-25 23:50:29 +02:00
Linus Rath f490886a8b fix: keep iCal subscriptions safe when the mail server was replaced
When the server behind an account is replaced (a fresh Stalwart 1.0
install at the address 0.16 ran on), its ids start over. The app
already dropped its caches then, but kept what the user set up with
server ids inside:

- iCal subscriptions kept the ids of their mirror calendars. The next
  sync deleted the "stale" events of whatever calendar now had the id
  and filled it with the feed, and "Remove subscription" destroyed it.
  Now the account's subscriptions keep their feed, name, colour and
  interval but lose their mirror, and the next sync makes a new one as
  adding a subscription does. Renaming or removing an unlinked one
  touches no calendar. Events are only pruned from, and remove only
  destroys, a calendar that still carries the subscription's name.
  Subscriptions now record the signed-in account, so accounts on two
  servers that got the same JMAP id no longer share them.
- the preferred sender identity kept its id, which a new server can
  give to another address (live: identity "b" is usera@ on 0.16.23 and
  team@ on 1.0). It is forgotten for the account's old and new JMAP
  id, which the app now records with the server fingerprint.
- hidden calendars kept their ids. The list is shared by all accounts,
  so it is cleared.

Offline changes the drop discards are now announced once ("3 offline
changes were discarded because the mail server changed"), in all
languages.

Revoking this device from another one turns push off here again: a
subscription gone before its recorded expiry counts as revoked, as it
did before the server check came in. A replaced server forgets the
recorded expiry, so there the subscription is re-created instead.
2026-09-25 23:49:36 +02:00
Linus Rath a2ca98e0e8 fix: send offline edits after a restart and never to another account
The queue of offline edits (outbox) and the offline mail cache were
attached to an account only when the email store switched to it. A cold
start restores the view of the account it showed last, so there was no
switch: the queue from the last session was never loaded or sent, an
edit made meanwhile was dropped with a console warning, and the offline
cache neither stored nor served bodies.

On an account switch the queue was loaded and flushed right away,
before the new account's session was up and before the check for a
replaced server. The JMAP client still served the previous account
then, so queued edits could reach it.

- the email store attaches the outbox and the offline cache whenever it
  is pointed at an account, also the one it already shows
- the outbox holds its ops until the auth store resumes it, once the
  account's session is up and its server was checked; edits made before
  that are queued, and edits made while the queue loads are kept
- the JMAP client serves no account while it loads or signs in to
  another one; a failed switch puts the previous account and its queue
  back
- dropping an account's queue or cache for a replaced server cancels a
  read of it still under way
- the offline sync stops when the cache or the client moves to another
  account, instead of filing one account's mail under another
2026-09-25 23:48:51 +02:00
Linus Rath 2e26ff7b64 fix: report refused tags and re-check the sort order after a server change
Stalwart 1.0 refuses keywords over 128 bytes and more than 100
keywords on one message, per message in Email/set `notUpdated`; a
read-only shared folder refuses any keyword on every version. The app
stopped a large selection at the first refused batch, "mark folder as
read" ignored refusals, and the list's swipe, bulk and tag actions let
the rejection go unhandled, so nothing was said. The keyword sort
polarity verdict was stored per server address for a week, so a server
reinstalled as 1.0 (which sorts as the RFC says, where 0.16.8-0.16.22
invert) kept the old reading.

- Email/set writes send every batch before reporting the first refused
  message, and "mark folder as read" marks the rest, then reports the
  refusal. The list shows "Tagging failed" or "Failed to update email"
  for swipe, bulk and tag actions, and the viewer's automatic
  mark-as-read puts the message back to unread when it is refused.
- The sort polarity verdict is keyed by the server fingerprint as well
  (stored as `keyword-sort-polarity:v2`), so a replaced or upgraded
  server is probed afresh. An upgrade that leaves the capabilities
  alone, such as 0.16.22 to 0.16.23, still waits out the week.
- Tests pin that Stalwart 1.0's `$hasattachment` / `$hasnoattachment`
  never show as tags or as tags to recover; the app lists no other
  keywords, and tag ids stay within 128 bytes by construction.
2026-09-25 20:59:19 +02:00
Linus Rath 42751a6b29 fix: keep the app's requests away from Stalwart 1.0's unfinished JMAP Metadata
Stalwart 1.0 advertises `urn:ietf:params:jmap:metadata` by default, but
its handlers are still `todo!()`: a get that selects `metadata` or
`privateMetadata`, or a /changes call with `ignoreMetadataOnlyChanges`,
panics the request on the server. The app never sends either, but its
Mailbox/get and SieveScript/get calls asked for the server's default
properties, which a later release could grow to include metadata.

- Mailbox/get (own folders, shared accounts' folders, by id, and the
  polling fallback) and SieveScript/get ask for explicit RFC property
  lists, the same as today's defaults on 0.16 and 1.0 (checked live).
- A test checks the requests the folder, filter, delta-sync and polling
  paths build, on 0.16.23 and 1.0 sessions, and scans the app's sources
  for metadata properties, the /changes argument and the capability.
2026-09-25 20:58:33 +02:00
Linus Rath 97daf76f05 fix: start over when the mail server behind an account was replaced
Stalwart 1.0 has no migration from 0.16, so the way there is a fresh
install at the same address, where ids start over ("a", "b", ...) and
name other objects. The app persists a lot that only means something on
the server it came from: JMAP states and query states, offline bodies
keyed by message id, an outbox that replays stored ids (a `destroy`
included), the calendar cache and push de-dupe lists. It also turned
push off for good when the server no longer had the subscription, and
would have taken another device's subscription for its own once the
reinstalled server handed out the recorded id again.

- Email/changes, Email/queryChanges and Mailbox/changes answers whose
  Stalwart state went below the one asked from count as not computable
  and lead to a full fetch (`stalwartChangeId`, a port of the webmail
  guard). It only reads accounts that list `urn:stalwart:jmap` and
  states in the exact form Stalwart writes, so it never trips while
  states grow or on other servers.
- Every time a session is established, a fingerprint of the server (API
  URL, primary account id, the session's and the primary account's
  capability names) is compared with the one recorded for the account.
  On a change, before anything reads them, the account's folders,
  lists, states and query states, offline bodies, queued and failed
  outbox ops, the calendar and contacts caches and the push de-dupe
  lists are dropped. Preferences, templates, tags and iCal
  subscriptions stay. The first sight of an account only records it.
- The whole outbox goes: every op names a message by its server id.
  Offline edits still waiting are lost, but never a message the user
  wrote, because the outbox holds no sends.
- A subscription the server no longer has counts as expired and is
  re-created, and the recorded one is only ours when its device client
  id matches, on setup and on teardown. Revoking this device from
  another one therefore no longer keeps push off here; turning it off
  on the device does.
2026-09-25 20:57:46 +02:00
Linus Rath e8a9c58d18 fix: keep push notifications working when a mailbox is shared with you
Stalwart refuses a push subscription's whole emailPush map as a
`forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and
later, and 1.0), and 1.0 stops listing emailpush on such accounts. The
app narrowed the map on any `forbidden`, including a refused method
call, and put every account with folders in the first attempt, so 1.0
cost one refused call per install. Worse, a failed listing counted as
"the server has no subscription" and dropped the key of a working
registration, and any failed refresh - a network error, a timeout, rate
limiting - started a replacement.

- `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same
  name, carrying `properties`) and `isJMAPSetError`. `assertSetResult`
  throws it, so every refused object can be told apart from a refused
  call; existing `instanceof` checks and the outbox's name-based error
  classification are unchanged.
- The emailPush map holds the primary account and the accounts whose
  `accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which
  leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused
  map still narrows to the accounts that look owned, then to the
  primary account, but only on a `forbidden` SetError.
- A subscription ends only with a verified replacement, a user disable
  or the server's verdict: a failed listing aborts the run, and only a
  SetError on the refresh starts a replacement. Other failures leave
  the subscription and its key alone and the next run tries again.
- `forbidden` and `overQuota` read as plain text in settings and the
  onboarding prompt, and settings says that mailboxes others share
  don't send background notifications. New strings in all 27 languages.
2026-09-25 20:56:57 +02:00
Linus Rath 3f4a81b7ad fix: handle shared folders, symlinks and file names on Stalwart 1.0
Files in the app assumed Stalwart 0.16 in several places:

- Stalwart 1.0 lists the owner's folders above a node shared with the
  user, without dates and with every right false. The app took the
  owner's top folder for the shared root, so the folder actually shared
  sat several levels down, under folders where rename, delete and move
  could only fail. Those placeholders are now left out of the root and
  of folder listings.
- A node counted as a folder when it had no blobId, so 1.0's symlinks
  showed up as empty folders that could be opened. Listings ask for
  `nodeType` (returned since 0.16.6, never sent in FileNode/set), and
  a symlink is an inert row with a link icon: it can be renamed, moved
  and deleted, but not opened, downloaded, duplicated or shared.
- Only "/" was checked in names, and uploads were not checked at all,
  so `<>:"\|?*` already failed on 0.16. New folder and rename now apply
  the naming rules from the filenode capability plus 1.0's stricter
  ones (control characters, 255 bytes, reserved stems such as
  `con.txt`) and say what is wrong. Uploaded names are made acceptable
  instead: `con.txt` becomes `con_.txt`, also on 0.16.6 to 0.16.23.
- One refused file stopped a whole upload. The batch now goes on and
  reports the files that failed at the end.
- Every media type over 30 characters, which is every Office document,
  was stored as application/octet-stream. New files get the bare
  lowercase media type 1.0 requires, and no `size`, which 1.0 refuses
  when it differs from the blob.
- File errors keep their JMAP type, and the alerts say why an action
  failed: too many files or folders (`overQuota`), folders nested too
  deeply, a refused name, missing rights, or a file too large.
2026-09-25 20:46:41 +02:00
Linus Rath 1129ba339b fix: save filters whose rules contain */, line breaks or odd header names
The app stores its filter rules as JSON inside a `/* */` comment at the
top of the Sieve script. A `*/` anywhere in a rule name, a value or the
auto-reply text ended that comment early and turned the rest of the
rules into script, and a CR or LF in a rule name did the same to its
`# Rule:` comment. Stalwart (sieve-rs, on 0.16 and 1.0) also accepts
only the letters A-Z, digits, `-` and `_` in a header name. Any of these
made the server refuse the whole script, so saving any filter, and the
auto-reply with it, failed.

- `*/` in the embedded rules is written as `*\/`, which JSON reads back
  as `*/`, so every client parses the rules as before
- the `# Rule:` comment carries the rule name without line breaks, and
  the parser matches the comment back to its rule by that form
- the filter editor trims header names, turns autocorrect off for them
  and refuses to save one the server would reject, with a translated
  message; the generator leaves a rule with such a header name out of
  the script, since dropping only the condition could widen an "all of"
  rule, and keeps it in the stored rules for editing
2026-09-25 20:46:00 +02:00
Linus Rath e2c76d46c7 fix: stop the app re-reading the same folder changes forever
When Mailbox/changes answered with `hasMoreChanges` (Stalwart caps one
answer at 5000 changes, other servers earlier), the folder sync applied
that page but kept the old state and ran again, which asked for and got
the same first page back, without end.

The sync now follows `newState` page by page, storing each page's state
as it applies it. After five pages, or when a page does not move the
state on or can't be calculated, it reads the whole folder list instead,
which takes one request whatever changed.
2026-09-25 20:45:18 +02:00
Linus Rath 90b78da8c8 fix: keep time zones, default alerts and calendar renames working on 1.0
Four smaller calendar changes that Stalwart 1.0 needs.

- Time zone: 1.0 fails every CalendarEvent get and query whose
  timeZone its database lacks, with invalidArguments "Invalid timeZone".
  It matches IANA names case-sensitively and knows no offsets, so a
  "+01:00" or "europe/berlin" that Intl accepts empties the calendar.
  0.16 reads such a zone as UTC. The zone is now checked with a get of
  no events that rides along with the first calendar list; once refused,
  reads ask in UTC and log one warning, and a read refused before the
  check answered is repeated once in UTC. The comment that said Stalwart
  ignores bad values is corrected.
- Default alerts: 1.0 fills a calendar's default alerts into the
  alerts of an event that uses them. The editor wrote them back with
  useDefaultAlerts false on every save, freezing today's defaults into
  the event. It now writes nothing while the reminders are unchanged; a
  change turns the defaults off and saves the alerts under ids of their
  own, since 1.0 drops alerts saved under a default alert's id.
- Calendar colour: renaming sent the colour along, and 1.0 accepts only
  #rgb, #rrggbb or a CSS name, so a calendar another client coloured
  with an 8-digit hex could not be renamed. The edit sheet and the iCal
  subscription settings now send only the fields that changed.
- Reply stubs: 0.16 stored an attendee's answer to one occurrence
  without a title, place or description, and 1.0 builds that occurrence
  from the override alone. Hydration now takes a missing title from the
  series, and the place and description when the override removes the
  title.
2026-09-25 20:44:34 +02:00
Linus Rath 6eeee8df54 fix: explain sharing when Stalwart 1.0 keeps its directory closed
Stalwart 1.0 refuses Principal/query with `forbidden` unless the
administrator allows directory queries, which is off by default. The
app took the missing Principal/get answer for an empty directory: the
calendar share sheet said "Sharing is not available on this server.",
the file share sheet said "No other users found", and existing shares
showed bare ids.

- `listPrincipals` says whether the listing is complete, `restricted`
  or failed. On `forbidden` it falls back to Principal/get without ids,
  which 1.0 still answers with the user's own account, their groups and
  the accounts that shared something with them.
- Principal/get asks for `id`, `type`, `name`, `description` and `email`
  only; 1.0's default list adds `capabilities` and `accounts`.
- Both share sheets explain a restricted directory under the search
  box. Only 1.0's own refusal ("The administrator has disabled directory
  queries.") gets the wording that points at Stalwart's sharing
  settings; any other refusal gets a generic one, and a failed listing
  says so.
- A sharee the app can't name shows as "Unknown user" with its id, and
  can still be changed or removed.

On 0.16 with the default roles the listing is complete and the sheets
behave as before.
2026-09-25 20:42:07 +02:00
Linus Rath 6be51a2bab fix: find multi-word, address and long-word searches in the app on Stalwart 1.0
Stalwart 1.0 reads a text filter value that ends in `*` as one prefix
over the whole string, not split into words and capped at 16 bytes. The
app added `*` to every word it searched for, so on 1.0 `project budget`
became the prefix "project* budget", an address was no single word and
`Geschäftsführung` (18 bytes) was over the cap: all three found
nothing. Quoted phrases broke too, since `"annual report" 2024` went out
as one value ending in `*`.

The search text now goes out as typed, split the way the webmail does
it (5967a1c4): each quoted phrase and each `word*` becomes a condition
of its own, ANDed with one condition that holds the other words, so a
query without quotes or stars is sent unchanged. The folder, all-folders
and tag-view searches, the unified inbox and the from, to, subject and
body filters all use it, and `searchEmails` puts `inMailbox` next to
the text conditions in one AND instead of on an operator.

0.16's built-in index dropped the `*` anyway, so its results stay the
same; there a quoted phrase is an AND of its words. On 1.0 a typed
`word*` is now a real prefix.

The fake server of the store tests now matches text the way Stalwart's
built-in index does, which the old `*` after every word fails.
2026-09-25 20:41:11 +02:00
Linus Rath 2af3892fec fix: apply recurrence overrides as patches when the app expands a series
Stalwart 1.0 returns recurrence overrides as RFC 8984 PatchObjects,
with keys like participants/p1/participationStatus and nulls that
remove. Expanding a series on the device spread them onto the
occurrence, so a declined occurrence stayed accepted, a changed room
stayed the same and the pointer keys became junk properties. Moved or
resized occurrences also kept the time and length the rule gave them,
and were placed in the range by their original date.

- apply overrides as patches: pointer paths decoded per RFC 6901, null
  deletes, shorter paths first, a whole participants map completed
  from the series
- time an occurrence from its patched start, time zone and duration,
  and range-check a moved occurrence by where it is now
- fall back to the JSCalendar duration when the master's utcStart or
  utcEnd don't parse (0.16.23 returned the year 32548 for a counted
  series), and never keep the master's instants on another occurrence
- merge a change into an existing override without keys that are
  prefixes of one another, and match an occurrence listed twice
  against the members its override patches

The device expands series on 0.16.6 to 0.16.19, after a created series
and when a range can't be expanded on the server. 0.16 returns whole
override values, which apply the same way.
2026-09-25 20:38:43 +02:00
Linus Rath 2a3febcc79 fix: edit or answer one occurrence of a series in the app on Stalwart 1.0
Stalwart 1.0 refuses series properties such as the organizer on a
single occurrence unless they repeat the series' value, and refuses
them inside a recurrence override on the base event. The event editor
wrote the user's first address as organizerCalendarAddress on every
save of an event with attendees, so editing one occurrence failed with
"This property cannot be modified on a single occurrence." whenever the
series was organized by someone else, under another alias or in
another letter case. Removing every attendee from one occurrence failed
the same way.

- leave series keys and per-event metadata out of changes to one
  occurrence, as Stalwart's validate.rs lists them, and pointers at a
  participant's calendarAddress
- write overrides on the base event as before (0.16.19 needs the
  organizer there) and, when the server refuses series keys inside
  them, repeat the write once without them and remember that
- answer one occurrence through its synthetic id without the organizer
- let the editor keep the organizer of an event: an invitation the
  user received is no longer rewritten with the user as organizer, and
  organizerCalendarAddress is only written for an event that has none
- keep the SetError type on a refused event update

0.16.20 to 0.16.23 dropped these keys on synthetic ids anyway; the
base-event override payload for older servers is unchanged.
2026-09-25 20:36:58 +02:00
Linus Rath ef6f1540dd fix: keep the app's calendar filled past a year on Stalwart 1.0
Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more
than the account's maxExpandedQueryDuration (P52W1D by default) with
expandDurationTooLarge, and one that expands to more than 3000
occurrences with cannotCalculateOccurrences. The scrolling views load
windows of up to about 460 days, and refresh() reloads the whole
window on every calendar push, after series edits and on pull to
refresh, so the calendar showed a raw error over stale events from
then on. The fallback to expanding on the device only knew 0.16's
invalidArguments.

Expanded range queries are now cut into chunks a day shorter than the
advertised limit, with overlapping borders, sent together as far as
maxCallsInRequest allows, and their ids de-duplicated. A chunk the
server still refuses is halved; if a week still expands to too many
occurrences, the whole range is expanded on the device as before.

The expanded query keeps a filter of the two bounds only: 1.0 refuses
operators there, so filtering several calendars would take a query per
calendar, and the store loads every calendar anyway. 0.16 advertises
the same limit without enforcing it and returns the same events.
2026-09-25 20:35:00 +02:00
Linus Rath f52ef7db7e fix: show and keep contact photos in the app on Stalwart 1.0
Stalwart 1.0 returns every photo, logo and sound in a contact card as a
blobId without a uri, including photos saved as data: URIs. The app read
only `uri`, so sender avatars, the contact list, the detail screen, the
form preview and vCard export showed nothing. Worse, the form rebuilt
`media` from the card on every save and sent it without the photo, or
as `null`, so editing a phone number deleted the photo on the server.

- `ContactMedia` carries `blobId`, `label`, `pref` and `contexts`, and
  `uri` is optional. The photo shown is the photo entry with the lowest
  `pref`, as a URI (0.16) or a blob (1.0).
- Blob photos load from the download URL with the Authorization
  header, like file thumbnails. At most half of the server's
  `maxConcurrentRequests` load at once, a failed one is tried once more
  and then left alone for two minutes, and one that loaded skips the
  queue from then on.
- The form leaves `media` out of the save unless the photo changed. A
  changed photo is built on the card as the server has it now, so a
  stale blobId can't make 1.0 refuse the update, and a new photo keeps
  the key, `pref`, `label` and `contexts` of the one it replaces.
- A new photo is uploaded into the user's own account and sent as a
  blobId where the blob capability lists ContactCard (1.0). Otherwise
  it goes as a data: URI, and a refusal like 0.16's "blobIds in media
  is not supported." retries once with the data: URI and is remembered
  for the account.
- vCard export fetches blob photos with batched Blob/get requests and
  writes them inline. It also writes photos whose data: URI names no
  media type, which it dropped before. Duplicate and moving a card to
  another account send the bytes instead of the source's blobIds.
- An update never clears `id`, `uid` or `addressBookIds`, and vCard
  export writes MEMBER values that are already URIs as they are instead
  of `urn:uuid:urn:uuid:…`.

The form's state and patch building moved to `src/lib/contact-form.ts`
so the save path can be tested.
2026-09-25 20:32:13 +02:00
Linus Rath fe0facfea5 fix: keep sending working in the app past Stalwart 1.0's submission limit
Stalwart 1.0 counts every EmailSubmission record of an account against
`maxSubmissions` (500 by default), whatever its status or age, and its
own purge of old records never runs. The app never removed a record, so
after about 500 sends from an account every send, quick reply, read
receipt and "Send now" failed with Stalwart's raw "There are too many
email submissions, please delete some" error. This ports the webmail
fix (48382170).

The app now removes the records that can no longer be cancelled.
Destroying a record never stops its delivery, but a pending one could
no longer be cancelled, so a record EmailSubmission/get reports as
pending is never touched:
- an immediate send (no undo delay, no schedule) and a read receipt
  destroy their own record right after the send, riding along with the
  old-draft cleanup when there is one; only where `urn:stalwart:jmap`
  is advertised, and never decided by the created undoStatus, which
  0.16.10-0.16.16 report as final for held sends
- a throttled background prune (`src/api/submissions.ts`) destroys
  canceled records and final records whose sendAt has passed, going by
  EmailSubmission/get, in every submission account; it runs at app
  start, on return to the foreground and after sends and undo
- a confirmed cancel (undo, cancel, reschedule, "Send now") destroys
  the canceled record in a request of its own
- an `overQuota` on a submission create prunes at once, on Stalwart
  walks `EmailSubmission/changes` from "n" for records older than the
  query's three-day window, retries once, and otherwise throws
  `SubmissionQuotaError`, shown as a translated explanation in the
  composer, quick reply, read receipts, the undo bar and the scheduled
  list; the copy the failed attempt made is removed as before

0.16 never enforces the limit, and destroy never cancels on any
version, so the same cleanup runs there unchanged.

The undo bar now also says when an undo or "Send now" failed instead
of only logging it. The scheduled list reads every page of
EmailSubmission/query with calculateTotal; it asked for 200 records,
oldest first, so the mail scheduled furthest ahead dropped off once the
three-day window held more than that.

`SubmissionQuotaError` is added to `jmap-result.ts` as a subclass of
`JMAPMethodError`.
2026-09-25 19:46:15 +02:00
Linus Rath 28ade775e3 fix: load calendar events and tasks in the app on Stalwart 1.0
Stalwart 1.0 refuses a CalendarEvent/get that asks for
recurrenceOverrides together with utcStart or utcEnd, and every event
read of the app did. The calendar showed a raw error over stale events,
and tasks, reminders, "all events" edits, contact activity, the
invitation banner's UID look-up and iCal subscriptions stopped working.
Subscriptions and imports also lost their UID check, so every sync tried
to create the whole feed again and ended in refusals.

- read a stored event's overrides with a second get in the same
  request; server-expanded occurrences get them from their base event
  as before
- read only ids, UIDs and calendars for the import and subscription
  UID look-ups
- fail a read when either get answers with an error, so the calendar
  keeps the events it has and shows the error
- keep the JMAP error type on calendar method errors (JMAPMethodError,
  plus a shared jmapErrorType helper in jmap-result)

0.16 accepts the split reads and returns the same events.
2026-09-25 19:45:19 +02:00
1149 changed files with 319401 additions and 101590 deletions
+92
View File
@@ -0,0 +1,92 @@
name: iOS simulator test leg
description: Boot a simulator, install the app and run one suite of Maestro flows
inputs:
suite:
description: Folder under .maestro (signin or features)
required: true
name:
description: Leg name, used for the artifact
required: true
app_run_id:
description: Take the simulator app from this run instead of the current one
required: false
default: ""
flows:
description: Comma list of flow names to run instead of the whole suite
required: false
default: ""
playwright:
description: Install Playwright for the helper's webmail pairing
required: false
default: "false"
runs:
using: composite
steps:
- name: Download the simulator app (this run)
if: inputs.app_run_id == ''
uses: actions/download-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}
- name: Download the simulator app (earlier run)
if: inputs.app_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.app_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}
- name: Unpack the simulator app
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/app"
tar -C "$RUNNER_TEMP/app" -xzf "$RUNNER_TEMP/simulator-app.tar.gz"
echo "APP=$RUNNER_TEMP/app/BulwarkMobile.app" >> "$GITHUB_ENV"
- name: Boot a simulator
shell: bash
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
- name: Install Maestro
shell: bash
run: |
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Install Playwright for the helper
if: inputs.playwright == 'true'
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/pw"
cd "$RUNNER_TEMP/pw"
npm init -y >/dev/null
npm install --no-audit --no-fund playwright@1.59.1 >/dev/null
npx playwright install chromium >/dev/null
echo "PLAYWRIGHT_DIR=$RUNNER_TEMP/pw/node_modules" >> "$GITHUB_ENV"
- name: Start the test helper
shell: bash
run: |
mkdir -p "$OUT/helper"
HELPER_SHOTS="$OUT/helper" nohup node scripts/e2e/helper.mjs > "$OUT/helper/helper.log" 2>&1 &
for i in $(seq 1 20); do
curl -fs http://127.0.0.1:8787/health >/dev/null && break
sleep 0.5
done
curl -fs http://127.0.0.1:8787/health
- name: Run the flows
shell: bash
run: bash scripts/e2e/run-flows.sh "${{ inputs.suite }}" "${{ inputs.flows }}"
- name: Upload screenshots, recording and logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-ios-${{ inputs.name }}
path: e2e-output/
if-no-files-found: ignore
+349
View File
@@ -0,0 +1,349 @@
name: iOS simulator tests
# Builds the app for the iOS Simulator (no signing needed) and runs the
# Maestro flows in .maestro/ on iPhone and iPad simulators:
#
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
# - features: the app feature by feature (.maestro/features), on iPhone and
# iPad in light and dark, plus the iPad in landscape (split layout)
#
# Each leg signs in with its own account on the test server (e2e-* accounts
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
#
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
# kept out of them.
on:
workflow_dispatch:
inputs:
app_run_id:
description: "Reuse the simulator app from this earlier run instead of building (run id)"
type: string
required: false
legs:
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
type: string
default: all
flows:
description: "Only these flows (comma list of file names without .yaml); empty runs all"
type: string
required: false
perf_base_run_id:
description: "perf leg: compare against the simulator app of this run (run id)"
type: string
required: false
perf_rounds:
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
type: string
default: "5"
required: false
release:
types: [published]
concurrency:
# Per branch and set of legs: a perf run doesn't cancel a feature run.
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
cancel-in-progress: true
env:
EXPO_NO_TELEMETRY: "1"
MAESTRO_CLI_NO_ANALYTICS: "1"
# The XCTest driver takes minutes to come up on a cold runner.
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
jobs:
plan:
runs-on: ubuntu-latest
outputs:
signin: ${{ steps.plan.outputs.signin }}
features: ${{ steps.plan.outputs.features }}
steps:
- id: plan
env:
LEGS: ${{ inputs.legs || 'all' }}
run: |
node -e '
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
const signin = ["phone", "tablet"]
.map((device) => ({ name: `signin-${device}`, device }))
.filter((l) => want(l.name, "signin"));
const features = [
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
].filter((l) => want(l.name, "features"));
// Push on iOS (.maestro/push): only on request, the app needs the
// notification extension (plugins/ios-push).
if (legs.includes("push")) {
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
}
const fs = require("fs");
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
'
build:
if: inputs.app_run_id == ''
runs-on: macos-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Cache CocoaPods
uses: actions/cache@v4
with:
path: |
~/Library/Caches/CocoaPods
~/.cocoapods/repos
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pods-${{ runner.os }}-
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
env:
# Simulator device tokens belong to the APNs sandbox.
APS_ENVIRONMENT: development
run: npx expo prebuild --platform ios --clean
- name: Build for the simulator
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The bundle times each module's evaluation for the perf leg, and
# Metro writes which id is which module (metro.config.js; a branch
# without that support ignores both).
BULWARK_MODULE_TIMINGS: '1'
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
run: |
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
mkdir -p e2e-build
# Release bundles the JS into the app, so no Metro server is needed.
# arm64 only: the runner is Apple silicon, and a second simulator
# architecture would double the build time.
if ! xcodebuild build \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
ARCHS=arm64 \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
PROVISIONING_PROFILE_SPECIFIER= \
> e2e-build/xcodebuild.log 2>&1; then
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
tail -n 80 e2e-build/xcodebuild.log
exit 1
fi
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
# Xcode embeds the simulator entitlements the keychain needs. An
# unsigned build has none, and every expo-secure-store call fails
# with "A required entitlement isn't present".
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
echo "Simulated entitlements:"
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
# Packed as a tar: artifacts drop the executable bit, which would
# leave a reused app unable to launch.
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
else
echo "no module map (branch without module timings)"
fi
gzip -f "$RUNNER_TEMP/simulator-app.tar"
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
- name: Upload the simulator app
uses: actions/upload-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}/simulator-app.tar.gz
if-no-files-found: error
- name: Upload the build log
if: failure()
uses: actions/upload-artifact@v4
with:
name: xcodebuild-log
path: e2e-build/
signin:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
fromJson(needs.plan.outputs.signin).include[0] != null
runs-on: macos-latest
timeout-minutes: 150
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: ${{ matrix.device }}
APPEARANCE: light
ORIENTATION: portrait
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/e2e-ios-leg
with:
suite: signin
app_run_id: ${{ inputs.app_run_id }}
flows: ${{ inputs.flows }}
playwright: "true"
name: ${{ matrix.name }}
features:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
fromJson(needs.plan.outputs.features).include[0] != null
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.features) }}
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: ${{ matrix.device }}
APPEARANCE: ${{ matrix.appearance }}
ORIENTATION: ${{ matrix.orientation }}
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
E2E_ACCOUNT: ${{ matrix.account }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/e2e-ios-leg
with:
suite: ${{ matrix.suite || 'features' }}
app_run_id: ${{ inputs.app_run_id }}
flows: ${{ inputs.flows }}
playwright: "false"
name: ${{ matrix.name }}
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
# them on one simulator so both sides share the runner.
perf:
needs: [plan, build]
if: >-
always() && !cancelled() &&
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
contains(inputs.legs, 'perf')
runs-on: macos-latest
timeout-minutes: 120
permissions:
contents: read
actions: read
env:
OUT: ${{ github.workspace }}/e2e-output
DEVICE: phone
APPEARANCE: light
ORIENTATION: portrait
E2E_SERVER: ${{ vars.E2E_SERVER }}
E2E_ACCOUNT: e2e-bulk
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
steps:
- uses: actions/checkout@v4
- name: Download the app under test (this run)
if: inputs.app_run_id == ''
uses: actions/download-artifact@v4
with:
name: simulator-app
path: ${{ runner.temp }}/cand
- name: Download the app under test (earlier run)
if: inputs.app_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.app_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/cand
- name: Download the baseline app
if: inputs.perf_base_run_id != ''
uses: actions/download-artifact@v4
with:
name: simulator-app
run-id: ${{ inputs.perf_base_run_id }}
github-token: ${{ github.token }}
path: ${{ runner.temp }}/base
- name: Unpack
run: |
for side in cand base; do
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
mkdir -p "$RUNNER_TEMP/$side/app"
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
done
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
- name: Boot a simulator
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
- name: Install Maestro
run: |
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Sign in once
run: |
mkdir -p "$OUT/screenshots"
cd "$OUT/screenshots"
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
- name: Measure
run: |
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
fi
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-ios-perf
path: e2e-output/
if-no-files-found: ignore
+156 -9
View File
@@ -1,4 +1,4 @@
name: Release Android APK
name: Release Android
on:
release:
@@ -137,7 +137,7 @@ jobs:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew assembleRelease --no-daemon
./gradlew assembleGithubRelease --no-daemon
- name: Put apksigner on the PATH
run: |
@@ -188,7 +188,7 @@ jobs:
# Oldest signer first: apksigner uses it for v2, and the rotated key
# for v3 from API 28. Without --rotation-min-sdk-version it would
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
APK=android/app/build/outputs/apk/release/app-release.apk
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
apksigner sign \
--v4-signing-enabled false \
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
@@ -210,7 +210,7 @@ jobs:
ROTATED: ${{ steps.rotation.outputs.rotated }}
run: |
set -euo pipefail
APK=android/app/build/outputs/apk/release/app-release.apk
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
apksigner verify --verbose --print-certs "$APK"
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
@@ -248,12 +248,12 @@ jobs:
run: |
set -euo pipefail
VERSION=$(cat VERSION | tr -d '[:space:]')
SRC=android/app/build/outputs/apk/release/app-release.apk
SRC=android/app/build/outputs/apk/github/release/app-github-release.apk
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
cp "$SRC" "$DEST"
sha256sum "$DEST" > "$DEST.sha256"
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt
MAPPING=""
if [ -f "$MAPPING_SRC" ]; then
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
@@ -299,12 +299,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# A manual run with no tag only builds: it must not replace the APK
# of the latest release with one from another commit.
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
TAG="${{ github.event.inputs.attach_to_release_tag }}"
else
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
TAG="${{ github.event.inputs.attach_to_release_tag }}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then
@@ -319,3 +319,150 @@ jobs:
with:
tag_name: ${{ steps.target_tag.outputs.tag }}
files: ${{ steps.artifact.outputs.files }}
# The Google Play bundle (docs/android-release.md, "Google Play"). It is
# signed with the release key, which Play knows as the upload key; Play
# re-signs what it delivers with its own app signing key. A separate job so
# the GitHub APK isn't held up by the three-ABI native build.
play-bundle:
runs-on: ubuntu-latest
timeout-minutes: 75
permissions:
contents: read
env:
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
steps:
- name: Require the release signing secrets
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
missing=""
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
if [ -n "$missing" ]; then
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md."
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle
- uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Install JS dependencies
run: npm ci
- name: Decode and check the upload key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -euo pipefail
KEYSTORE="$RUNNER_TEMP/release.keystore"
CERT="$RUNNER_TEMP/release-cert.der"
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
-storepass:env KEYSTORE_PASSWORD -file "$CERT"
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then
echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key."
exit 1
fi
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
# Play splits a bundle per ABI, so the extra architectures cost a phone
# nothing and let 32-bit ARM phones and x86_64 Chromebooks install it.
- name: Build the Play bundle
working-directory: android
env:
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
chmod +x ./gradlew
./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
# Play rejects a bundle signed with another key than the registered
# upload key, and its policies reject a self-updater and the media
# permission. Catch all three here instead of in the Play Console.
- name: Check the bundle
env:
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
run: |
set -euo pipefail
AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab
SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')
echo "Bundle signer SHA-256: $SIGNER"
if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then
echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'."
exit 1
fi
MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n')
if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then
echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile."
exit 1
fi
for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do
if grep -q "android.permission.$permission" <<< "$MANIFEST"; then
echo "::error title=Permission not allowed on Play::The Play bundle declares $permission."
exit 1
fi
done
- name: Name the bundle and R8 mapping with version
id: artifact
run: |
set -euo pipefail
VERSION=$(cat VERSION | tr -d '[:space:]')
AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab"
MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB"
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: ${{ steps.artifact.outputs.aab }}
path: |
${{ steps.artifact.outputs.aab }}
${{ steps.artifact.outputs.mapping }}
if-no-files-found: error
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
# uploads after the first bundle was uploaded by hand in the Console).
# Every release goes out to the internal and closed (beta) testers right
# away; closed testing may wait for Google's review first.
- name: Upload to Google Play (internal and closed testing)
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: org.bulwarkmail.mobile
releaseFiles: ${{ steps.artifact.outputs.aab }}
mappingFile: ${{ steps.artifact.outputs.mapping }}
releaseName: ${{ steps.artifact.outputs.version }}
tracks: internal,beta
status: completed
+61 -39
View File
@@ -59,9 +59,6 @@ jobs:
- name: Install JS dependencies
run: npm ci
- name: Generate the iOS project
run: npx expo prebuild --platform ios --clean
- name: Check for signing credentials
id: creds
env:
@@ -69,14 +66,69 @@ jobs:
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
TEAM: ${{ secrets.APPLE_TEAM_ID }}
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
if [ -z "$NSE_PROFILE" ]; then
# The notification service extension (plugins/ios-push) is its
# own App ID and needs its own App Store profile.
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
exit 1
fi
echo "signed=true" >> "$GITHUB_OUTPUT"
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
fi
- name: Install the provisioning profiles
if: steps.creds.outputs.signed == 'true'
id: profiles
env:
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
run: |
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
# One App Store profile per target: the app and its notification
# service extension (plugins/ios-push).
for side in app nse; do
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
echo "$DATA" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
# ID from the profile keeps ExportOptions honest if app.config.js
# changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
done
- name: Generate the iOS project
env:
# plugins/ios-push names each target's profile: the command line
# can't, it would give every target the same one.
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
run: npx expo prebuild --platform ios --clean
- name: Resolve workspace and scheme
id: xcode
run: |
@@ -141,38 +193,6 @@ jobs:
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
- name: Install the provisioning profile
if: steps.creds.outputs.signed == 'true'
id: profile
env:
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
run: |
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
# before anything inside it can be read.
PLIST=$(security cms -D -i "$PROFILE")
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
# from the profile keeps ExportOptions honest if app.config.js changes.
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
BUNDLE_ID="${APP_ID#*.}"
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
# Xcode moved the profile directory in 16; write both so this does not
# silently stop working on a runner image bump.
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
echo "name=$NAME" >> "$GITHUB_OUTPUT"
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
- name: Archive
if: steps.creds.outputs.signed == 'true'
env:
@@ -192,8 +212,7 @@ jobs:
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
CODE_SIGN_STYLE=Manual \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
CODE_SIGN_IDENTITY="Apple Distribution"
# Signing failures report a symptom ("no profiles found") rather than the
# setting that caused it, and a re-run costs ~30 minutes. Dump the
@@ -215,8 +234,10 @@ jobs:
id: export
env:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
PROFILE_NAME: ${{ steps.profile.outputs.name }}
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
run: |
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
@@ -230,6 +251,7 @@ jobs:
<key>provisioningProfiles</key>
<dict>
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key><true/>
<key>destination</key><string>export</string>
+36
View File
@@ -0,0 +1,36 @@
# Mailbox list and folders: the drawer (or docked sidebar) lists the role
# folders and custom ones; opening a folder shows its messages.
appId: org.bulwarkmail.mobile
name: Folders
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- takeScreenshot: folders-01-inbox
- assertVisible: ".*Your verification code.*"
- runFlow: ../subflows/open-drawer.yaml
- takeScreenshot: folders-02-drawer
- assertVisible: "Inbox([ ,].*)?"
- assertVisible: "Sent([ ,].*)?"
- assertVisible: "Drafts([ ,].*)?"
- assertVisible: "Archive([ ,].*)?"
# Parent folders read "Collapse, Projects" (the chevron comes first).
- assertVisible: ".*Projects.*"
- tapOn: "Archive([ ,].*)?"
- extendedWaitUntil:
visible: ".*Invoice 2025-12.*"
timeout: 20000
- takeScreenshot: folders-03-archive
- runFlow: ../subflows/open-drawer.yaml
# Projects/Alpha holds the seeded thread.
- runFlow:
when:
notVisible: "Alpha([ ,].*)?"
commands:
- tapOn: "Expand, Projects.*"
- tapOn: "Alpha([ ,].*)?"
- extendedWaitUntil:
visible: ".*Project Alpha kickoff.*"
timeout: 20000
- takeScreenshot: folders-04-alpha
- runFlow: ../subflows/go-to-inbox.yaml
+75
View File
@@ -0,0 +1,75 @@
# Opening messages: plain text, HTML with blocked remote content, a thread,
# and a PDF attachment in the in-app preview.
appId: org.bulwarkmail.mobile
name: Read messages
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
# Plain text
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Grüße aus München
- extendedWaitUntil:
visible: ".*Jürgen.*"
timeout: 10000
- takeScreenshot: read-01-plain
- runFlow: ../subflows/close-message.yaml
# HTML newsletter
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- extendedWaitUntil:
visible: ".*5 new articles.*|Weekly digest"
timeout: 20000
- takeScreenshot: read-02-html
- runFlow:
when:
visible: "Load images"
commands:
- tapOn: "Load images"
- takeScreenshot: read-03-html-images
- runFlow: ../subflows/close-message.yaml
# Attachment and its preview
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Q3 report
- extendedWaitUntil:
visible: ".*q3-report.pdf.*"
timeout: 20000
- takeScreenshot: read-04-attachment
# The message's attachment chip, labelled with the file name alone (on the
# iPad the list row next to it mentions the name in its longer label).
- tapOn: "q3-report.pdf"
- extendedWaitUntil:
visible: "Share"
timeout: 30000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: read-05-attachment-preview
- assertVisible: "q3-report.pdf"
- tapOn: "Close"
# Back on the message (the iPad pane has a quick reply instead of Reply All).
- extendedWaitUntil:
notVisible: "Share"
timeout: 10000
- assertVisible: ".*q3-report.pdf.*"
- runFlow: ../subflows/close-message.yaml
# A thread (Projects/Alpha)
- runFlow:
file: ../subflows/go-to-folder.yaml
env:
FOLDER: Alpha
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Project Alpha kickoff
- takeScreenshot: read-06-thread
- runFlow: ../subflows/go-to-inbox.yaml
+103
View File
@@ -0,0 +1,103 @@
# Compose a new message and send it to userb; userb's mailbox (checked over
# JMAP) must get it.
appId: org.bulwarkmail.mobile
name: Compose and send
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- tapOn:
text: "Subject"
index: 1
- inputText: ${output.subject}
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Hello from the iOS simulator test."
- takeScreenshot: compose-01-filled
# The attach sheet offers its options one by one (it used to be a single
# accessibility element, and picking one opened no picker on iOS).
- tapOn: "Attach"
- extendedWaitUntil:
visible: "Photos & Videos"
timeout: 10000
- assertVisible: "Camera"
# A server with Files offers "Upload from this device" and "Attach from
# Files" instead of a plain "Files".
- assertVisible: "Upload from this device|Files"
- takeScreenshot: compose-02-attach-sheet
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
# The system photo picker runs in another process: XCTest sees none of its
# elements, but taps by position reach it. Pick the first photo (the app
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
# landscape lays the picker out differently, so it only closes the sheet.
- runFlow:
when:
true: ${!output.pickPhoto}
commands:
- tapOn: "Cancel"
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- tapOn: "Photos & Videos"
- extendedWaitUntil:
notVisible: "Draft saved|New Message"
timeout: 10000
optional: true
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-03-photo-picker
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- tapOn:
point: "44%,33%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,26%"
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- tapOn:
point: "16%,25%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "90%,13%"
- extendedWaitUntil:
visible: "New Message"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: compose-04-with-photo
- tapOn: "Send"
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: ${output.subject}
- runFlow:
when:
true: ${output.pickPhoto}
commands:
- assertTrue:
condition: ${output.helper.attachments.length >= 1}
label: "${output.subject} carries the picked photo"
@@ -0,0 +1,72 @@
# Incoming mail while the app is open (sent by userb over JMAP), then reply
# and forward it; userb receives both, the forward with the attachment.
appId: org.bulwarkmail.mobile
name: Incoming, reply, forward
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1
# Arrives without a pull to refresh (push over EventSource).
- extendedWaitUntil:
visible: ".*${output.incoming}.*"
timeout: 90000
- takeScreenshot: incoming-01-arrived
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: ${output.incoming}
- takeScreenshot: incoming-02-open
# Reply
- tapOn:
text: "Reply"
index: 0
- extendedWaitUntil:
visible: "Re: ${output.incoming}"
timeout: 15000
- runFlow:
file: ../subflows/compose-body.yaml
env:
BODY: "Reply from the iOS simulator test."
- takeScreenshot: incoming-03-reply
- tapOn: "Send"
# The toast is gone after a few seconds; delivery is checked below.
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: "Re: ${output.incoming}"
# Forward with the attachment
- extendedWaitUntil:
visible: "Forward"
timeout: 15000
- tapOn:
text: "Forward"
index: 0
- extendedWaitUntil:
visible: "Fwd: ${output.incoming}"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- takeScreenshot: incoming-04-forward
- tapOn: "Send"
- extendedWaitUntil:
visible: "Email sent successfully"
timeout: 20000
optional: true
- runFlow:
file: ../subflows/assert-delivered.yaml
env:
SUBJECT: "Fwd: ${output.incoming}"
ATTACHMENT: e2e-attachment.pdf
- runFlow: ../subflows/close-message.yaml
+27
View File
@@ -0,0 +1,27 @@
# Search: a hit, a miss, clearing.
appId: org.bulwarkmail.mobile
name: Search
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- tapOn: "Search mail..."
- inputText: "verification"
- pressKey: Enter
- extendedWaitUntil:
visible: ".*Your verification code.*"
timeout: 30000
- assertNotVisible: ".*Weekly digest.*"
- takeScreenshot: search-01-hit
- tapOn: "Clear search"
- tapOn: "Search mail..."
- inputText: "zzqx-no-such-mail"
- pressKey: Enter
- extendedWaitUntil:
visible: "No results found"
timeout: 30000
- takeScreenshot: search-02-miss
- tapOn: "Clear"
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 30000
+49
View File
@@ -0,0 +1,49 @@
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
# right toggles read.
appId: org.bulwarkmail.mobile
name: Swipe actions and undo
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
# Swipe the row itself: the avatar checkbox's label has the subject too, and
# a swipe that starts on it only selects the row (#91).
- swipe:
from:
id: email-row
text: ".*Grüße aus München.*"
direction: LEFT
duration: 400
# The undo bar shows for 5 s; when it goes, the Compose button slides into
# its place, and a late tap on UNDO opens the composer. So tap at once and
# check the rest afterwards.
- tapOn:
text: "UNDO"
waitToSettleTimeoutMs: 500
- extendedWaitUntil:
visible: ".*Grüße aus München.*"
timeout: 15000
- takeScreenshot: swipe-02-undone
- swipe:
from:
id: email-row
text: ".*Grüße aus München.*"
direction: RIGHT
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: swipe-03-read-toggled
- swipe:
from:
id: email-row
text: ".*Grüße aus München.*"
direction: RIGHT
duration: 400
- waitForAnimationToEnd:
timeout: 3000
+103
View File
@@ -0,0 +1,103 @@
# Message actions with undo: move to a folder, delete to Trash, select and
# star from the list.
appId: org.bulwarkmail.mobile
name: Move, delete, select
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
# Move via More → Move to
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- tapOn: "more"
- extendedWaitUntil:
visible: "More actions"
timeout: 10000
- takeScreenshot: actions-01-more
# Both the menu row and the sheet title read "Move to" (webmail catalog),
# so wait for the folder list instead of the title.
- tapOn: "Move to"
- extendedWaitUntil:
visible: "Projects"
timeout: 10000
- tapOn:
text: "Projects"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-02-moved
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Delete (to Trash) and undo
- runFlow:
file: ../subflows/open-message.yaml
env:
SUBJECT: Weekly digest
- takeScreenshot: actions-03a-before-delete
- tapOn:
text: "Delete"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- takeScreenshot: actions-03-deleted
- tapOn:
text: "UNDO"
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
waitToSettleTimeoutMs: 500
- runFlow: ../subflows/close-message.yaml
- extendedWaitUntil:
visible: ".*Weekly digest.*"
timeout: 15000
# Select from the list and star, then unstar. On a phone the row can be
# below the fold (earlier runs left mail on top).
- scrollUntilVisible:
element: ".*Grüße aus München.*"
direction: DOWN
centerElement: true
timeout: 20000
# The row, not its avatar checkbox (whose label has the subject too).
- longPressOn:
id: email-row
text: ".*Grüße aus München.*"
- extendedWaitUntil:
visible: "Clear selection"
timeout: 10000
- takeScreenshot: actions-04-selected
# Star (or Unstar, when an earlier run left it starred), then toggle back.
# On a tablet the list column is narrow and Star sits in the More menu.
- runFlow:
when:
notVisible: "Star|Unstar"
commands:
- tapOn: "More actions.*"
- tapOn: "Star|Unstar"
# A bulk action ends selection mode by itself.
- runFlow:
when:
visible: "Clear selection"
commands:
- tapOn: "Clear selection"
- longPressOn:
id: email-row
text: ".*Grüße aus München.*"
- runFlow:
when:
notVisible: "Unstar|Star"
commands:
- tapOn: "More actions.*"
- tapOn: "Unstar|Star"
- runFlow:
when:
visible: "Clear selection"
commands:
- tapOn: "Clear selection"
+60
View File
@@ -0,0 +1,60 @@
# Scheduled send: schedule a message for later, find it under Scheduled,
# cancel it.
appId: org.bulwarkmail.mobile
name: Scheduled send
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 15000
- tapOn: "Recipient email addresses"
- inputText: "userb@example.org"
- pressKey: Enter
- tapOn:
text: "Subject"
index: 1
- inputText: ${output.subject}
- runFlow:
file: ../subflows/compose-body.yaml
env:
FROM_SUBJECT: "yes"
BODY: "Scheduled from the iOS simulator test."
- tapOn: "Schedule send"
- extendedWaitUntil:
visible: "Tomorrow morning.*"
timeout: 10000
- takeScreenshot: scheduled-01-options
- tapOn: "Tomorrow morning.*"
- extendedWaitUntil:
visible: "Scheduled"
timeout: 30000
- takeScreenshot: scheduled-02-confirmed
- tapOn: "OK"
- runFlow:
file: ../subflows/go-to-folder.yaml
env:
FOLDER: Scheduled
- extendedWaitUntil:
visible: ".*${output.subject}.*"
timeout: 30000
- takeScreenshot: scheduled-03-list
- tapOn: ".*${output.subject}.*"
- tapOn: "Cancel send"
- extendedWaitUntil:
visible: "Keep scheduled"
timeout: 10000
# The dialog's button (below its message), not its title or the button
# behind it. Cancelling now moves the message back to Drafts.
- tapOn:
text: "Cancel send"
below: ".*will not be (sent|delivered).*"
- extendedWaitUntil:
notVisible: ".*${output.subject}.*"
timeout: 30000
- takeScreenshot: scheduled-04-cancelled
- runFlow: ../subflows/go-to-inbox.yaml
+144
View File
@@ -0,0 +1,144 @@
# Calendar: the views, create an event with a reminder, edit it, delete it.
appId: org.bulwarkmail.mobile
name: Calendar
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
- tapOn:
id: tab-calendar
# The first visit asks for notification permission (event reminders).
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow: ../subflows/allow-notifications.yaml
- extendedWaitUntil:
visible: "Go to today"
timeout: 30000
- takeScreenshot: calendar-01-month
- tapOn: "Open menu"
- tapOn: "Schedule"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-02-schedule
- tapOn: "Open menu"
- tapOn: "Day"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-03-day
- tapOn: "Open menu"
- tapOn: "Week"
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: calendar-04-week
# Create. The button starts the event on the selected day: make that today
# (a stray tap, such as one through the notification prompt, can select
# another day), so the schedule below finds it after Go to today.
- tapOn: "Go to today"
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "New event"
- extendedWaitUntil:
visible: "Save"
timeout: 10000
- tapOn: "Title"
- inputText: ${output.event}
# Close the keyboard first: a tap while it slides away lands on a form that
# is still moving.
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 3000
# A new event starts on the calendar's default reminders; Customize turns
# them into the event's own list, which has Add reminder.
- scrollUntilVisible:
element: "Customize|Add reminder"
direction: DOWN
timeout: 10000
- runFlow:
when:
visible: "Customize"
commands:
- tapOn: "Customize"
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "Add reminder"
direction: DOWN
timeout: 10000
- repeat:
while:
notVisible: "15 minutes before"
times: 2
commands:
- tapOn: "Add reminder"
- waitForAnimationToEnd:
timeout: 3000
- scrollUntilVisible:
element: "15 minutes before"
direction: DOWN
timeout: 10000
- tapOn: "15 minutes before"
- takeScreenshot: calendar-05-editor
- tapOn: "Save"
# The editor closes (its Save goes; the FAB is labelled "New event" too).
- extendedWaitUntil:
notVisible: "Save"
timeout: 20000
# Find it in the schedule and open it
- tapOn: "Open menu"
- tapOn: "Schedule"
- tapOn: "Go to today"
- scrollUntilVisible:
element: ".*${output.event}.*"
direction: DOWN
timeout: 30000
- tapOn: ".*${output.event}.*"
- extendedWaitUntil:
visible: "Edit"
timeout: 10000
- takeScreenshot: calendar-06-detail
- assertVisible: ".*15 minutes before.*"
# Edit: add a location (an empty field, so no cursor games in the title)
- tapOn: "Edit"
- extendedWaitUntil:
visible: "Edit event"
timeout: 10000
- scrollUntilVisible:
element: "Place or address"
direction: DOWN
timeout: 10000
- tapOn: "Place or address"
- inputText: "Room 42"
- tapOn: "Save"
- extendedWaitUntil:
visible: ".*Room 42.*"
timeout: 20000
- takeScreenshot: calendar-07-edited
# Delete (the detail sheet is still open after saving)
- runFlow:
when:
notVisible: "Delete"
commands:
- tapOn: ".*${output.event}.*"
- extendedWaitUntil:
visible: "Delete"
timeout: 10000
- tapOn: "Delete"
- extendedWaitUntil:
visible: "Delete this event.*"
timeout: 10000
- tapOn:
text: "Delete"
below: "Delete this event.*"
- extendedWaitUntil:
notVisible: ".*${output.event}.*"
timeout: 20000
- takeScreenshot: calendar-08-deleted
- tapOn:
id: tab-mail
+70
View File
@@ -0,0 +1,70 @@
# Contacts: the list, a contact's detail, create one and delete it again.
appId: org.bulwarkmail.mobile
name: Contacts
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.last = 'Tester' + Date.now() % 100000}
- tapOn:
id: tab-contacts
- extendedWaitUntil:
visible: "All Contacts"
timeout: 30000
- takeScreenshot: contacts-01-list
- tapOn: ".*Alice.*"
- extendedWaitUntil:
visible: "Email"
timeout: 10000
- takeScreenshot: contacts-02-detail
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
- tapOn: "New Contact"
- extendedWaitUntil:
visible: "Save"
timeout: 10000
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: contact-first-name
TEXT: E2E
# Return moves to Middle name, then Last name: tapping the field would hit
# the keyboard covering it.
- pressKey: Enter
- pressKey: Enter
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: contact-last-name
TAP: "no"
TEXT: ${output.last}
- takeScreenshot: contacts-03-form
- tapOn: "Save"
- extendedWaitUntil:
visible: ".*${output.last}.*"
timeout: 20000
- takeScreenshot: contacts-04-created
- runFlow:
when:
notVisible: "More actions"
commands:
- tapOn: ".*E2E ${output.last}.*"
- tapOn: "More actions"
- tapOn: "Delete"
- extendedWaitUntil:
visible: "Delete contact"
timeout: 10000
- tapOn:
text: "Delete"
below: "Are you sure.*"
- extendedWaitUntil:
notVisible: ".*E2E ${output.last}.*"
timeout: 20000
- takeScreenshot: contacts-05-deleted
- tapOn:
id: tab-mail
+67
View File
@@ -0,0 +1,67 @@
# Files: open a folder, preview a text and a PDF file, create and delete a
# folder.
appId: org.bulwarkmail.mobile
name: Files
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
- tapOn:
id: tab-files
- extendedWaitUntil:
visible: ".*Documents.*"
timeout: 30000
- takeScreenshot: files-01-root
- tapOn: ".*Documents.*"
- extendedWaitUntil:
visible: ".*readme.txt.*"
timeout: 20000
- tapOn: ".*readme.txt.*"
- extendedWaitUntil:
visible: "readme.txt"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: files-02-text-preview
- tapOn: "Close"
- tapOn: ".*q3-report.pdf.*"
- extendedWaitUntil:
visible: "q3-report.pdf"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: files-03-pdf-preview
- tapOn: "Close"
- tapOn: "Back"
- tapOn: "New Folder"
- tapOn: "Folder name"
- inputText: ${output.folder}
- tapOn: "Create"
- extendedWaitUntil:
visible: ".*${output.folder}.*"
timeout: 20000
- takeScreenshot: files-04-folder-created
- longPressOn: ".*${output.folder}.*"
# Delete moves it to the trash at once, with Undo and no question (like the
# webmail). A tap while the actions sheet still slides in can miss: tap
# again while Delete is still there.
- extendedWaitUntil:
visible: "Delete"
timeout: 10000
- repeat:
while:
visible: "Delete"
times: 3
commands:
- tapOn:
text: "Delete"
optional: true
- waitForAnimationToEnd:
timeout: 3000
- extendedWaitUntil:
notVisible: ".*${output.folder}.*"
timeout: 20000
- tapOn:
id: tab-mail
+33
View File
@@ -0,0 +1,33 @@
# Every settings page opens and shows its content, first half (13-settings-more
# has the rest: one flow for all 24 pages ran into the 15-minute flow limit on
# the iPad simulator).
appId: org.bulwarkmail.mobile
name: Settings pages
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: "Search settings"
timeout: 20000
- takeScreenshot: settings-00-list
# The search also lists the settings inside each page under it, so a common
# word ("Folders", "Calendar") pushes the page's row below the keyboard.
# Each Q matches its page near the top. A PROOF the search lists under the
# row would pass on the iPad without the page, so those differ. The Themes
# cards are one element each, named by the theme: check the page's text.
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
- tapOn:
id: tab-mail
+32
View File
@@ -0,0 +1,32 @@
# Every settings page opens and shows its content, second half (see 12-settings).
appId: org.bulwarkmail.mobile
name: Settings pages, continued
tags:
- features
---
- runFlow: ../subflows/signed-in.yaml
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: "Search settings"
timeout: 20000
- takeScreenshot: settings-00-list-more
# The search also lists the settings inside each page under it, so a common
# word ("Folders", "Calendar") pushes the page's row below the keyboard.
# Each Q matches its page near the top. A PROOF the search lists under the
# row would pass on the iPad without the page, so those differ. The Themes
# cards are one element each, named by the theme: check the page's text.
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "New folder", PROOF: ".*Inbox.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content &", PROOF: "External Content"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Week start", PROOF: "Default view"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Group by", PROOF: "Sort by last name"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "Pick a colou?r theme.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
- tapOn:
id: tab-mail
+30
View File
@@ -0,0 +1,30 @@
# Fresh install: the app starts and shows the sign-in choices, and typing
# into a field works. Needs no server, so it runs before every suite as the
# warm-up for the XCTest driver. It types only a made-up address, so its
# debug log is safe to print.
appId: org.bulwarkmail.mobile
name: Launch to sign-in
tags:
- smoke
---
- launchApp:
clearState: true
clearKeychain: true
# A cold start of a release build on a CI simulator can be slow.
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 120000
- takeScreenshot: warmup-01-sign-in-choices
- tapOn: "Enter server details manually"
- extendedWaitUntil:
visible:
id: login-server-input
timeout: 10000
- runFlow: subflows/dismiss-keyboard-tip.yaml
- runFlow:
file: subflows/type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: typing-probe.example.net
- takeScreenshot: warmup-02-typed
+27
View File
@@ -0,0 +1,27 @@
# Perf: open a message and go back, five times. The app is already running
# with -perfMarks YES (no launchApp here: it would drop the launch argument).
appId: org.bulwarkmail.mobile
name: Perf open message
---
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
- repeat:
times: 5
commands:
- tapOn:
id: email-row
index: 1
- extendedWaitUntil:
visible: "Reply All"
timeout: 30000
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
- waitForAnimationToEnd:
timeout: 3000
+33
View File
@@ -0,0 +1,33 @@
# Perf: switch through the tabs, three times.
appId: org.bulwarkmail.mobile
name: Perf tab switches
---
- repeat:
times: 3
commands:
- tapOn:
id: tab-calendar
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn: "Allow"
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: tab-contacts
- waitForAnimationToEnd:
timeout: 3000
- takeScreenshot: perf-contacts
- tapOn:
id: tab-files
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: tab-settings
- waitForAnimationToEnd:
timeout: 3000
- tapOn:
id: tab-mail
- waitForAnimationToEnd:
timeout: 3000
+23
View File
@@ -0,0 +1,23 @@
# Perf: open the composer and close it untouched, five times.
appId: org.bulwarkmail.mobile
name: Perf compose open
---
- tapOn:
id: tab-mail
- repeat:
times: 5
commands:
- tapOn: "Compose"
- extendedWaitUntil:
visible: "New Message"
timeout: 20000
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "Close"
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Discard"
commands:
- tapOn: "Discard"
+28
View File
@@ -0,0 +1,28 @@
# Perf: fling through the long inbox (thousands of messages), then back up.
appId: org.bulwarkmail.mobile
name: Perf list scroll
---
- tapOn:
id: tab-mail
- extendedWaitUntil:
visible:
id: email-row
timeout: 30000
- repeat:
times: 8
commands:
- swipe:
start: 50%,80%
end: 50%,20%
duration: 250
- waitForAnimationToEnd:
timeout: 4000
- repeat:
times: 8
commands:
- swipe:
start: 50%,20%
end: 50%,80%
duration: 250
- waitForAnimationToEnd:
timeout: 4000
+11
View File
@@ -0,0 +1,11 @@
# Performance runs: sign in once (ACCOUNT, the bulk account with thousands of
# messages). Later installs of other builds keep the data and the keychain.
appId: org.bulwarkmail.mobile
name: Perf sign-in
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
INBOX_TEXT: ".*Bulk.*"
+79
View File
@@ -0,0 +1,79 @@
# Push on iOS: the relay's payload carries only ids and a placeholder text.
# A tap opens the message, with the app running and from a cold start. The
# helper sends the message as userb and pushes the relay's payload with
# simctl, so this needs no APNs key.
#
# simctl push hands the payload to the simulator as a local notification
# request: the notification service extension (plugins/ios-push), which
# puts sender and subject in, never runs that way (the log shows no
# NotificationService process). So the banner shows the placeholder here;
# the extension needs a real APNs push to be tested.
appId: org.bulwarkmail.mobile
name: Push notification on iOS
tags:
- push
---
- runFlow: ../subflows/signed-in.yaml
# Without notification permission iOS drops the push silently, and the
# launch-time grant doesn't reach it on these simulators: let the app ask
# (the Calendar tab does, for event reminders) and allow.
- tapOn:
id: tab-calendar
- extendedWaitUntil:
visible: ".*Would Like to Send You Notifications.*|Go to today"
timeout: 30000
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- tapOn:
text: "Allow"
optional: true
- tapOn:
id: tab-mail
- waitForAnimationToEnd:
timeout: 3000
- pressKey: Home
# 1. The banner shows (and what it says).
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
- extendedWaitUntil:
visible: ".*${output.subject0}.*|.*New message.*"
timeout: 30000
- takeScreenshot: push-01-banner
# Let it go, so the next tap can't hit this one.
- extendedWaitUntil:
notVisible: ".*${output.subject0}.*|.*New message.*"
timeout: 20000
# 2. A tap opens the message, app in the background. The banner shows for a
# few seconds and finding it takes Maestro about as long; taps on the lock
# screen list do nothing on these simulators. So tap where the banner
# appears, right after the push (push-and-tap.yaml).
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject}
- extendedWaitUntil:
visible: "Reply All"
timeout: 60000
- assertVisible: ".*${output.subject}.*"
- takeScreenshot: push-02-opened-warm
# 3. The same from a cold start: the app isn't running.
- stopApp
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
- runFlow:
file: ../subflows/push-and-tap.yaml
env:
SUBJECT: ${output.subject2}
- extendedWaitUntil:
visible: "Reply All"
timeout: 90000
- assertVisible: ".*${output.subject2}.*"
- takeScreenshot: push-04-opened-cold
+8
View File
@@ -0,0 +1,8 @@
// Calls the test helper (scripts/e2e/helper.mjs) on the runner.
// env CALL: path and query, e.g. "/totp" or "/pair?user=x&deliver=openurl".
// The answer lands in output.helper.
const response = http.get(HELPER + CALL);
if (!response.ok) {
throw new Error('helper ' + CALL.split('?')[0] + ' failed: ' + response.status + ' ' + response.body);
}
output.helper = json(response.body);
+32
View File
@@ -0,0 +1,32 @@
# Password sign-in as the first account, sign out, sign in again.
appId: org.bulwarkmail.mobile
name: Password, sign out, sign in again
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- takeScreenshot: password-01-choices
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- takeScreenshot: password-02-confirm
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: password-03-inbox
- runFlow: ../subflows/sign-out.yaml
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 30000
- takeScreenshot: password-04-signed-out
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- takeScreenshot: password-05-signed-in-again
+58
View File
@@ -0,0 +1,58 @@
# A wrong password and an unreachable server: the app says what went wrong
# and stays on the form.
appId: org.bulwarkmail.mobile
name: Password errors
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${ACCOUNT}@example.org
PASSWORD: wrong-password-123
- extendedWaitUntil:
visible: "That didn't work"
timeout: 30000
- takeScreenshot: errors-01-wrong-password
- assertVisible:
id: login-password-input
# Back to the server step, then a host that doesn't resolve.
- tapOn: "Back"
- tapOn: "Back"
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: login-server-input
TAP: "no"
ERASE: "yes"
TEXT: mail.does-not-exist.invalid
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Sign in with a password instead"
- extendedWaitUntil:
visible:
id: login-password-input
timeout: 10000
- runFlow:
file: ../subflows/type-secret.yaml
env:
FIELD: login-password-input
# The email is filled in, so the password field focuses itself.
TAP: "no"
TEXT: ${PASSWORD}
- pressKey: Enter
- extendedWaitUntil:
visible: "Can't reach mail.does-not-exist.invalid|No mail server at mail.does-not-exist.invalid|Sign-in failed"
timeout: 60000
- takeScreenshot: errors-02-unreachable
- assertVisible:
id: login-password-input
+24
View File
@@ -0,0 +1,24 @@
# A TOTP account signs in with a password: Stalwart answers 402 for the right
# password, and the app asks for the code.
appId: org.bulwarkmail.mobile
name: Password with TOTP
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${TOTP_ACCOUNT}@example.org
- extendedWaitUntil:
visible: "Authentication code"
timeout: 30000
- takeScreenshot: totp-01-code-asked
- runFlow: ../subflows/two-factor.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: totp-02-inbox
+20
View File
@@ -0,0 +1,20 @@
# OAuth with Stalwart's own authorization server: the confirm step's
# Continue opens an ASWebAuthenticationSession on Stalwart's sign-in page.
appId: org.bulwarkmail.mobile
name: OAuth (Stalwart)
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/stalwart-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: oauth-01-inbox
+39
View File
@@ -0,0 +1,39 @@
# Backing out of OAuth: Cancel on the system alert, and closing the sheet
# halfway. Both leave the user on the confirm step without an error.
appId: org.bulwarkmail.mobile
name: OAuth cancelled
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Continue"
- extendedWaitUntil:
visible: ".*Wants to Use.*to Sign In.*"
timeout: 30000
- tapOn:
text: "Cancel"
below: ".*Wants to Use.*to Sign In.*"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- assertNotVisible: "Sign-in failed"
- assertNotVisible: "Sign-in was interrupted"
- takeScreenshot: oauth-cancel-01-alert-cancelled
# Accept the alert, then close the sheet on Stalwart's page.
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- extendedWaitUntil:
visible: "you@example.com"
timeout: 60000
- tapOn: "Cancel"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- assertNotVisible: "Sign-in failed"
- assertNotVisible: "Sign-in was interrupted"
- takeScreenshot: oauth-cancel-02-sheet-closed
+20
View File
@@ -0,0 +1,20 @@
# Sign in via webmail: the confirm step on a Bulwark webmail address hands
# off to the webmail's sign-in page in an auth session.
appId: org.bulwarkmail.mobile
name: Webmail hand-off
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: webmail-01-inbox
+24
View File
@@ -0,0 +1,24 @@
# Webmail hand-off for a TOTP account: the webmail asks for the code; if it
# hands back the password, the app finishes with a code of its own. Uses the
# beta webmail: the stable one still has 06's session in the browser, which
# strands the hand-off (see 20-webmail-session.yaml).
appId: org.bulwarkmail.mobile
name: Webmail hand-off with TOTP
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL_BETA}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${TOTP_ACCOUNT}@example.org
TOTP: "yes"
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: webmail-totp-01-inbox
+17
View File
@@ -0,0 +1,17 @@
# Sign-in link from another app (simctl openurl), password account. The
# helper signs in to the test webmail, creates a fresh "Link mobile app" code
# and opens its bulwarkmail://pair link on the simulator.
appId: org.bulwarkmail.mobile
name: Sign-in link from another app
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: link-password-01-inbox
+16
View File
@@ -0,0 +1,16 @@
# "Paste sign-in link" with a fresh pairing link on the clipboard (simctl
# pbcopy), password account.
appId: org.bulwarkmail.mobile
name: Paste sign-in link
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT}&deliver=pbcopy
- runFlow: ../subflows/paste-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: paste-password-01-inbox
+15
View File
@@ -0,0 +1,15 @@
# Sign-in link from another app for a TOTP account.
appId: org.bulwarkmail.mobile
name: Sign-in link, TOTP account
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: link-totp-01-inbox
+15
View File
@@ -0,0 +1,15 @@
# "Paste sign-in link" for a TOTP account.
appId: org.bulwarkmail.mobile
name: Paste sign-in link, TOTP account
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=pbcopy
- runFlow: ../subflows/paste-link.yaml
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
- runFlow: ../subflows/inbox.yaml
- takeScreenshot: paste-totp-01-inbox
+21
View File
@@ -0,0 +1,21 @@
# An expired code (codes live 2 minutes): the helper creates one now and
# opens it after 130 s. The app says so and stays on the sign-in screen.
appId: org.bulwarkmail.mobile
name: Expired sign-in code
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair-later?user=${ACCOUNT}&deliver=openurl&delay=130
- runFlow:
file: ../subflows/confirm-link.yaml
env:
LINK_WAIT: 200000
- extendedWaitUntil:
visible: "That code has expired.*"
timeout: 30000
- takeScreenshot: link-expired-01-notice
- assertVisible: "Enter server details manually"
+44
View File
@@ -0,0 +1,44 @@
# A code that was already used: sign in with it, sign out, open the same link
# again (same app process, then after a restart, which asks the server).
appId: org.bulwarkmail.mobile
name: Used sign-in code
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- runFlow: ../subflows/inbox.yaml
- runFlow: ../subflows/sign-out.yaml
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 30000
- runScript:
file: ../scripts/helper.js
env:
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- extendedWaitUntil:
visible: "That code was already used|That code has expired or was already used"
timeout: 30000
- takeScreenshot: link-used-01-same-process
- stopApp
- launchApp
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 60000
- runScript:
file: ../scripts/helper.js
env:
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
- runFlow: ../subflows/confirm-link.yaml
- extendedWaitUntil:
visible: "That code was already used|That code has expired or was already used"
timeout: 30000
- takeScreenshot: link-used-02-after-restart
- assertVisible: "Enter server details manually"
+35
View File
@@ -0,0 +1,35 @@
# The QR scanner with camera access denied, then allowed (the simulator has
# no camera, so nothing can be scanned). Neither may crash the app.
appId: org.bulwarkmail.mobile
name: Scanner without camera
tags:
- signin
---
- runFlow:
file: ../subflows/start.yaml
env:
CAMERA: deny
- tapOn: "Scan a sign-in code.*"
# Denied for good: "Camera access is disabled..."; not asked yet (the
# simulator doesn't always apply the denial up front): "Bulwark Mail needs
# camera access..." (English catalog) or the built-in fallback wording.
- extendedWaitUntil:
visible: "Camera access is.*|.*needs camera access.*|Scanning a sign-in QR code uses the camera.*"
timeout: 15000
- takeScreenshot: scanner-01-denied
- tapOn: "Close"
- assertVisible: "Enter server details manually"
- runFlow:
file: ../subflows/start.yaml
env:
CAMERA: allow
- tapOn: "Scan a sign-in code.*"
- extendedWaitUntil:
visible: "Sign-in code"
timeout: 15000
- waitForAnimationToEnd:
timeout: 5000
- takeScreenshot: scanner-02-allowed
- tapOn: "Close"
- assertVisible: "Enter server details manually"
+23
View File
@@ -0,0 +1,23 @@
# "Use my email address": example.org has no autodiscovery, so the app moves
# on to the server step and names the domain.
appId: org.bulwarkmail.mobile
name: Email address discovery
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- tapOn: "Use my email address.*"
- extendedWaitUntil:
visible: "What's your email address."
timeout: 10000
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${ACCOUNT}@example.org
- assertVisible: ${ACCOUNT}@example.org
- tapOn: "Continue"
- extendedWaitUntil:
visible: "We couldn't find a server for example.org.*"
timeout: 60000
- takeScreenshot: discovery-01-not-found
- assertVisible:
id: login-server-input
+29
View File
@@ -0,0 +1,29 @@
# Second account by password via "Another account on <host>", then switch
# between the two.
appId: org.bulwarkmail.mobile
name: Add account, password
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/open-add-account.yaml
- takeScreenshot: add-password-01-choices
- tapOn: "Another account on .*"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Sign in with a password instead"
- runFlow:
file: ../subflows/password-form.yaml
env:
LOGIN: ${ACCOUNT2}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-password-02-two-accounts
+41
View File
@@ -0,0 +1,41 @@
# Second account by OAuth. Adding uses an ephemeral auth session, so the
# first account's Stalwart sign-in must not be reused.
appId: org.bulwarkmail.mobile
name: Add account, OAuth
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/stalwart-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow: ../subflows/open-add-account.yaml
- tapOn: "Another account on .*"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Continue"
- runFlow:
when:
visible: ".*Wants to Use.*to Sign In.*"
commands:
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/stalwart-login-page.yaml
env:
LOGIN: ${ACCOUNT2}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-oauth-01-two-accounts
+43
View File
@@ -0,0 +1,43 @@
# Second account via the webmail hand-off, on top of a password account.
appId: org.bulwarkmail.mobile
name: Add account, webmail
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: ../subflows/open-add-account.yaml
- tapOn: "An account somewhere else.*"
- extendedWaitUntil:
visible: "What's the other address."
timeout: 10000
- tapOn: "I know my server address"
- runFlow:
file: ../subflows/type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: ${WEBMAIL}
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
- tapOn: "Continue"
- runFlow:
when:
visible: ".*Wants to Use.*to Sign In.*"
commands:
- runFlow: ../subflows/allow-auth-session.yaml
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${ACCOUNT2}@example.org
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-webmail-01-two-accounts
+37
View File
@@ -0,0 +1,37 @@
# Second account from a sign-in link opened by another app while signed in.
appId: org.bulwarkmail.mobile
name: Add account, sign-in link
tags:
- signin
---
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runScript:
file: ../scripts/helper.js
env:
CALL: /pair?user=${ACCOUNT2}&deliver=openurl
- extendedWaitUntil:
visible: "Open in .*Bulwark Mobile.*|Sign in with a link."
timeout: 60000
- runFlow:
when:
visible: "Open in .*Bulwark Mobile.*"
commands:
- tapOn: "Open"
- extendedWaitUntil:
visible: "Sign in with a link."
timeout: 20000
- takeScreenshot: add-link-01-prompt
- assertVisible: "This link adds an account.*"
- tapOn:
text: "Continue"
below: "Sign in with a link."
- runFlow: ../subflows/inbox.yaml
- runFlow:
file: ../subflows/check-active-account.yaml
env:
ACTIVE: ${ACCOUNT2}@example.org
- takeScreenshot: add-link-02-two-accounts
+52
View File
@@ -0,0 +1,52 @@
# Sign in via webmail while the browser still has a webmail session (from
# 06-webmail, or from using the webmail in Safari): the hand-off must still
# reach the app. Before the fix, the webmail's login page redirected an
# existing session to its own inbox inside the sign-in sheet, and the app
# never got the sign-in.
appId: org.bulwarkmail.mobile
name: Webmail hand-off with an existing webmail session
tags:
- signin
---
# Leaves a webmail session in the shared browser cookies.
- runFlow: ../subflows/start.yaml
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- extendedWaitUntil:
visible: "user@example.com|Hand over to the app|.*Inbox.*"
timeout: 60000
- runFlow:
when:
visible: "user@example.com"
commands:
- runFlow:
file: ../subflows/webmail-login-page.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow:
when:
visible: "Hand over to the app"
commands:
- tapOn: "Hand over to the app"
- runFlow: ../subflows/inbox.yaml
- runFlow: ../subflows/sign-out.yaml
# Again: the webmail now knows the user.
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 30000
- runFlow:
file: ../subflows/manual-server.yaml
env:
HOST: ${WEBMAIL}
- tapOn: "Continue"
- runFlow: ../subflows/allow-auth-session.yaml
- extendedWaitUntil:
visible: "user@example.com|Hand over to the app|Search mail.*"
timeout: 60000
- takeScreenshot: webmail-session-01-second-visit
- assertNotVisible: "Search mail.*"
+11
View File
@@ -0,0 +1,11 @@
# iOS asks before an app opens a sign-in session in the system browser:
# "“Bulwark Mobile” Wants to Use “<domain>” to Sign In". Accept it.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: ".*Wants to Use.*to Sign In.*"
timeout: 30000
- takeScreenshot: auth-session-alert
- tapOn:
text: "Continue"
below: ".*Wants to Use.*to Sign In.*"
@@ -0,0 +1,25 @@
# The app asks for notification permission now and then (calendar
# reminders, push). The system alert covers the whole app, so XCTest sees
# nothing else until it is answered: allow it when it is up. Taps on system
# alerts miss in landscape, so answer it in portrait.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: ".*Would Like to Send You Notifications.*"
commands:
- setOrientation: PORTRAIT
- waitForAnimationToEnd:
timeout: 3000
# Maestro repeats a tap that seems to change nothing; here the repeat
# landed on the app once the alert was gone (a day in the calendar's
# month grid, which opened that day). Tap once.
- tapOn:
text: "Allow"
optional: true
retryTapIfNoChange: false
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
+18
View File
@@ -0,0 +1,18 @@
# The helper checks over JMAP that AS (default userb) received SUBJECT; with
# ATTACHMENT set, that the message carries that file.
appId: org.bulwarkmail.mobile
---
- runScript:
file: ../scripts/helper.js
env:
CALL: /find?as=${AS || 'userb'}&subject=${encodeURIComponent(SUBJECT)}&timeout=60
- assertTrue:
condition: ${output.helper.found}
label: "${SUBJECT} delivered to ${AS || 'userb'}"
- runFlow:
when:
true: ${!!ATTACHMENT}
commands:
- assertTrue:
condition: ${output.helper.attachments.indexOf(ATTACHMENT) >= 0}
label: "${SUBJECT} carries ${ATTACHMENT}"
@@ -0,0 +1,17 @@
# The Settings account card names the active account ACTIVE, and the Account
# page lists another account to switch to. Goes back to Mail afterwards.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: ".*${ACTIVE}.*"
timeout: 20000
- tapOn: ".*${ACTIVE}.*"
- scrollUntilVisible:
element: "Switch to this account"
direction: DOWN
timeout: 20000
- takeScreenshot: accounts-${ACTIVE}
- tapOn:
id: tab-mail
+11
View File
@@ -0,0 +1,11 @@
# Empty the field with id FIELD. A tap puts the cursor where it lands and
# eraseText only deletes before the cursor, so erase in passes: each tap
# lands in the middle of what is left.
appId: org.bulwarkmail.mobile
---
- repeat:
times: 4
commands:
- tapOn:
id: ${FIELD}
- eraseText: 100
+11
View File
@@ -0,0 +1,11 @@
# Leave the message view: Back on a phone; the split layout keeps the list.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
# A tap during the transition lands on stale coordinates.
- waitForAnimationToEnd:
timeout: 5000
+41
View File
@@ -0,0 +1,41 @@
# In the composer: type BODY into the editor. The rich editor is a WebView
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
# the screen, which is the body area below Subject (above the keyboard).
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
# to the body (an iPad in landscape shows hardly any body with the keyboard
# up, so there is nothing to tap).
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${FROM_SUBJECT == 'yes'}
commands:
- pressKey: Enter
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${BODY}
- runFlow:
when:
true: ${FROM_SUBJECT != 'yes'}
commands:
- runFlow:
when:
visible: "Write your message.*"
commands:
- tapOn: "Write your message.*"
# A tap on the WebView doesn't always take the focus from the Subject field
# (on the iPad the body once went into Subject); a second tap does.
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
- runFlow:
when:
notVisible: "Write your message.*"
commands:
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 2000
- tapOn:
point: "50%,50%"
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${BODY}
+31
View File
@@ -0,0 +1,31 @@
# A sign-in link from another app asks first. Continue.
appId: org.bulwarkmail.mobile
---
# simctl openurl acts like a link from another app: iOS asks first.
- extendedWaitUntil:
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
timeout: ${LINK_WAIT || 60000}
- runFlow:
when:
visible: "Open in .*Bulwark Mobile.*"
commands:
- tapOn: "Open"
# Right after a sign-out the app can take a while to get to the link.
- extendedWaitUntil:
visible: "Sign in with a link.|Open the sign-in page of.*"
timeout: 60000
- takeScreenshot: link-confirm-prompt
- tapOn:
text: "Continue"
below: "Sign in with a link."
# A tap while the alert is still animating in can go nowhere: tap again if
# it is still up.
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Sign in with a link."
commands:
- tapOn:
text: "Continue"
below: "Sign in with a link."
@@ -0,0 +1,12 @@
# iOS shows a one-time slide-to-type tip over the keyboard on a fresh
# simulator. The workflow turns it off, but dismiss it if it still shows up.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Speed up your typing by sliding.*"
commands:
# The sign-in screen has its own Continue button; take the tip's.
- tapOn:
text: "Continue"
below: "Speed up your typing by sliding.*"
@@ -0,0 +1,12 @@
# Some routes hand the app a password for a TOTP account; the app then asks
# for a code itself. Answer it when it shows up.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "Authentication code|Inbox"
timeout: 90000
- runFlow:
when:
visible: "Authentication code"
commands:
- runFlow: two-factor.yaml
+9
View File
@@ -0,0 +1,9 @@
# Open folder FOLDER from the drawer or docked sidebar.
appId: org.bulwarkmail.mobile
---
- runFlow: open-drawer.yaml
# A folder row's label joins its texts: "<name>, <unread>, /, <total>".
- tapOn: ${FOLDER}([ ,].*)?
- extendedWaitUntil:
visible: ${FOLDER}
timeout: 15000
+16
View File
@@ -0,0 +1,16 @@
# Back to the inbox list, from wherever in the Mail tab.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
- runFlow:
file: go-to-folder.yaml
env:
FOLDER: Inbox
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
+26
View File
@@ -0,0 +1,26 @@
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
# one-time prompts on the way.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible:
id: tab-mail
timeout: 90000
- runFlow:
when:
visible: "Not now"
commands:
- tapOn: "Not now"
# Adding an account from Settings returns to Settings.
- tapOn:
id: tab-mail
- extendedWaitUntil:
visible:
id: email-row
timeout: 60000
# Rows are one element: sender, subject and preview together. Mail the
# tests sent earlier can push the seeded row below the fold.
- scrollUntilVisible:
element: ${INBOX_TEXT || '.*Your verification code.*'}
direction: DOWN
timeout: 30000
+31
View File
@@ -0,0 +1,31 @@
# From the sign-in choices: enter a server address by hand and continue to the
# confirm step. env HOST: the address as a user types it.
appId: org.bulwarkmail.mobile
---
- tapOn: "Enter server details manually"
- extendedWaitUntil:
visible:
id: login-server-input
timeout: 10000
- runFlow:
file: type.yaml
env:
FIELD: login-server-input
TAP: "no"
TEXT: ${HOST}
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
optional: true
# Once in a while the tap doesn't reach the button (it moves as the keyboard
# goes away): tap again if the form is still there.
- runFlow:
when:
visible:
id: login-server-input
commands:
- tapOn: "Continue"
- extendedWaitUntil:
visible: "Sign in with a password instead"
timeout: 20000
+19
View File
@@ -0,0 +1,19 @@
# Signed in: Settings → account card → Account page → Add account, up to the
# add-mode choices. (On the iPad split layout the Account page is already
# open; tapping the card again is harmless.)
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- extendedWaitUntil:
visible: ".*@example.org, .*"
timeout: 10000
- tapOn: ".*@example.org, .*"
- scrollUntilVisible:
element: "Add account"
direction: DOWN
timeout: 20000
- tapOn: "Add account"
- extendedWaitUntil:
visible: "Add another account"
timeout: 10000
+24
View File
@@ -0,0 +1,24 @@
# Show the folder list: the modal drawer, or nothing to do where the sidebar
# is docked (iPad landscape).
appId: org.bulwarkmail.mobile
---
- waitForAnimationToEnd:
timeout: 3000
- runFlow:
when:
visible: "Toggle menu"
commands:
- tapOn: "Toggle menu"
- extendedWaitUntil:
visible: "Scheduled.*"
timeout: 5000
optional: true
# Once more if the first tap went to a list still settling.
- runFlow:
when:
notVisible: "Scheduled.*"
commands:
- tapOn: "Toggle menu"
- extendedWaitUntil:
visible: "Scheduled.*"
timeout: 10000
+45
View File
@@ -0,0 +1,45 @@
# From a message list: open the message whose row mentions SUBJECT.
# A row near the bottom can count as visible while the phone's tab bar
# covers it (the tap then opens another tab): on the phone, scroll it to the
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
# screen) doesn't reach; its rows fit on screen.
# The avatar's "Select <sender>: <subject>" checkbox mentions the subject
# too, and a tap on it selects the row instead of opening it (#91): match
# the row itself by its id.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element:
id: email-row
text: ".*${SUBJECT}.*"
direction: DOWN
centerElement: true
visibilityPercentage: 100
timeout: 30000
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- scrollUntilVisible:
element:
id: email-row
text: ".*${SUBJECT}.*"
direction: DOWN
timeout: 30000
# A tap while the list still moves lands on another row.
- waitForAnimationToEnd:
timeout: 5000
- tapOn:
id: email-row
text: ".*${SUBJECT}.*"
# The message toolbar's "more" button: "Reply All" shows on the phone, but
# the iPad pane has a quick reply box instead for mail to one recipient.
- extendedWaitUntil:
visible: "more"
timeout: 20000
- waitForAnimationToEnd:
timeout: 5000
+19
View File
@@ -0,0 +1,19 @@
# On the password step: fill in LOGIN and PASSWORD and sign in.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible:
id: login-username-input
timeout: 20000
- runFlow:
file: type.yaml
env:
FIELD: login-username-input
TEXT: ${LOGIN}
- runFlow:
file: type-secret.yaml
env:
FIELD: login-password-input
TEXT: ${PASSWORD}
# Return on the password field signs in.
- pressKey: Enter
+11
View File
@@ -0,0 +1,11 @@
# From the sign-in choices to the inbox: manual server, password.
# env LOGIN: the account's address.
appId: org.bulwarkmail.mobile
---
- runFlow:
file: manual-server.yaml
env:
HOST: ${SERVER}
- tapOn: "Sign in with a password instead"
- runFlow: password-form.yaml
- runFlow: inbox.yaml
+22
View File
@@ -0,0 +1,22 @@
# The helper has put a sign-in link on the simulator's clipboard: open
# "Paste sign-in link", paste it and sign in.
appId: org.bulwarkmail.mobile
---
- tapOn: "Paste sign-in link.*"
- extendedWaitUntil:
visible: "Paste"
timeout: 10000
- tapOn: "Paste"
# iOS asks before an app reads the clipboard.
- runFlow:
when:
visible: "Allow Paste"
commands:
- tapOn: "Allow Paste"
- extendedWaitUntil:
visible: "bulwarkmail://pair.*"
timeout: 10000
- takeScreenshot: paste-link-filled
- tapOn:
text: "Sign in"
below: "bulwarkmail://pair.*"
+23
View File
@@ -0,0 +1,23 @@
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
# where it appears, at once (it shows for a few seconds, about as long as
# Maestro takes to find it). If the tap came too early or too late, push
# again: up to three tries until the message is open.
appId: org.bulwarkmail.mobile
---
- repeat:
while:
notVisible: "Reply All"
times: 3
commands:
- runScript:
file: ../scripts/helper.js
env:
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
- tapOn:
point: "50%,9%"
waitToSettleTimeoutMs: 500
retryTapIfNoChange: false
- extendedWaitUntil:
visible: "Reply All"
timeout: 30000
optional: true
+89
View File
@@ -0,0 +1,89 @@
# Settings list: find the row ROW (regex on "Title, summary"), open it,
# check PROOF shows, screenshot, and go back on a phone.
#
# Rows count as visible while the phone's tab bar or header covers them or
# while they hang half off the iPad's screen, and a tap then misses; the
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
# doesn't reach. So search for the page (Q, a word of its title): the row
# then sits right under the search box. The account card (no Q) is always
# at the top.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${!!Q}
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# Now and then the simulator drops or garbles a typed character
# (seen: "Storagew,"): type it again, a few times if need be.
- repeat:
while:
notVisible: ${ROW}
times: 3
commands:
- tapOn: "Search settings"
- eraseText: 40
- inputText: ${Q}
- waitForAnimationToEnd:
timeout: 3000
# A tap while the list still coasts from the swipe only stops it.
- waitForAnimationToEnd:
timeout: 5000
- tapOn: ${ROW}
- waitForAnimationToEnd:
timeout: 5000
# On the phone the page replaces the list: make sure it opened before
# scrolling (else the swipes below scroll the list away).
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- runFlow:
when:
notVisible: "Back"
commands:
- tapOn: ${ROW}
- extendedWaitUntil:
visible: "Back"
timeout: 10000
# PROOF can be further down the page: scroll the page (the right-hand pane
# on the iPad) until it shows.
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 75%,80%
end: 75%,40%
duration: 600
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- repeat:
while:
notVisible: ${PROOF}
times: 6
commands:
- swipe:
start: 50%,80%
end: 50%,40%
duration: 600
- extendedWaitUntil:
visible: ${PROOF}
timeout: 20000
- takeScreenshot: settings-${NAME}
- runFlow:
when:
visible: "Back"
commands:
- tapOn: "Back"
+45
View File
@@ -0,0 +1,45 @@
# Signed in: sign out of the active account from the end of Settings.
# On the iPad the Settings list is the left column; scroll that one.
appId: org.bulwarkmail.mobile
---
- tapOn:
id: tab-settings
- runFlow:
when:
true: ${DEVICE == 'tablet'}
commands:
- repeat:
while:
notVisible: "Sign out"
times: 12
commands:
- swipe:
start: 30%,85%
end: 30%,30%
duration: 500
- runFlow:
when:
true: ${DEVICE != 'tablet'}
commands:
- scrollUntilVisible:
element: "Sign out"
direction: DOWN
timeout: 30000
# The scroll can stop with the button half under the tab bar, where
# a tap reaches the tab bar instead: scroll on to the end of the list.
- swipe:
start: 50%,70%
end: 50%,30%
duration: 400
- waitForAnimationToEnd:
timeout: 3000
- tapOn: "Sign out"
- extendedWaitUntil:
visible: "The account is removed from this device.*"
timeout: 10000
# The dialog's button, not the Settings row of the same name behind it.
- tapOn:
text: "Sign out"
rightOf: "Cancel"
below: "The account is removed from this device.*"
- takeScreenshot: sign-out-tapped
+36
View File
@@ -0,0 +1,36 @@
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
# Keeps the app's data between flows; signs in only when needed.
appId: org.bulwarkmail.mobile
---
- launchApp:
permissions:
notifications: allow
photos: allow
camera: allow
# Answer the notification prompt (if the app asks) before turning to
# landscape: rotated, taps on system alerts miss.
- setOrientation: PORTRAIT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
timeout: 120000
- runFlow: allow-notifications.yaml
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
- extendedWaitUntil:
visible: "Enter server details manually|Inbox"
timeout: 30000
- runFlow:
when:
visible: "Enter server details manually"
commands:
- runFlow:
file: password-sign-in.yaml
env:
LOGIN: ${ACCOUNT}@example.org
- runFlow: allow-notifications.yaml
- tapOn:
id: tab-mail
- runFlow: go-to-inbox.yaml
@@ -0,0 +1,38 @@
# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any
# non-empty value: the account needs a one-time code).
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "you@example.com"
timeout: 60000
- takeScreenshot: stalwart-login-page
- runFlow:
file: type-web.yaml
env:
HINT: "you@example.com"
TEXT: ${LOGIN}
- tapOn: "••••••••"
- inputText: ${PASSWORD}
- runFlow:
when:
true: ${TOTP == 'yes'}
commands:
- runScript:
file: ../scripts/helper.js
env:
CALL: /totp
- tapOn: "000000"
- inputText: ${output.helper.code}
- tapOn:
text: "Sign in"
index: 1
# Stalwart 1.0.1 asks for consent after Sign in, once per client grant;
# 0.16 and repeat sign-ins go straight back to the app.
- waitForAnimationToEnd:
timeout: 5000
- runFlow:
when:
visible: "Allow"
commands:
- takeScreenshot: stalwart-consent-page
- tapOn: "Allow"
+27
View File
@@ -0,0 +1,27 @@
# Fresh install state, rotated for the landscape leg, on the sign-in choices.
# env CAMERA: allow (default) or deny.
appId: org.bulwarkmail.mobile
---
# A system prompt left over from an earlier flow ("Open in …?") stays on
# screen across app launches.
- runFlow:
when:
visible: "Open in .*"
commands:
- tapOn: "Cancel"
- launchApp:
clearState: true
clearKeychain: true
permissions:
notifications: allow
camera: ${CAMERA || 'allow'}
photos: allow
- runFlow:
when:
true: ${ORIENTATION == 'landscape'}
commands:
- setOrientation: LANDSCAPE_LEFT
# A cold start of a release build on a CI simulator can be slow.
- extendedWaitUntil:
visible: "Enter server details manually"
timeout: 120000
+21
View File
@@ -0,0 +1,21 @@
# The two-factor field is showing (it focuses itself): type a fresh code from
# the helper and sign in.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "Authentication code"
timeout: 30000
- runScript:
file: ../scripts/helper.js
env:
CALL: /totp
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${output.helper.code}
# The number pad has no return key.
- runFlow:
when:
notVisible: "Sign in"
commands:
- hideKeyboard
- tapOn: "Sign in"
+22
View File
@@ -0,0 +1,22 @@
# Type a secret into the field with id FIELD (TAP=no for a field that focuses
# itself; see type.yaml for why the first character goes alone). The value
# can't be checked (a secure field shows dots) and must never be shown, so a
# lost keystroke surfaces as a failed sign-in.
appId: org.bulwarkmail.mobile
---
- evalScript: ${output.secretHead = TEXT.slice(0, 1); output.secretTail = TEXT.slice(1)}
- runFlow:
when:
true: ${TAP != 'no'}
commands:
- tapOn:
id: ${FIELD}
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${output.secretHead}
# A short pause: wait for something that never shows.
- extendedWaitUntil:
visible: "zz-no-such-element-zz"
timeout: 1000
optional: true
- inputText: ${output.secretTail}
+32
View File
@@ -0,0 +1,32 @@
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
# pages in the auth session), and check it arrived as typed. Now and then a
# stray character lands in front of it on the CI simulator; then erase the
# field and type again. The caret is still at the end of what was just
# typed, so eraseText (which deletes before the caret) empties it; selecting
# the text with a long press didn't work and appended instead. SECRET=yes
# skips the check (a password field shows dots). TAP=no types into the
# field that has the focus already.
appId: org.bulwarkmail.mobile
---
- runFlow:
when:
true: ${TAP != 'no'}
commands:
- tapOn: ${HINT}
- waitForAnimationToEnd:
timeout: 3000
- inputText: ${TEXT}
- runFlow:
when:
true: ${SECRET != 'yes'}
commands:
- repeat:
while:
notVisible: ${TEXT}
times: 2
commands:
- eraseText: 150
- waitForAnimationToEnd:
timeout: 2000
- inputText: ${TEXT}
- assertVisible: ${TEXT}
+54
View File
@@ -0,0 +1,54 @@
# Type TEXT into the field with id FIELD and check it arrived in full.
#
# iOS drops keystrokes in two situations seen on the CI simulator: while the
# edit menu (the "AutoFill" bubble) opens, which happens when a flow taps a
# field that already has focus, and right after the first character, while
# the field re-renders. So: no tap for a field that focuses itself (TAP=no;
# Maestro can't see a React Native field's focus), type the first character,
# wait until it shows, then type the rest. Retyped up to twice.
appId: org.bulwarkmail.mobile
---
- evalScript: ${output.typeHead = TEXT.slice(0, 1); output.typeTail = TEXT.slice(1)}
- runFlow:
when:
true: ${TAP != 'no'}
commands:
- tapOn:
id: ${FIELD}
- waitForAnimationToEnd:
timeout: 3000
# ERASE=yes: the field already holds text.
- runFlow:
when:
true: ${ERASE == 'yes'}
commands:
- runFlow: clear-field.yaml
- inputText: ${output.typeHead}
- extendedWaitUntil:
visible:
id: ${FIELD}
text: "${output.typeHead}.*"
timeout: 5000
optional: true
- inputText: ${output.typeTail}
- runFlow:
when:
notVisible:
id: ${FIELD}
text: ${TEXT_PATTERN || TEXT}
commands:
- retry:
maxRetries: 2
commands:
- runFlow: clear-field.yaml
- inputText: ${output.typeHead}
- extendedWaitUntil:
visible:
id: ${FIELD}
text: "${output.typeHead}.*"
timeout: 5000
optional: true
- inputText: ${output.typeTail}
- assertVisible:
id: ${FIELD}
text: ${TEXT_PATTERN || TEXT}
+54
View File
@@ -0,0 +1,54 @@
# The Bulwark webmail's sign-in page inside the auth session (the app's
# "Sign in via webmail" hand-off). env LOGIN, TOTP=yes for a TOTP account.
appId: org.bulwarkmail.mobile
---
- extendedWaitUntil:
visible: "user@example.com"
timeout: 60000
- takeScreenshot: webmail-login-page
- runFlow:
file: type-web.yaml
env:
HINT: "user@example.com"
TEXT: ${LOGIN}
# On the iPhone, Safari's keyboard toolbar (Previous, Next, Done) can sit
# over the password field, and a tap there leaves the focus in Email (the
# password then went into Email). Its Next moves the focus to the password.
- evalScript: ${output.webNext = false}
- runFlow:
when:
visible: "Next"
commands:
- tapOn: "Next"
- waitForAnimationToEnd:
timeout: 2000
- evalScript: ${output.webNext = true}
- runFlow:
file: type-web.yaml
env:
HINT: "Enter your password"
TEXT: ${PASSWORD}
SECRET: "yes"
TAP: "${output.webNext ? 'no' : 'yes'}"
- tapOn: "Sign in"
- runFlow:
when:
true: ${TOTP == 'yes'}
commands:
- extendedWaitUntil:
visible: "000000"
timeout: 30000
- runScript:
file: ../scripts/helper.js
env:
CALL: /totp
- tapOn: "000000"
- inputText: ${output.helper.code}
- tapOn: "Sign in|Verify"
# Before handing over, the webmail asks whether the app may take the sign-in.
- extendedWaitUntil:
visible: "Hand over to the app"
timeout: 60000
- takeScreenshot: webmail-handover-prompt
- tapOn: "Hand over to the app"
+140 -46
View File
@@ -12,6 +12,7 @@ import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
import { pruneEmailSubmissions } from './src/api/submissions';
import type { StateChange } from './src/api/types';
import { dispatchStateChange } from './src/lib/state-change-bus';
import {
@@ -34,26 +35,24 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
notificationTapJmapAccountId,
renewOtherPushAccounts,
resyncPushNotifications,
teardownPushNotificationsForAccount,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import MailTab from './src/screens/MailTab';
import FilesScreen from './src/screens/FilesScreen';
import CalendarScreen from './src/screens/CalendarScreen';
import ContactsTab from './src/screens/ContactsTab';
import ContactDetailScreen from './src/screens/ContactDetailScreen';
import ContactFormScreen from './src/screens/ContactFormScreen';
import GroupDetailScreen from './src/screens/GroupDetailScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ScheduledScreen from './src/screens/ScheduledScreen';
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
// Screens that aren't on the way to the inbox load on first visit
// (getComponent below), so their modules stay out of the startup work.
import { useAccountStore } from './src/stores/account-store';
import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
@@ -62,6 +61,8 @@ import { useEmailStore } from './src/stores/email-store';
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { startPolicySync, usePolicyFeature } from './src/stores/policy-store';
import { startScreenPrivacySync } from './src/lib/screen-privacy';
import { useLocaleStore } from './src/stores/locale-store';
import { useLayout } from './src/lib/use-layout';
import { toast } from './src/stores/toast-store';
@@ -77,10 +78,12 @@ import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
import { draftContextFromEmail } from './src/lib/draft-context';
import {
acceptSignInLink,
enterLinkAccount,
handleDeepLink,
parseDeepLink,
routeParkedSignInLink,
shareToDeepLink,
type AccountSwitchResult,
type DeepLink,
} from './src/navigation/linking';
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
@@ -91,6 +94,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { useOutboxStore } from './src/stores/outbox-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
import { useColors } from './src/theme/colors';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -101,20 +105,26 @@ const navigationRef = createNavigationContainerRef<RootStackParamList>();
// on every getInitialURL(), and a sign-in link must not run twice.
let initialSignInLinkRead = false;
// Move to the account a notification, reminder or link names. An account
// that was signed out meanwhile has nothing safe to open.
async function switchToAccount(accountId: string): Promise<AccountSwitchResult> {
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 ? 'switched' : false;
}
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
if (!navigationRef.isReady()) return;
// The notification carries the account it was generated for. If the user
// has since switched to a different account (or had a different one active
// when the notification arrived), opening EmailThread under the active
// account would fetch the email from the wrong server and fail.
const auth = useAuthStore.getState();
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
const account = useAccountStore.getState().getAccountById(payload.accountId);
if (!account) return; // account was logged out — nothing safe to open.
await auth.switchAccount(payload.accountId);
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
}
// account would fetch the email from the wrong server and fail. After a
// switch the stack starts over on that account's inbox, so Back from the
// message lands there instead of on the old account's screens.
if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return;
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
navigationRef.navigate('EmailThread', {
@@ -132,12 +142,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
// 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;
}
if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) 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++) {
@@ -172,13 +177,7 @@ async function openDeepLink(link: DeepLink): Promise<void> {
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;
},
switchAccount: switchToAccount,
});
// A widget or a link elsewhere brought the app up for this; say it failed
// rather than leave the user wondering why the app just opened.
@@ -234,6 +233,12 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
// The organisation policy can turn a whole area off (webmail feature
// gates); its tab goes away rather than showing as unavailable.
const calendarAllowed = usePolicyFeature('calendarEnabled');
const contactsAllowed = usePolicyFeature('contactsEnabled');
const filesAllowed = usePolicyFeature('filesEnabled');
const hiddenTab = { tabBarItemStyle: { display: 'none' as const }, tabBarButton: () => null };
const t = useLocaleStore((state) => state.t);
// The webmail's navigation names; a tab the server lacks says so.
const tabLabels = {
@@ -258,6 +263,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
<PushOnboardingPrompt />
<AppIconBadge />
<Tab.Navigator
screenListeners={perfMarksEnabled() ? ({ route }) => ({
tabPress: () => perfStart('tab-switch'),
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
}) : undefined}
screenOptions={{
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
@@ -294,6 +303,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Mail"
options={{
title: tabLabels.mail,
tabBarButtonTestID: 'tab-mail',
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
tabBarBadgeStyle: {
@@ -314,46 +324,52 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
</Tab.Screen>
<Tab.Screen
name="Calendar"
component={CalendarScreen}
getComponent={() => require('./src/screens/CalendarScreen').default}
options={{
title: tabLabels.calendar,
tabBarButtonTestID: 'tab-calendar',
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
...(calendarAllowed ? null : hiddenTab),
}}
listeners={{
tabPress: (e) => {
if (!hasCalendar) e.preventDefault();
if (!hasCalendar || !calendarAllowed) e.preventDefault();
},
}}
/>
<Tab.Screen
name="Contacts"
component={ContactsTab}
getComponent={() => require('./src/screens/ContactsTab').default}
options={{
title: tabLabels.contacts,
tabBarButtonTestID: 'tab-contacts',
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
...(contactsAllowed ? null : hiddenTab),
}}
listeners={{
tabPress: (e) => {
if (!hasContacts) e.preventDefault();
if (!hasContacts || !contactsAllowed) e.preventDefault();
},
}}
/>
<Tab.Screen
name="Files"
component={FilesScreen}
getComponent={() => require('./src/screens/FilesScreen').default}
options={{
title: tabLabels.files,
tabBarButtonTestID: 'tab-files',
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
...(filesAllowed ? null : hiddenTab),
}}
listeners={{
tabPress: (e) => {
if (!hasFiles) e.preventDefault();
if (!hasFiles || !filesAllowed) e.preventDefault();
},
}}
/>
@@ -361,6 +377,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
name="Settings"
options={{
title: tabLabels.settings,
tabBarButtonTestID: 'tab-settings',
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
}}
>
@@ -415,7 +432,17 @@ export default function App() {
// Device sync (Android, #34): live changes, the app's own edits and the
// foreground ask Android to sync; accounts are checked against Android's.
startDeviceSyncTriggers();
return useNetworkStore.getState().init();
// The organisation policy of the active account's webmail: the cached
// one at once, a fresh one on start and on every account switch.
const stopPolicySync = startPolicySync();
// Settings > Security > Block screenshots.
const stopScreenPrivacy = startScreenPrivacySync();
const stopNetwork = useNetworkStore.getState().init();
return () => {
stopPolicySync();
stopScreenPrivacy();
stopNetwork?.();
};
}, []);
// Home-screen widgets follow the signed-in data; once the last account is
@@ -628,18 +655,39 @@ export default function App() {
return () => { cancelled = true; };
}, [addAccountForLink]);
// The server refused the stored sign-in of an account (auth store
// keepForSignIn): open the sign-in to it, filled in, over what is cached.
const reauthTarget = useAuthStore((s) => s.reauth);
React.useEffect(() => {
if (!isAuthenticated || !reauthTarget) return;
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (cancelled) return;
}
if (cancelled || !navigationRef.isReady()) return;
if (navigationRef.getCurrentRoute()?.name === 'AddAccount') return;
navigationRef.navigate('AddAccount');
})();
return () => { cancelled = true; };
}, [isAuthenticated, reauthTarget]);
// 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. An account the user turned push off for, or whose device
// registration was revoked, stays off (see resyncPushNotifications).
// setups intact. An account the user turned push off for on this device
// stays off (see resyncPushNotifications).
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
// The setup asks the server through the client: it waits for the
// session, and runs again once one comes up (an account added or
// switched to, a cold start that began offline).
if (!isAuthenticated || !client || !haveLiveSession) return;
let cancelled = false;
const doSetup = async () => {
@@ -658,13 +706,16 @@ export default function App() {
relayBaseUrl,
accountLabel: client.username ?? undefined,
});
if (cancelled) return;
} catch (error) {
console.warn(
'[push] relay setup failed:',
error instanceof Error ? error.message : error,
);
}
if (cancelled) return;
// The other signed-in accounts' subscriptions expire too (webmail #1054); each
// is renewed through a client of its own.
await renewOtherPushAccounts().catch(() => undefined);
};
void doSetup();
@@ -682,7 +733,44 @@ export default function App() {
unsubscribe();
unsubscribeUp();
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId, haveLiveSession]);
// Muting a folder or account changes the server-side push filter (#78),
// which the setup above would only send at the next launch. Send it now,
// after a short pause so a few taps in a row make one update. Compared by
// content, since hydration hands back an equal but new object; the value
// seen first after hydration is the baseline, not a change.
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// The Inbox-only switch changes the same filter (webmail #983).
const pushInboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const mutesKey = JSON.stringify([notificationMutes, pushInboxOnly]);
const lastMutesKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!settingsHydrated) return;
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
lastMutesKey.current = mutesKey;
return;
}
lastMutesKey.current = mutesKey;
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
let cancelled = false;
const timer = setTimeout(async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl || cancelled) return;
try {
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
} catch (error) {
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
}
}, MUTES_RESYNC_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// Only a change of the mutes should send an update; the rest is read live.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutesKey, settingsHydrated]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
@@ -739,6 +827,7 @@ export default function App() {
void email.fetchMailboxes();
if (email.currentMailboxId) void email.refreshEmails();
void useOutboxStore.getState().flush();
void pruneEmailSubmissions();
};
// Foreground liveness for the per-account connection dot: the open
@@ -759,6 +848,11 @@ export default function App() {
});
void start();
// Stalwart 1.0 stops accepting sends once an account holds
// `maxSubmissions` submission records, so remove the ones that can no
// longer be cancelled (throttled per account; a resume or a send runs it
// again).
void pruneEmailSubmissions();
const subscription = AppState.addEventListener('change', (state) => {
const nowActive = state === 'active';
@@ -825,10 +919,10 @@ export default function App() {
<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} />
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
getComponent={() => require('./src/screens/ComposeScreen').default}
// A tablet shows the composer as a panel over the screen it came
// from, so that screen stays drawn behind the dimmed backdrop.
options={isSplit
@@ -838,15 +932,15 @@ export default function App() {
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
<Stack.Screen
name="ContactForm"
component={ContactFormScreen}
getComponent={() => require('./src/screens/ContactFormScreen').default}
options={{
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
<Stack.Screen
name="AddAccount"
options={{
+4 -2
View File
@@ -98,9 +98,11 @@ the audit's "Fix pass, 2026-09-24" section.
| 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** |
| 10 | Webmail 1.10.0 to 1.18.0 and Unreleased (all areas) | [docs/parity/10-webmail-1.10-to-1.18.md](docs/parity/10-webmail-1.10-to-1.18.md) | 112 | 52 | 60 | 10 | 40 | 62 |
| | **Total** | | **527** | **432** | **95** | **42** | **178** | **306** |
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24. Done and
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24 (row 10:
2026-10-29). Done and
Open split them by tick; the P columns count the priority tags on those items
(one item carries none).
+1 -1
View File
@@ -1 +1 @@
0.1.77
0.1.87
+34 -8
View File
@@ -90,8 +90,8 @@ react {
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
// The distribution flavors (github, play) need their debug variants listed.
debuggableVariants = ["githubDebug", "playDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@@ -154,13 +154,39 @@ android {
versionCode appVersionCode.toInteger()
versionName appVersionName
// Android account type of device sync (#34), derived from the package
// name so a later rename needs no other change. The XML in res/xml and
// the Kotlin in sync/ read it as @string/device_sync_account_type.
resValue "string", "device_sync_account_type", "${applicationId}.account"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
/**
* Where the build is distributed. The two are separate apps on a phone:
*
* - github: the APK on GitHub Releases, sideloaded, updated by the in-app
* updater. Keeps the original package name so existing installs update.
* - play: the Google Play bundle. Play forbids an app updating itself
* outside Play, so this flavor has no installer permission and the JS
* hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities).
*
* See docs/android-release.md.
*/
flavorDimensions = ["distribution"]
productFlavors {
github {
dimension "distribution"
applicationId 'com.anonymous.bulwarkmobile'
buildConfigField "String", "DISTRIBUTION", '"github"'
}
play {
dimension "distribution"
applicationId 'org.bulwarkmail.mobile'
buildConfigField "String", "DISTRIBUTION", '"play"'
}
}
applicationVariants.all { variant ->
// Android account type of device sync (#34), derived from the package
// name of each flavor. The XML in res/xml and the Kotlin in sync/ read
// it as @string/device_sync_account_type.
variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
@@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') {
}
}
tasks.configureEach { task ->
if (task.name == 'preReleaseBuild') {
if (task.name ==~ /pre\w*ReleaseBuild/) {
task.dependsOn(checkReleaseSigning)
}
}
+8 -8
View File
@@ -1,13 +1,13 @@
{
"project_info": {
"project_number": "504486613721",
"project_id": "bulwark-mobile",
"storage_bucket": "bulwark-mobile.firebasestorage.app"
"project_number": "752109678859",
"project_id": "bulwark-20c72",
"storage_bucket": "bulwark-20c72.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
"mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
"android_client_info": {
"package_name": "com.anonymous.bulwarkmobile"
}
@@ -15,7 +15,7 @@
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
}
],
"services": {
@@ -26,15 +26,15 @@
},
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
"mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
"android_client_info": {
"package_name": "com.bulwark.bulwarkmobile"
"package_name": "org.bulwarkmail.mobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
}
],
"services": {
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug builds load the bundle from Metro over http, which the main config
forbids (a network security config replaces the manifest's
usesCleartextTraffic). Same trust anchors as the release config.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The in-app updater hands the downloaded release APK to the system
installer (src/lib/install-update.ts). Only the sideloaded GitHub build
updates itself; the Play build is updated by Play. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
</manifest>
@@ -0,0 +1,48 @@
{
"project_info": {
"project_number": "504486613721",
"project_id": "bulwark-mobile",
"storage_bucket": "bulwark-mobile.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
"android_client_info": {
"package_name": "com.anonymous.bulwarkmobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
"android_client_info": {
"package_name": "com.bulwark.bulwarkmobile"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+49 -35
View File
@@ -2,9 +2,16 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<!-- REQUEST_INSTALL_PACKAGES (in-app updater) is declared by the github flavor only. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<!-- Photos are picked with the system photo picker, which needs no media
permission; Play's photo and video policy rejects READ_MEDIA_IMAGES for that. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
<!-- expo-screen-capture registers its screenshot listener on Android 14+ as soon
as it loads; without this permission the app crashes at launch. -->
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
@@ -21,7 +28,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="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
@@ -43,6 +50,13 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="bulwarkmail"/>
</intent-filter>
<!-- Browser sign-in callback of the client the app registers at Stalwart 1.0 (org.bulwarkmail.mobile:/auth/callback). -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.bulwarkmail.mobile"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<action android:name="android.intent.action.SENDTO"/>
@@ -110,224 +124,224 @@
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
</receiver>
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
</receiver>
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
</receiver>
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
</receiver>
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
</receiver>
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
</receiver>
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
</receiver>
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
</receiver>
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
</receiver>
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
</receiver>
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
</receiver>
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
</receiver>
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
</receiver>
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
</receiver>
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
</receiver>
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
</receiver>
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
</receiver>
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
</receiver>
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
</receiver>
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
</receiver>
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
</receiver>
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
</receiver>
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
</receiver>
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
</receiver>
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
</receiver>
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
</receiver>
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
</receiver>
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
</receiver>
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
</receiver>
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
</receiver>
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
</receiver>
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
<action android:name="${applicationId}.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
</receiver>
Binary file not shown.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
The reading fonts in this folder (Settings > Accessibility > Font) are
licensed under the SIL Open Font License, Version 1.1, copied below.
AtkinsonHyperlegibleNext*.ttf:
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
(https://github.com/googlefonts/atkinson-hyperlegible-next)
Lexend*.ttf:
Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend),
with Reserved Font Name "RevReading Lexend".
OpenDyslexic*.ttf:
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
with Reserved Font Name OpenDyslexic.
Copyright (c) 12/2012 - 2019
The files are built by scripts/build-reading-fonts.py from the woff2 files
the webmail bundles (from Fontsource, https://fontsource.org): the variable
Atkinson Hyperlegible Next and Lexend fonts as static regular and bold
instances, OpenDyslexic unchanged apart from the woff2 decompression.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -0,0 +1,18 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
/**
* Facts about this build that JS can't see on its own. `distribution` is the
* product flavor (app/build.gradle): "github" for the sideloaded APK with its
* own updater, "play" for the Google Play bundle, which must not update
* itself (lib/platform-capabilities).
*/
class BulwarkBuildModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName() = "BulwarkBuild"
override fun getConstants(): Map<String, Any> = mapOf(
"distribution" to BuildConfig.DISTRIBUTION,
)
}
@@ -77,6 +77,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
// Delete moves the mail to Trash where there is one: no unlock needed
// for that (#85). Without a Trash it destroys the mail, so it asks.
// Unlock stays the default for a caller that doesn't say.
val deleteNeedsUnlock = !options.hasKey("deleteNeedsUnlock") || options.getBoolean("deleteNeedsUnlock")
// Bitmap fetch + draw off the bridge thread so the caller doesn't
// block waiting for the favicon request.
@@ -86,7 +90,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
postNotification(
notificationId, title, body, preview, largeIcon, bgColorHex,
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
groupTitle, markReadLabel, deleteLabel, replyLabel,
groupTitle, markReadLabel, deleteLabel, replyLabel, deleteNeedsUnlock,
)
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
promise.resolve(null)
@@ -122,6 +126,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
markReadLabel: String,
deleteLabel: String,
replyLabel: String,
deleteNeedsUnlock: Boolean,
) {
val ctx = reactApplicationContext
val intent = Intent(ctx, MainActivity::class.java).apply {
@@ -148,8 +153,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
}
}
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
.setSmallIcon(R.drawable.ic_notification)
// Android 7 has no channels: the notification names its sound.
.setSound(BulwarkMessagingService.soundUri(ctx))
.setLargeIcon(largeIcon)
.setContentTitle(title)
// body is the subject with the "(No subject)" fallback; an empty
@@ -209,8 +216,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
)
builder.addAction(
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
// Android 12+: unlock first, so a locked phone can't delete mail.
.setAuthenticationRequired(true)
// Android 12+: unlock first, so a locked phone can't destroy mail.
.setAuthenticationRequired(deleteNeedsUnlock)
.build()
)
@@ -279,7 +286,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(groupTitle)
.setContentText(if (count == 1) "1 new message" else "$count new messages")
@@ -15,6 +15,8 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
add(BulwarkWidgetsModule(reactContext))
add(BulwarkPdfModule(reactContext))
add(BulwarkBuildModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
@@ -6,6 +6,8 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
@@ -49,7 +51,10 @@ class BulwarkMessagingService : FirebaseMessagingService() {
}
companion object {
const val CHANNEL_ID = "bulwark_mail"
// Plays the Involved sound; see channelId().
const val CHANNEL_ID = "bulwark_mail_involved"
// The first mail channel, with the system's default sound.
const val LEGACY_CHANNEL_ID = "bulwark_mail"
private const val TAG = "BulwarkPush"
private const val START_WAKE_LOCK_MS = 10_000L
@@ -103,10 +108,39 @@ class BulwarkMessagingService : FirebaseMessagingService() {
startWakeLock = null
}
/**
* The channel new-mail notifications go to. A channel's sound can't
* change after it is created, so the Involved sound (the webmail's
* default, lib/notification-sound.ts) needs a channel of its own; the
* old one stays as the user left it. Someone who turned mail
* notifications off in the old channel keeps them off: their
* notifications keep going there.
*/
fun channelId(context: Context): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return CHANNEL_ID
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val legacy = manager.getNotificationChannel(LEGACY_CHANNEL_ID)
return if (legacy != null && legacy.importance == NotificationManager.IMPORTANCE_NONE) {
LEGACY_CHANNEL_ID
} else {
CHANNEL_ID
}
}
/** The Involved sound (res/raw, kept from the shrinker by res/raw/keep.xml). */
fun soundUri(context: Context): Uri =
Uri.parse("android.resource://${context.packageName}/raw/involved_notification")
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (channelId(context) != CHANNEL_ID) return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val sound = soundUri(context)
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channel = NotificationChannel(
CHANNEL_ID,
"Mail notifications",
@@ -114,6 +148,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
).apply {
description = "Incoming email alerts"
enableVibration(true)
setSound(sound, attributes)
}
manager.createNotificationChannel(channel)
}
@@ -0,0 +1,143 @@
package com.anonymous.bulwarkmobile
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.Executors
/**
* Renders PDF pages to PNG files for the in-app file preview, with the
* platform's own PdfRenderer: Android has no PDF view, and its WebView
* doesn't show PDFs either. The preview draws the pages in a WebView of
* its own so they zoom, asking for each page as it scrolls into view.
*
* PdfRenderer is not thread-safe and allows one open page at a time, so
* every call runs on one background thread, and the open document is kept
* between calls for the file the preview shows.
*/
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
private var openPath: String? = null
private var descriptor: ParcelFileDescriptor? = null
private var renderer: PdfRenderer? = null
override fun getName(): String = "BulwarkPdf"
override fun invalidate() {
worker.execute { closeDocument() }
worker.shutdown()
super.invalidate()
}
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
@ReactMethod
fun open(fileUri: String, promise: Promise) {
worker.execute {
try {
val doc = document(fileUri)
val pages = Arguments.createArray()
for (i in 0 until doc.pageCount) {
doc.openPage(i).use { page ->
pages.pushMap(Arguments.createMap().apply {
putInt("width", page.width)
putInt("height", page.height)
})
}
}
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
} catch (e: SecurityException) {
// Password-protected; PdfRenderer can't open those.
closeDocument()
promise.reject("E_PDF_PROTECTED", e.message, e)
} catch (e: Throwable) {
closeDocument()
promise.reject("E_PDF_OPEN", e.message, e)
}
}
}
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
@ReactMethod
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
worker.execute {
try {
val doc = document(fileUri)
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
doc.openPage(index).use { page ->
val w = width.coerceIn(1, MAX_PAGE_PX)
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
try {
// Pages are transparent where nothing is drawn.
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val out = fileOf(outUri)
// The preview's folder is gone once it closed; a page
// still queued then must not bring it back.
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
} finally {
bitmap.recycle()
}
}
promise.resolve(outUri)
} catch (e: Throwable) {
promise.reject("E_PDF_RENDER", e.message, e)
}
}
}
/** Close the document the preview showed. */
@ReactMethod
fun close(fileUri: String, promise: Promise) {
worker.execute {
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
promise.resolve(null)
}
}
private fun document(fileUri: String): PdfRenderer {
val path = fileOf(fileUri).absolutePath
renderer?.let { if (openPath == path) return it }
closeDocument()
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
try {
val doc = PdfRenderer(fd)
descriptor = fd
renderer = doc
openPath = path
return doc
} catch (e: Throwable) {
fd.close()
throw e
}
}
private fun closeDocument() {
try { renderer?.close() } catch (_: Throwable) {}
try { descriptor?.close() } catch (_: Throwable) {}
renderer = null
descriptor = null
openPath = null
}
private fun fileOf(uri: String): File {
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
}
companion object {
// A 4096 px page bitmap is 64 MB; keep well below.
private const val MAX_PAGE_PX = 2480
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The calendar reminder channel (src/lib/calendar-notifications.ts) and the
mail channel (BulwarkMessagingService.kt) name their sound by resource
name, which the release resource shrinker can't see. -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@raw/involved_notification" />
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Trust anchors for every connection the app makes (fetch, the client
certificate module, the sync adapters). Android 7+ trusts only the system
CAs unless the app opts in; a self-hosted server behind a private CA
installed under Settings > Security > Encryption & credentials would fail
with "Network request failed" otherwise (#3). Cleartext stays off, as it is
for an app targeting API 28+ without this file; the debug source set
overrides this for Metro.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

Some files were not shown because too many files have changed in this diff Show More