Compare commits

..
30 Commits
Author SHA1 Message Date
Linus Rath f5c491f6d7 chore: update version number to 0.1.72 2026-10-02 15:02:12 +02:00
Linus Rath 23af324d79 fix: verify app updates without a second slow progress bar
Since releases ship an `.apk.sha256` file, every in-app update checks
the downloaded APK against it. The check read the 40 MB file in base64
chunks and hashed it in JS, which took longer than the download, and it
reset the progress bar to 0 under the "Downloading" label, so the update
looked like it downloaded twice, the second time slowly.

The file is now read and hashed natively through expo-crypto (the JS
hasher remains as a fallback), and the bar stays full under a
"Verifying…" label while the check runs.
2026-10-02 15:02:10 +02:00
Linus Rath 761c35918f chore: update version number to 0.1.71 2026-10-02 14:09:36 +02:00
Linus Rath 86e33b9afe fix: sync trusted senders to the address book by default
The "Sync with address book" toggle under Content & Senders was off until
the user turned it on, so senders trusted on the phone stayed in the
local allow-list and never reached the "Trusted Senders" address book the
other devices read. A setting the user never chose now counts as on;
only an explicit opt-out, or a server without contacts, turns it off.
2026-10-02 14:09:34 +02:00
Linus Rath 2a1265a9ac chore: update version number to 0.1.70 2026-10-02 13:50:49 +02:00
Linus Rath aba5c70b3e docs: record the widget checks after the fixes on Stalwart 0.16 and 1.0 #52
Adds the phase 2 and 3 results to the verification checklist: the
corrections to phase 1 (two link failures were the test rig), every
action and link re-run on both servers, which commit fixes which cause,
the layout review and what is still open.
2026-10-02 13:45:57 +02:00
Linus Rath ccd8030af0 fix: round the widget corners less #52
Widgets had 22dp corners, rounder than the launcher's own widgets and
the app's cards. They now use 16dp, the Pixel launcher's widget radius,
on the surface, its frame and the picker previews.
2026-10-02 13:35:47 +02:00
Linus Rath ad10d4a8aa feat: show contact photos and sender logos in the widget avatars #52
The widgets drew every sender as initials, while the app and the
webmail show the contact's photo or the sender's logo first. The
widget avatars now follow the same order: an embedded contact photo,
then the logo of a non-personal sender domain when sender favicons are
on, then the initials.

A widget draws from the stored snapshot and cannot load anything while
it draws, so the pictures are fetched when the snapshot is built and
kept in it as small data URIs. Logos have a cache of their own (a week
for a logo, a day for a domain without one) that survives being
offline, and pictures over 40 KB or in a format Android cannot decode
stay out.
2026-10-02 13:34:30 +02:00
Linus Rath 9de8c56743 fix: work out avatar initials the way the webmail does, everywhere in the app
The app had six ways to turn a name into avatar initials, and none of
them was the webmail's. The webmail's Avatar skips punctuation and
symbols in front of each name part ("Sofia" Russo is SR, not "S), takes
two letters of a single name and the first letter of the address
without one. Senders, accounts (sidebar and sign-in), contacts, compose
suggestions and the home-screen widgets all use that now.
2026-10-02 13:18:29 +02:00
Linus Rath 5fcf9367a6 fix: show the widgets' current layouts in the widget picker #52
The picker previews still showed the layouts from before the fixes in
this series (Month's arrows, the Vacation switch, the old task rows and
triage header). Redrawn from the sample data at each widget's default
size, light theme, with the rounded corners and frame the launcher
draws.
2026-10-02 13:11:29 +02:00
Linus Rath bba0274a40 feat: translate the home-screen widgets into German #52 2026-10-02 13:05:14 +02:00
Linus Rath d179e1de82 fix: keep "Tap to try again" visible on a widget notice about a long task #52 2026-10-02 13:05:07 +02:00
Linus Rath 63f2e07254 feat: show more of the message on taller triage and latest-message widgets #52
The triage card kept its preview to two lines however tall it was
resized, leaving most of a large card empty, and Latest message showed
no preview at all. Both now use the room they have for the start of
the message.
2026-10-02 13:04:38 +02:00
Linus Rath 80b57161ca fix: say what an empty folder list or a finished task is on the widgets #52
Folder counts showed only its "Folders" heading when there was no
folder to list, which looks broken; it now says there are no folders to
show. A finished task past its due date still read "Overdue" on the
Tasks widget; it shows its date instead.
2026-10-02 13:04:01 +02:00
Linus Rath ee20f9a06d fix: drop widget controls that look like they do something they do not #52
The Month widget drew back and forward arrows, and the Vacation widget
an on/off switch. A widget cannot page through months or switch the
vacation reply, so both only opened the app, which the rest of the
widget already does. The arrows are gone; the switch becomes a chevron,
and the Active/Inactive pill under the title still says which state
the reply is in.
2026-10-02 13:03:19 +02:00
Linus Rath 60b92e6ef4 fix: give widget buttons a 48dp tap area #52
Most widget buttons were 32dp tall (Archive, Delete, Reply and Next on
the triage card, Accept, Maybe and Decline, Send wishes, Mail my free
times), Up next's were 36dp and the round compose/new buttons in the
headers 28-32dp, all under the 48dp a finger needs, so taps landed
beside them and did nothing.

Button and Fab take a transparent tap area around what they draw
(`slop`), and the layouts give up the same amount of padding or gap
next to them, so the widgets look the same but the buttons take a 48dp
tap.
2026-10-02 13:01:57 +02:00
Linus Rath c520e0a125 fix: keep widget text and counts inside small and translated widgets #52
Checked every widget at its smallest size with long names, big counts
and German labels. What spilled over:

- the Inbox header at two cells: the count pushed the compose button
  off the edge (search now goes at that width, the title truncates
  first, and only the unread count stays);
- a long sender pushed the triage card's time and position out;
- German button labels were cut ("Archivie", "Lösc"); the triage
  buttons now drop to icons, Archive and Delete first, when the labels
  do not fit;
- five-digit counts overflowed Unread count, Tag and Mail and next, and
  "/ 23000" wrapped a character per line; counts from 10,000 up are
  shortened (10K, 12,3K) and the total goes where it does not fit;
- "ungelesen" was cut beside the number in Mail and next and moves
  under it instead.

Every widget header now lets its title give way before the buttons on
its right.
2026-10-02 13:01:05 +02:00
Linus Rath c9a0718acf fix: keep the app from closing when a files widget opens it offline #52 2026-10-02 12:57:43 +02:00
Linus Rath c5143975db fix: finish the widget refresh after leaving the app #52
The previous fix started the refresh as the app went to the background,
but it still stopped after its first request: fetch hands every response
over through a JS timer, and React Native pauses timers while the app's
activity is in the background, so the answer was only read once the app
came back. The widgets kept the previous account after a switch and
Home, and changes made in the app reached them late.

The refresh now runs as a headless task of its own,
BulwarkWidgetRefresh, started through BulwarkWidgetRefreshService. A
running headless task keeps the timers going, and the service keeps the
process from being frozen before the widgets are redrawn. Where Android
refuses to start the service the app refreshes in place as before. A
refresh started for a change also no longer joins one that began before
it.
2026-10-02 12:01:31 +02:00
Linus Rath ae3121a746 fix: load calendar and tasks into the widgets when the app is not running #52
The calendar, tasks, files and scheduled-send parts of the widgets only
refreshed while the app's UI was running, because they reuse the app's
stores, which are bound to its JMAP client. A widget event or push that
woke the app without its UI refreshed mail only, so invitations
answered elsewhere, tasks ticked on another device and new events
stayed as the app last left them. A tick or RSVP the server refused in
that state was also never undone, since nothing reloaded those parts.

When no UI has started in the runtime, nothing else uses that client,
so the widget refresh now signs it in to the active account and loads
calendar, tasks, files and scheduled sends the way the app does. Once
the UI starts it owns the client again.
2026-10-02 12:01:20 +02:00
Linus Rath 2cec096040 fix: open a widget's event in the account it belongs to #52
Event links from the widgets named only the event (and the JMAP account
of a shared calendar), never the signed-in account it was shown for.
With another account active by then, the Calendar tab opened without
the event, or with an id both accounts use, on the other account's
event. The links now carry `appAccount`, and the app switches to that
account first, as it already did for message links.
2026-10-02 11:46:53 +02:00
Linus Rath a694751944 fix: send widget RSVPs and task ticks to the item the tap was made on #52
An RSVP or task tick named only the item's id and looked it up in the
stored snapshot when the tap arrived, then used the snapshot's active
account. Between a refresh and the redraw that follows, a tap made on
the old drawing could reach a snapshot of another account, and JMAP ids
are short per-account sequences, so the lookup could match that
account's task or event with the same id. A tick also flipped whatever
the stored state was, so a quick second tap on a drawing that had not
caught up yet undid the first.

The tap now carries what the call needs: the item's account, its server
ids and, for a tick, the state the user asked for. The change laid over
the widget data only touches items of that account. Drawings made
before this keep working through the old lookup.
2026-10-02 11:45:48 +02:00
Linus Rath 6c797f6e2c fix: refresh the widgets when the app goes to the background #52
Leaving the app scheduled the widget refresh with a zero-delay timer.
React Native pauses JS timers while the activity is in the background
(timers in the bridgeless runtime have no immediate path for a zero
delay), so the refresh only ran once the app came back or a widget task
happened to start. Switching accounts and pressing Home left the
widgets on the previous account's mail. The refresh now starts directly
from the app state change.
2026-10-02 11:42:50 +02:00
Linus Rath 7fe4a1ba27 feat: show on the widget when an action could not reach the server #52
A widget button whose JMAP call failed (offline, refused, the account's
credentials unavailable) only logged a console warning, so to the user
the button had simply done nothing, or had worked. The change is now
undone on the widget and a notice says what failed: "Couldn't archive.
Tap to try again." on the triage card, which goes back to the message,
and the same for RSVPs on the invitation card and task ticks on Tasks
and Today overview. Tapping the notice repeats the tap; any new tap or
ten minutes clear it.
2026-10-02 11:41:22 +02:00
Linus Rath 79c53e713c fix: make widget task ticks easy to hit #52
The tick on the Tasks widget was a 22dp circle, and the rest of the row
did nothing: a tap on the title or just beside the circle was lost. It
now takes the row's full height and 50dp of its width, and the rest of
the row opens the calendar. Today overview's task rows grow from 32dp
to 48dp for the same reason; the widget fits fewer of them where space
is short.
2026-10-02 11:32:50 +02:00
Linus Rath 26d0cf3ccc fix: keep a widget archive when a second tap's refresh lands before it #52
Every widget tap runs as its own headless task in the same JS runtime,
and the tasks interleave at every await. Each one loaded the stored
snapshot, edited it and saved it back, then refreshed from the server.
That went wrong in three ways:

- two taps loaded the same snapshot and the second save dropped the
  first change;
- the refresh of the first tap read the server before the second tap's
  archive landed and was stored anyway, so the message came back (seen
  on the emulator with a slow `Email/set`);
- a redraw of an older snapshot could land after a newer one, leaving
  buttons that act on a message the widget no longer shows.

The snapshot now only holds what the server last said. A button's
change is kept as a pending op next to it and laid over it when a
widget draws; it stays until a refresh that started after the server
took it has been stored (calendar and task changes only count as caught
up by a refresh that loaded calendar and tasks). A call that fails
drops its op, which also undoes an archive made offline: before, the
failed refresh kept the edited snapshot and the message stayed hidden
although it was never archived. Reads, writes and redraws of the
widget data run one at a time, and every redraw draws the latest view.
2026-10-02 11:32:38 +02:00
Linus Rath bc74d3bbac fix: keep the app from closing when a calendar widget opens it offline #52 2026-10-02 11:22:10 +02:00
Linus Rath 80f7278a6f fix: stop widget buttons from going dead when two are drawn in the same millisecond #52
react-native-android-widget registers every button's PendingIntent with
the current time in milliseconds as its request code and
FLAG_CANCEL_CURRENT. All buttons of a widget share the same action and
provider, so the request code is the only thing telling them apart: two
buttons registered within one millisecond got the same intent, and the
second registration cancelled the first. That button then did nothing
when tapped. The emulator takes 3-10 ms per button, a current phone well
under one, so which button died depended on timing.

The library is patched with patch-package (run on postinstall) to hand
out request codes that count up from the time, unique in the process
and clear of the codes an earlier process left on the launcher. The
patch keeps the library's CRLF line endings, hence `-text` for
`patches/*.patch`.
2026-10-02 11:14:40 +02:00
Linus Rath 604843a408 docs: list every widget tap target and how each one fails today #52 2026-10-02 11:08:05 +02:00
Linus Rath 1b4b185ac8 chore: allow app store distribution under a provisional AGPL permission
App store terms put limits on recipients, such as device caps and DRM,
that section 10 of the AGPL does not allow, so the app could not be
shipped through the Apple App Store or Google Play without breaking the
licence.

Add a "Bulwark Mobile" header and a section 7 additional permission to
LICENSE that allows app store distribution as long as the source of
every shipped version is public, free, linked from the listing or the
app, and not further restricted.

The permission is not final yet: not every earlier contributor has
answered the consent request. A reservation limits it to contributions
made on or after 30 September 2026 and to older parts whose authors
have agreed. The README licence line says the same.
2026-09-30 12:13:28 +02:00
150 changed files with 2815 additions and 450 deletions
+3
View File
@@ -0,0 +1,3 @@
# patch-package patches must keep the patched files' line endings byte for byte
# (react-native-android-widget ships CRLF sources).
patches/*.patch -text
+61
View File
@@ -1,3 +1,64 @@
Bulwark Mobile
Copyright (C) 2026 Bulwark Project Authors
This project is licensed under the GNU Affero General Public License,
version 3 only, with the additional permission for app store
distribution below. That permission is provisional; see the
reservation at its end.
Additional permission under GNU AGPL version 3 section 7
The copyright holders of this Program give you additional permission to
convey a covered work (this Program, a modified version of it, or a work
that contains parts of it) through an app store, even though the app
store's terms of service, usage rules or technical measures place
restrictions on recipients that section 10 of this License would otherwise
not allow, such as a limit on the number of devices a copy may be installed
on, or digital rights management applied to the copy.
This permission applies only if you:
a) make the Corresponding Source of each version you convey through the
app store available, at no charge and under the terms of this
License, from a network server that anyone can access;
b) state, in the app store listing or inside the application, where
that Corresponding Source can be obtained; and
c) do not impose any further restrictions of your own on the exercise
of the rights granted under this License.
"App store" means a digital distribution service for applications that is
operated by a third party, such as the Apple App Store and TestFlight,
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
AppGallery.
This permission covers only the parts of a covered work that its copyright
holders license under this License; it does not extend to parts licensed
under other terms. As section 7 of this License allows, you may remove this
permission from copies you convey and from your modified versions.
Reservation
This additional permission has not been finalized. The contributors of
earlier parts of this Program have been asked whether they agree to it:
https://github.com/orgs/bulwarkmail/discussions/1113
Until that request is settled, the permission is granted subject to this
reservation:
- It applies to all contributions made on or after 30 September 2026.
These contributions are accepted under this License together with
this permission.
- It applies to parts of this Program written before that date only
where their copyright holders have agreed to it. Parts whose
copyright holders do not agree will be rewritten or removed.
This reservation will be removed once every earlier contributor has
agreed or their parts have been replaced.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
+1 -1
View File
@@ -46,4 +46,4 @@ For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-re
## License
AGPL-3.0-only. See [LICENSE](LICENSE).
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
+1 -1
View File
@@ -1 +1 @@
0.1.69
0.1.72
+1
View File
@@ -74,6 +74,7 @@
</intent-filter>
</service>
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
@@ -14,6 +14,7 @@ class BulwarkFcmPackage : ReactPackage {
add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext))
add(BulwarkDeviceSyncModule(reactContext))
add(BulwarkWidgetsModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
@@ -0,0 +1,20 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
/**
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
* task when the app goes to the background. Run from the app itself the
* refresh stalled: React Native pauses JS timers while the activity is in
* the background, and fetch resolves every response through a timer, so the
* first request went out and its answer was never read. A running headless
* task keeps timers going, and the service keeps the process from being
* frozen before the widgets are redrawn.
*/
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
}
@@ -0,0 +1,28 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
/** JS side: src/widgets/sync.ts. */
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "BulwarkWidgets"
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
@ReactMethod
fun refreshInBackground(promise: Promise) {
try {
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
promise.resolve(true)
} catch (e: IllegalStateException) {
// Background start limits (the app left the foreground too long ago).
promise.resolve(false)
} catch (e: SecurityException) {
promise.resolve(false)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<resources>
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
<dimen name="widget_radius">22dp</dimen>
<dimen name="widget_radius">16dp</dimen>
</resources>
+314
View File
@@ -0,0 +1,314 @@
# Android widgets: verification checklist
Every tap target of the 32 Android home-screen widgets (`src/widgets/`), how
it was tested on the emulator, and what happened. Started for the report
"widget actions sometimes don't work" (#52 follow-up).
## Rig
- Emulator `Bulwark_Pixel7_API35` (Pixel 7, Android 15, x86_64), Pixel
launcher, release APK built with `-PreactNativeArchitectures=x86_64`.
- Servers: the standing test servers, Stalwart 0.16 (`stalwart-test`) and
1.0 (`stalwart-test-v1`), reached through a logging reverse proxy that
records every JMAP method, `notUpdated`/`notCreated` and errors, and can
delay or fail chosen methods on request.
- Accounts: `usera@example.org` (password) and `userb@example.org` (OAuth
through Stalwart's own authorization server) signed in at once. Both are
members of the `team@` group; `usera`'s calendar is shared with `userb`.
Extra test data: tasks for both, invitations each way through server-side
scheduling.
- Placed: Triage, Tasks, Invitations (others in phase 3).
## States
| Code | State | How |
|---|---|---|
| FG | App on screen when the link arrives | `am start` of the link while the app is in front |
| BG | App backgrounded, JS runtime alive | Home after using the app |
| COLD | Process dead | `adb root`, `kill -9 <pid>` (not `am kill`, not `force-stop`) |
| OFF | Offline | `cmd connectivity airplane-mode enable` |
| ACC2 | Item belongs to the non-active account | the other account active in the app |
| SHARED | Item in a shared or group account | `usera`'s calendar seen by `userb` |
| DBL | Two taps in quick succession | `input tap` twice, 150 ms and 1–1.5 s apart |
| UPD | Tap while a refresh is running | proxy delays `Email/set` by 4 s, second tap meanwhile |
A widget tap cannot happen with the app in front, so FG applies to deep
links only. Links were opened with `am start -a VIEW -f 0x10000000`, which is
exactly what the widget provider does (`RNWidgetProvider.openUri`); the
widget's own Reply, card and event taps were also tapped on the launcher.
## Phase 1 results (before fixes, Stalwart 0.16)
### Background actions
| Action | Widgets | BG | COLD | OFF | ACC2 | DBL | UPD | Notes |
|---|---|---|---|---|---|---|---|---|
| archive | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | see A1, A2, A3 |
| trash | Triage | pass | pass | **fail** | n/a | **fail** | **fail** | same code path as archive |
| triageNext | Triage | pass | pass | pass | n/a | pass | pass | local only |
| rsvp | Invitations | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5 |
| toggleTask | Tasks, Today overview | pass | pass | **fail** | **fail** | **fail** | **fail** | A1, A3, A4, A5, A6 |
| markRead | (no button uses it) | – | – | – | – | – | – | kept for the API |
| any button | all | pass* | pass* | – | – | – | – | *on this emulator; A0 |
### Deep links (`links.*` in `src/widgets/clicks.ts`)
| Link | Used by | FG | BG | COLD | OFF | ACC2 | Notes |
|---|---|---|---|---|---|---|---|
| inbox | Inbox, Unread count, Folder counts, Tag, Attachments, Mail and next, Today overview | pass | pass | pass | pass | n/a | |
| message | Inbox/Starred/All accounts rows, Latest message, Triage card, Mail and next, Attachments | pass | pass | pass | **fail** | pass online | L4 |
| reply | Triage | **fail** | **fail** | **fail** | **fail** | **fail** | L1 |
| draft | Last draft | pass | pass | pass | **fail** | pass online | L4 |
| unified (all / starred / role) | All accounts, Starred, Folder counts, Last draft | pass | pass | pass | not run | n/a | |
| scheduled | Outbox, Folder counts | pass | pass | pass | not run | n/a | |
| search (empty / query) | Inbox, Search mail, Favourite people | pass | pass | pass | not run | n/a | empty search opens the list without focusing the field |
| compose (plain) | Inbox, Last draft | pass | pass | pass | pass | n/a | |
| compose with to + subject | Birthdays "Send wishes", Up next "Running late" | **fail** | **fail** | **fail** | **fail** | n/a | L2 |
| calendar | all calendar and plans widgets | pass | pass | pass | **crash** | n/a | L3 |
| event | Week, Month, Today, Next event, Up next, Countdown, Invitations, Mail and next | **fail** | pass | pass | **crash** | **fail** | L3, L5 |
| contacts | Shortcuts, Favourite people, Birthdays | pass | pass | pass | not run | n/a | |
| files | Shortcuts, Recent files | pass | pass | pass | not run | n/a | |
| settings/vacation, /account, /calendar | Vacation, Storage, Free time, Tasks (off) | pass | pass | pass | not run | n/a | |
| video URL | Up next "Join" | – | – | – | – | – | external https link, opens the browser |
### Refresh
| Case | Result | Notes |
|---|---|---|
| App goes to the background | **fail** (sometimes) | R1 |
| Account switched in the app | **fail** | R1: widgets kept the previous account until the next app start |
| Cold periodic update | pass for mail | R2: calendar, tasks, files, scheduled stay as last loaded by the app |
| Back online after an offline tap | **fail** | A1: nothing brings the change back until the app or a 30-minute update runs |
## Causes found
- **A0. Buttons silently dead on fast phones.** The widget library registers
every button's `PendingIntent` with `requestCode = (int)
System.currentTimeMillis()` and `FLAG_CANCEL_CURRENT`. All buttons of one
widget share the same action and component, so two buttons registered in
the same millisecond get the same identity and the second registration
cancels the first: that button then does nothing at all. On the emulator
each registration takes 3–10 ms (measured from the request codes in
`dumpsys activity intents`), so every button survived; a current phone
makes the binder call in well under a millisecond, which makes the dead
button depend on timing — "sometimes". The cancelled-then-recreated
records also pile up (227 for three widgets after an hour of testing).
- **A1. Offline or failed calls look like they worked.** The action edits
the stored snapshot first and relies on the next refresh to undo a failed
call. Offline that refresh fails too and returns the edited snapshot, so
an archive that never reached the server shows as done; a cold refresh
never reloads calendar or tasks, so a refused RSVP or task change is never
undone either. Failures are only `console.warn`ed.
- **A2. A fast second tap repeats the first.** Two taps 150 ms apart both
archived `d2aaaaa3`: the second tap hits the buttons of the old drawing,
which still name the first message. Harmless for archive; for task ticks
the code would flip the task back, because `toggleTask` inverts whatever
the stored snapshot says instead of carrying the state the user saw.
- **A3. A refresh brings back a message just archived.** Each tap is its own
headless task in the same runtime; they interleave at every await. With
`Email/set` delayed 4 s, tap 1's refresh read the server before tap 2's
archive landed, was stored anyway (it started after tap 2's local edit, so
`noteLocalChange` did not catch it) and redrew the archived message
(`baaaaaai`, log 08:44:19), until tap 2's own refresh removed it again.
Separately, load–edit–save of the snapshot is not serialized, so two
concurrent edits can drop one, and redraws of different snapshots can land
out of order. In one run the second tap archived a message the card was
not showing.
- **A4. RSVP and task ticks go to the wrong account after a switch.** They
pick the client from `snapshot.activeAccountId` and look the item up by id
in the current snapshot, not from the tap's own data. JMAP ids are small
per-account sequences (`e`, `f`, `h1kbleaaaaaj`), so a tap on a drawing
made before an account switch can match another account's task or event
with the same id. Not reproduced on the device (the window between the
stored refresh and the redraw is short); follows from the code.
- **A5. Nothing tells the user.** See A1.
- **A6. The task tick target is 22 dp.** A tap on the task's title or a few
dp beside the circle does nothing.
- **Lead 2 (`target` lacks `widgetId`) is ruled out**: the logged click data
of Archive/Delete is `{"id":…,"widgetId":3,"accountId":…}`.
- **Lead 4 (credentials in a cold runtime)**: the password account restores
and acts from a cold start (archive, task tick). The OAuth account with an
access token that expired while the app was dead is checked in phase 2.
- **Lead 5 (`progressUpdated`)** is ruled out: the widget's task update has
carried no `progressUpdated` since the B12 fix, and both 0.16 and 1.0
accept it (`updated`, no `notUpdated`).
- **R1. The refresh on going to the background often never runs.** With an
account switched in the app and Home pressed, no refresh request reached
the proxy and the widgets kept the old account's mail.
- **R2. Cold refreshes skip calendar and tasks.** Only mail has JMAP code of
its own; the rest needs the app's stores, which only run with the UI.
- **L1. Widget Reply never opens the composer.** The reader opens on the
message but the composer does not follow, in every state.
- **L2. "Send wishes" loses its subject.** `parseDeepLink` takes the
subject from the mailto parser, which returns an empty string rather than
nothing, so the link's own `subject` is never used.
- **L3. Calendar links crash the app offline.** On an offline cold start the
Calendar tab mounts `CalendarShareSheet`, whose list of people to share
with calls `ownPrincipalId()` while rendering; with no session that throws
"Not authenticated - call connect() first" and the app closes.
- **L4. Message links for the other account fail offline.** The link
switches accounts first, which needs the server; offline the switch fails
and the app shows "Couldn't open this".
- **L5. Event links carry no account.** With the other account active, an
event link opens the Calendar tab and nothing else (or, with an id that
exists in both accounts, the wrong event). In the foreground the event
link landed on the Inbox.
## Corrections to phase 1
- **L1 and L2 were faults of the test rig, not of the app.** `adb shell am
start -d <url>` handed the URL to the device shell unquoted, and the shell
cut it at the first `&`: Reply lost `&action=reply` (and `&thread=`), and
"Send wishes" lost `&subject=`. Quoted, Reply opens the composer over the
message and the subject arrives, cold, backgrounded and in the foreground,
on 0.16 and 1.0.
- **L4 is two app limits, not a widget fault.** A message link offline needs
the message on the device: with the offline mail cache off (the default)
a message is not kept across app starts and the reader says "Failed to
load email". A link for the other account needs the server for the account
switch, so offline it says "Couldn't open this".
- **R1 had a second half.** Starting the background refresh directly (no
timer) was not enough: fetch resolves every response through a zero-delay
timer, and with the activity paused the first request went out and its
answer was never read. The refresh now runs in a headless task of its own.
## After the fixes (phase 2)
Same rig. 1.0 was reached as `http://127.0.0.1:18191` through `adb
reverse`, so its accounts (`usera@127.0.0.1`) sit next to the 0.16 ones;
that path does not go through the emulator's network, so "offline" on 1.0
was simulated with the proxy refusing `Email/set` (503) instead of airplane
mode.
### Background actions
| Action | BG | COLD | OFF | ACC2 | DBL | UPD | 1.0 | Notes |
|---|---|---|---|---|---|---|---|---|
| archive | pass | pass | pass | n/a | pass | pass | pass | offline: the message comes back on the card with "Couldn't archive. Tap to try again."; the tap archives it once online |
| trash | pass | pass | pass | n/a | pass | pass | pass | offline: "Couldn't delete. Tap to try again."; the tap moves it to Trash once online |
| triageNext | pass | pass | pass | n/a | pass | pass | pass | local only, no server call |
| rsvp | pass | pass | pass | pass (unit test) | not run | not run | call passes, see 1.0 below | offline: the invitation stays with "Couldn't send your answer"; the retry accepts it. DBL and UPD not run on the device: they go through the same serial lane and pending overlay as archive and the tick, and `actions.test.ts` covers both |
| toggleTask | pass | pass | pass | pass (unit test) | pass | not run | call passes, see 1.0 below | two taps 150 ms apart send `completed` twice; offline the tick reverts with a notice naming the task, and tapping the notice once online sends one `CalendarEvent/set` and the task shows done. UPD not run on the device (see rsvp) |
| any button | pass | pass | – | – | – | – | – | request codes unique (107 click intents, 107 codes) |
DBL for archive: two taps 150 ms apart still both name the message the
drawing showed (the second is a harmless repeat); taps a second apart act on
consecutive messages. UPD: with `Email/set` delayed 4 s, two archives 1.5 s
apart leave the card on "5 of 5" for the whole run and nothing comes back
(before: the first archive's refresh brought the second message back). Trash
behaves the same: a double tap names the shown message twice, and two
deletes 1.5 s apart with `Email/set` delayed move two different messages
and nothing comes back.
### Deep links
| Link | FG | BG | COLD | OFF | ACC2 | 1.0 |
|---|---|---|---|---|---|---|
| inbox, unified, scheduled, search, compose | pass | pass | pass | pass | n/a | pass |
| message | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
| reply | pass | pass | pass | pass with the offline cache (L4) | pass | pass |
| draft | pass | pass | pass | only if opened before (L4) | pass | not run (same route as message) |
| compose with to + subject | pass | pass | pass | not run (no server involved) | n/a | pass |
| calendar | pass | pass | pass | pass (no crash) | n/a | opens; see 1.0 below |
| event | pass | pass | pass | opens the Calendar tab; the event needs the server | pass (switches account) | see 1.0 below |
| contacts, settings | pass | pass | pass | pass (Vacation settings say the server does not support it while offline) | n/a | pass |
| files | pass | pass | pass | pass (no crash; the screen shows the raw network error) | n/a | pass |
### Refresh
| Case | Result |
|---|---|
| Account switched in the app, then Home | pass: the widgets show the new account within a second, both directions |
| App goes to the background | pass |
| Cold periodic update | pass: mail, calendar, tasks, files and scheduled (`appDataAt` set) |
| OAuth account with an expired access token, cold | pass: userb's token had expired an hour after sign-in; the cold refresh renewed it and loaded userb's inbox preview |
### Stalwart 1.0
- Mail actions and links pass: the archive race, a refused write with its
notice and retry, trash, triage next, message and reply links cold and
backgrounded, and the inbox, unified, scheduled, search, compose,
calendar, contacts, files and settings links.
- The app on main does not load calendar events or tasks from 1.0: its
`CalendarEvent/get` asks for `recurrenceOverrides` together with
`utcStart`/`utcEnd`, which 1.0 refuses as a whole (`invalidArguments`).
The unmerged `feat/stalwart-1.0` branch fixes that. Until it is merged the
calendar and task widgets are empty against 1.0. The widgets' own calls
were sent to 1.0 directly: the task update (`progress`,
`percentComplete`) and the RSVP patch both come back `updated`.
### Causes and their fixes
| Cause | Commit |
|---|---|
| A0 dead buttons (shared request codes) | `fix: stop widget buttons from going dead when two are drawn in the same millisecond` |
| A3 refresh brings an archived message back, lost concurrent edits, out-of-order redraws | `fix: keep a widget archive when a second tap's refresh lands before it` |
| A1, A5 failures looked like success, no feedback | the commit above (a failed op is dropped) and `feat: show on the widget when an action could not reach the server` |
| A2, A4 tick flips back, item of another account | `fix: send widget RSVPs and task ticks to the item the tap was made on` |
| A6 22dp tick | `fix: make widget task ticks easy to hit` |
| R1 no refresh on leaving the app | `fix: refresh the widgets when the app goes to the background`, `fix: finish the widget refresh after leaving the app` |
| R2 cold refresh skipped calendar and tasks | `fix: load calendar and tasks into the widgets when the app is not running` |
| L3 offline crash from calendar and files widgets | `fix: keep the app from closing when a calendar widget opens it offline`, `fix: keep the app from closing when a files widget opens it offline` |
| L5 event links without account | `fix: open a widget's event in the account it belongs to` |
## Phase 3: layout review
All 32 widgets were drawn through a temporary in-app gallery
(`WidgetPreview`, removed again) at their smallest, default and largest
(4x5) size with sample data, light and dark, German, very long names and
counts (five-digit counts, 50-character names and subjects), empty data,
and the loading, signed-out, not-yet-loaded and unsupported states, and
with the tap areas highlighted. Fixed:
- The Inbox header at two cells pushed the compose button off; header
titles now give way to the buttons, and the search icon goes at that
width.
- A long sender pushed the triage card's time and position out.
- German labels were cut on the triage buttons ("Archivie", "Lösc"); they
drop to icons when they do not fit.
- Five-digit counts overflowed Unread count, Tag and Mail and next, and the
total wrapped a character per line; counts from 10,000 are shortened.
- "ungelesen" was cut in Mail and next; it moves under the number.
- Buttons under 48dp (32–36dp) get a transparent 48dp tap area.
- The Month arrows and the Vacation switch looked like controls but only
opened the app.
- Folder counts with no folders showed a bare heading.
- A finished task past its due date read "Overdue".
- The triage card and Latest message did not use their height for the
preview.
- A failure notice about a long task title cut off "Tap to try again".
- Widget strings were English in every language; German added.
- Avatars showed initials only, worked out differently from the webmail.
Initials now follow the webmail everywhere in the app (first letters of
the first and last name, leading punctuation and emoji skipped), and the
widget avatars show the contact photo or the sender's logo first, as the
app's list does. Logos come from the same favicon service, are fetched
when the snapshot is built and are cached for offline drawing; a logo
over 40 KB, or one Android cannot decode, leaves the initials.
- The corners were rounder (22dp) than the launcher's own widgets; they are
16dp now, previews included.
Checked and left as they are:
- Dark theme: no white fallbacks; the primary is white as in the webmail.
- Large sizes of the 2x2 widgets are mostly empty space. A resize limit
(`maxResizeWidth`) is given in dp, and on launchers with large cells it
would pin a widget below two cells, so there is none.
- RTL is not supported: the widget library does not mirror layouts, and
there are no right-to-left widget strings.
- Mail and next's chip for the following event is 24dp tall; the whole
column also opens the calendar.
## Open
- A0 cannot be shown on this emulator, where each registration takes
3–10 ms; the fix is checked by the request codes being unique.
- Calendar and task widgets against Stalwart 1.0 wait for
`feat/stalwart-1.0` (see above).
- Offline message links need the offline cache; links for the other account
need the server for the switch (L4).
- Widget strings exist in English and German only.
- RSVP double tap and update race, and the tick's update race, were not run
on the device; unit tests cover them.
- The Files screen opened offline shows the raw network error instead of an
offline message (app screen, not a widget).
+4
View File
@@ -6,6 +6,7 @@ import { registerWidgetTaskHandler } from 'react-native-android-widget';
import { pushBackgroundTask } from './src/lib/push-background-task';
import { widgetTaskHandler } from './src/widgets/task-handler';
import { refreshWidgetsInBackground } from './src/widgets/sync';
import { markUiStarted } from './src/widgets/ui-presence';
// Runs in a fresh headless JS runtime when BulwarkPushTaskService is started
// from BulwarkMessagingService on an FCM data message. Must be registered
@@ -25,11 +26,14 @@ AppRegistry.registerHeadlessTask('BulwarkDeviceSync', () => async (data: import(
// Home-screen widgets (Android): every widget event runs this headless task.
registerWidgetTaskHandler(widgetTaskHandler);
// ...and leaving the app refreshes them in one (BulwarkWidgetRefreshService).
AppRegistry.registerHeadlessTask('BulwarkWidgetRefresh', () => refreshWidgetsInBackground);
// A headless push task evaluates this file too, so App is required when the
// UI first renders instead of imported above: importing it here would load
// every screen, store and icon before the task could run.
function Root() {
markUiStarted();
const App = (require('./App') as typeof import('./App')).default;
return createElement(App);
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
"severity_deprecated": "إلزامي",
"severity_security": "أمني",
"title": "تحديثات التطبيق"
"title": "تحديثات التطبيق",
"verifying": "جارٍ التحقق…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.",
"severity_deprecated": "Obligatòria",
"severity_security": "Seguretat",
"title": "Actualitzacions de l'aplicació"
"title": "Actualitzacions de l'aplicació",
"verifying": "S'està verificant…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.",
"severity_deprecated": "Povinná",
"severity_security": "Zabezpečení",
"title": "Aktualizace aplikace"
"title": "Aktualizace aplikace",
"verifying": "Ověřování…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.",
"severity_deprecated": "Påkrævet",
"severity_security": "Sikkerhed",
"title": "Appopdateringer"
"title": "Appopdateringer",
"verifying": "Verificerer…"
}
}
+193 -1
View File
@@ -1168,6 +1168,198 @@
"release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.",
"severity_deprecated": "Erforderlich",
"severity_security": "Sicherheit",
"title": "App-Updates"
"title": "App-Updates",
"verifying": "Wird überprüft…"
},
"widgets": {
"attachments": {
"empty": "Keine neuen Anhänge",
"from_mail": "aus E-Mail",
"title": "Anhänge",
"unknown_sender": "Unbekannter Absender"
},
"birthdays": {
"empty": "Keine Geburtstage in Sicht",
"empty_body": "Geburtstage kommen aus Ihren Kontakten.",
"in_days": "{count, plural, one {in # Tag} other {in # Tagen}}",
"send_wishes": "Gratulieren",
"title": "Geburtstage",
"wishes_subject": "Alles Gute zum Geburtstag!"
},
"calendar": {
"agenda": "Agenda",
"agenda_more": "{day}: {events}",
"all_day": "Ganztägig",
"join": "Teilnehmen",
"join_named": "{name} beitreten",
"more_this_week": "{count, plural, one {# weiterer diese Woche} other {# weitere diese Woche}}",
"no_more_today": "Heute keine Termine mehr",
"nothing_planned": "Nichts im Kalender",
"now": "Jetzt",
"open": "Öffnen",
"running_late": "Verspäte mich",
"running_late_body": "Ich komme ein paar Minuten später zu {title}.",
"running_late_subject": "Verspäte mich: {title}",
"today": "Heute",
"tomorrow": "Morgen",
"unit_days": "{count, plural, one {Tag} other {Tage}}",
"unit_hours": "{count, plural, one {Std.} other {Std.}}",
"unit_minutes": "{count, plural, one {Min.} other {Min.}}",
"unsupported": "Kalender ist auf diesem Server nicht verfügbar",
"untitled": "(ohne Titel)",
"video_call": "Videoanruf"
},
"countdown": {
"days": "{count, plural, one {Tag} other {Tage}}",
"empty": "Keine anstehenden Termine"
},
"draft": {
"edited_at": "Bearbeitet {when}",
"edited_minutes": "{count, plural, one {Vor # Min. bearbeitet} other {Vor # Min. bearbeitet}}",
"edited_now": "Gerade bearbeitet",
"empty": "Keine Entwürfe",
"empty_body": "Nachrichten, die Sie beginnen und nicht senden, warten hier.",
"keep_writing": "Weiterschreiben",
"title": "Entwürfe"
},
"files": {
"empty": "Noch keine Dateien",
"recent": "Zuletzt geändert",
"unsupported": "Dateien sind auf diesem Server nicht verfügbar"
},
"free": {
"busy_until": "Belegt bis {time}",
"free_rest": "Den Rest des Tages frei",
"free_until": "Frei bis {time}",
"mail": "Freie Zeiten mailen",
"mail_subject": "Meine freien Zeiten diese Woche",
"no_calendar": "Kein Kalender in diesem Konto",
"then": "danach {title}",
"then_free": "danach frei"
},
"hub": {
"all_clear": "Alles erledigt",
"mail_unread": "E-Mail · {count} ungelesen",
"next": "Als Nächstes",
"next_on": "Als Nächstes · {day}",
"no_events": "Keine anstehenden Termine",
"now": "Jetzt",
"tasks_due": "Aufgaben · {count} fällig"
},
"invites": {
"accept": "Zusagen",
"decline": "Absagen",
"empty": "Keine offenen Einladungen",
"label": "Einladung · {name}",
"label_plain": "Einladung",
"maybe": "Vielleicht",
"position": "{index} von {count}"
},
"mail": {
"all_accounts": "Alle Konten",
"all_caught_up": "Alles gelesen",
"archive": "Archivieren",
"delete": "Löschen",
"empty_all_accounts": "Ihre Posteingänge sind leer",
"empty_inbox": "Ihr Posteingang ist leer",
"folders": "Ordner",
"in_outbox": "{count, plural, one {# Änderung wartet} other {# Änderungen warten}}",
"in_outbox_short": "{count} warten",
"inbox": "Posteingang",
"more_unread": "+{count} weitere ungelesen",
"no_folders": "Keine Ordner",
"no_starred": "Keine markierten E-Mails",
"no_starred_body": "Markieren Sie eine Nachricht mit einem Stern, um sie hier zu behalten.",
"no_subject": "(kein Betreff)",
"no_unread": "Keine ungelesenen Nachrichten im Posteingang.",
"position": "{index} von {count}",
"reply": "Antworten",
"scheduled": "Geplant",
"starred": "Markiert",
"unread": "ungelesen"
},
"notice": {
"archive": "Archivieren fehlgeschlagen. Tippen zum Wiederholen.",
"mark_read": "Als gelesen markieren fehlgeschlagen. Tippen zum Wiederholen.",
"rsvp": "Antwort nicht gesendet. Tippen zum Wiederholen.",
"task": "„{title}“ nicht aktualisiert. Tippen zum Wiederholen.",
"trash": "Löschen fehlgeschlagen. Tippen zum Wiederholen."
},
"people": {
"empty": "Noch keine Favoriten",
"empty_body": "Die Personen, von denen Sie am meisten hören, erscheinen hier.",
"favourites": "Favoriten"
},
"relative": {
"days": "{count, plural, one {in # Tag} other {in # Tagen}}",
"hours": "in {count} Std.",
"minutes": "in {count} Min.",
"now": "jetzt"
},
"scheduled": {
"day_at": "{day}, {time}",
"empty": "Nichts geplant",
"empty_body": "Nachrichten, die Sie später senden lassen, erscheinen hier.",
"no_recipient": "(kein Empfänger)",
"sending": "Wird gesendet",
"to": "An: {name}",
"today_at": "Heute, {time}",
"tomorrow_at": "Morgen, {time}",
"waiting": "Wartet auf Netz",
"waiting_body": "Offline vorgenommene Änderungen werden gesendet, sobald Sie wieder online sind."
},
"search": {
"no_recent": "Ihre letzten Suchen erscheinen hier.",
"placeholder": "E-Mails durchsuchen…",
"recent": "Letzte Suchen"
},
"shortcuts": {
"calendar": "Kalender",
"compose": "Verfassen",
"contacts": "Kontakte",
"files": "Dateien"
},
"state": {
"error": "Widget kann nicht angezeigt werden",
"loading": "Wird geladen…",
"open_app": "Öffnen Sie die App einmal, falls dies leer bleibt.",
"open_to_load": "Öffnen Sie Bulwark, um dies zu laden",
"signed_out": "Bei Bulwark anmelden"
},
"storage": {
"no_limit": "Kein Speicherlimit",
"title": "Speicher",
"unavailable": "Keine Angaben zum Speicher",
"used_of": "{used} von {total}"
},
"tag": {
"empty": "Noch keine Tags",
"empty_body": "Tags, die Sie in der App vergeben, erscheinen hier.",
"messages": "{count, plural, one {Nachricht} other {Nachrichten}}",
"total": "{count, plural, one {# Nachricht} other {# Nachrichten}}"
},
"tasks": {
"all_done": "Alles erledigt",
"empty": "Keine offenen Aufgaben",
"mark_complete": "Als erledigt markieren",
"mark_incomplete": "Als nicht erledigt markieren",
"no_title": "(ohne Titel)",
"off": "Aufgaben sind aus",
"off_body": "Schalten Sie Aufgaben in den Kalendereinstellungen der App ein",
"open_count": "{count} offen",
"overdue": "Überfällig",
"still_open": "{count} noch offen",
"title": "Aufgaben",
"today": "Heute",
"tomorrow": "Morgen"
},
"vacation": {
"active": "Aktiv",
"inactive": "Inaktiv",
"subject": "Betreff: {subject}",
"title": "Abwesenheitsnotiz",
"unsupported": "Abwesenheitsnotizen sind nicht verfügbar",
"until": "Bis {date}"
}
}
}
+10 -1
View File
@@ -1184,7 +1184,8 @@
"release_page_pending_desc": "Watch the build progress or download the APK manually.",
"severity_deprecated": "Required",
"severity_security": "Security",
"title": "App updates"
"title": "App updates",
"verifying": "Verifying…"
},
"widgets": {
"attachments": {
@@ -1282,6 +1283,7 @@
"in_outbox_short": "{count} waiting",
"inbox": "Inbox",
"more_unread": "+{count} more unread",
"no_folders": "No folders to show",
"no_starred": "No starred mail",
"no_starred_body": "Star a message to keep it here.",
"no_subject": "(no subject)",
@@ -1292,6 +1294,13 @@
"starred": "Starred",
"unread": "unread"
},
"notice": {
"archive": "Couldn't archive. Tap to try again.",
"mark_read": "Couldn't mark as read. Tap to try again.",
"rsvp": "Couldn't send your answer. Tap to try again.",
"task": "Couldn't update “{title}”. Tap to try again.",
"trash": "Couldn't delete. Tap to try again."
},
"people": {
"empty": "No favourites yet",
"empty_body": "The people you hear from most show up here.",
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.",
"severity_deprecated": "Obligatoria",
"severity_security": "Seguridad",
"title": "Actualizaciones de la app"
"title": "Actualizaciones de la app",
"verifying": "Verificando…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
"severity_deprecated": "الزامی",
"severity_security": "امنیتی",
"title": "به‌روزرسانی‌های برنامه"
"title": "به‌روزرسانی‌های برنامه",
"verifying": "در حال بررسی…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.",
"severity_deprecated": "Requise",
"severity_security": "Sécurité",
"title": "Mises à jour de l'application"
"title": "Mises à jour de l'application",
"verifying": "Vérification…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
"severity_deprecated": "חובה",
"severity_security": "אבטחה",
"title": "עדכוני אפליקציה"
"title": "עדכוני אפליקציה",
"verifying": "מאמת…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.",
"severity_deprecated": "Kötelező",
"severity_security": "Biztonsági",
"title": "Alkalmazásfrissítések"
"title": "Alkalmazásfrissítések",
"verifying": "Ellenőrzés…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.",
"severity_deprecated": "Obbligatorio",
"severity_security": "Sicurezza",
"title": "Aggiornamenti dell'app"
"title": "Aggiornamenti dell'app",
"verifying": "Verifica in corso…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
"severity_deprecated": "必須",
"severity_security": "セキュリティ",
"title": "アプリのアップデート"
"title": "アプリのアップデート",
"verifying": "検証中…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
"severity_deprecated": "필수",
"severity_security": "보안",
"title": "앱 업데이트"
"title": "앱 업데이트",
"verifying": "확인 중…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.",
"severity_deprecated": "Obligāts",
"severity_security": "Drošība",
"title": "Lietotnes atjauninājumi"
"title": "Lietotnes atjauninājumi",
"verifying": "Pārbauda…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
"severity_deprecated": "Заавал",
"severity_security": "Аюулгүй байдал",
"title": "Аппын шинэчлэл"
"title": "Аппын шинэчлэл",
"verifying": "Шалгаж байна…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.",
"severity_deprecated": "Påkrevd",
"severity_security": "Sikkerhet",
"title": "Appoppdateringer"
"title": "Appoppdateringer",
"verifying": "Verifiserer…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.",
"severity_deprecated": "Vereist",
"severity_security": "Beveiliging",
"title": "App-updates"
"title": "App-updates",
"verifying": "Controleren…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.",
"severity_deprecated": "Wymagana",
"severity_security": "Bezpieczeństwo",
"title": "Aktualizacje aplikacji"
"title": "Aktualizacje aplikacji",
"verifying": "Weryfikowanie…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.",
"severity_deprecated": "Obrigatória",
"severity_security": "Segurança",
"title": "Atualizações do app"
"title": "Atualizações do app",
"verifying": "Verificando…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.",
"severity_deprecated": "Obligatorie",
"severity_security": "Securitate",
"title": "Actualizări aplicație"
"title": "Actualizări aplicație",
"verifying": "Se verifică…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
"severity_deprecated": "Обязательное",
"severity_security": "Безопасность",
"title": "Обновления приложения"
"title": "Обновления приложения",
"verifying": "Проверка…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.",
"severity_deprecated": "Povinná",
"severity_security": "Zabezpečenie",
"title": "Aktualizácie aplikácie"
"title": "Aktualizácie aplikácie",
"verifying": "Overovanie…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.",
"severity_deprecated": "Zorunlu",
"severity_security": "Güvenlik",
"title": "Uygulama güncellemeleri"
"title": "Uygulama güncellemeleri",
"verifying": "Doğrulanıyor…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
"severity_deprecated": "Обов'язкове",
"severity_security": "Безпека",
"title": "Оновлення застосунку"
"title": "Оновлення застосунку",
"verifying": "Перевірка…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
"severity_deprecated": "必要",
"severity_security": "安全性",
"title": "應用程式更新"
"title": "應用程式更新",
"verifying": "正在驗證…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
"severity_deprecated": "必需",
"severity_security": "安全",
"title": "应用更新"
"title": "应用更新",
"verifying": "正在验证…"
}
}
+247 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.69",
"version": "0.1.72",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.69",
"version": "0.1.72",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -47,6 +47,7 @@
},
"devDependencies": {
"@types/react": "~19.1.0",
"patch-package": "8.0.1",
"typescript": "~5.9.2",
"vitest": "^4.1.2"
}
@@ -3803,6 +3804,13 @@
"node": ">=10.0.0"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -6181,6 +6189,16 @@
"node": ">=8"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flow-enums-runtime": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
@@ -6226,6 +6244,21 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -7300,6 +7333,33 @@
"node": ">=6"
}
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -7312,6 +7372,29 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@@ -7324,6 +7407,16 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -8601,6 +8694,138 @@
"node": ">= 0.8"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/patch-package/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/patch-package/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/patch-package/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/patch-package/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/patch-package/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -10264,6 +10489,16 @@
"node": ">=14.0.0"
}
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -10392,6 +10627,16 @@
"node": ">=4"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.69",
"version": "0.1.72",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -8,6 +8,7 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"postinstall": "patch-package",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
@@ -56,6 +57,7 @@
},
"devDependencies": {
"@types/react": "~19.1.0",
"patch-package": "8.0.1",
"typescript": "~5.9.2",
"vitest": "^4.1.2"
},
@@ -0,0 +1,54 @@
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
index 1c12d1f..60f01e7 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidget.java
@@ -207,7 +207,7 @@ public class RNWidget {
intent.putExtra("clickActionData", Arguments.toBundle(clickableView.getClickActionData()));
PendingIntent pendingIntent = PendingIntent.getBroadcast(
appContext,
- (int) System.currentTimeMillis(),
+ RNWidgetUtil.nextClickRequestCode(),
intent,
PendingIntent.FLAG_CANCEL_CURRENT
| PendingIntent.FLAG_MUTABLE
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
index ed1af56..12c3630 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetCollectionService.java
@@ -162,7 +162,7 @@ class ListRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
intent.putExtra("clickActionData", clickableArea.getBundle("clickActionData"));
return PendingIntent.getBroadcast(
mContext,
- (int) System.currentTimeMillis(),
+ RNWidgetUtil.nextClickRequestCode(),
intent,
PendingIntent.FLAG_CANCEL_CURRENT
| PendingIntent.FLAG_MUTABLE
diff --git a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
index 2a34305..711503b 100644
--- a/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
+++ b/node_modules/react-native-android-widget/android/src/main/java/com/reactnativeandroidwidget/RNWidgetUtil.java
@@ -15,8 +15,23 @@ import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
public class RNWidgetUtil {
+ private static final AtomicInteger lastRequestCode = new AtomicInteger((int) System.currentTimeMillis() - 1);
+
+ /**
+ * A request code no other click PendingIntent of this app holds. Every click intent has the same
+ * action and component, so the request code alone tells them apart: a code shared by two buttons
+ * (two registrations within one millisecond with the time as the code) made the second one cancel
+ * the first, leaving a button that does nothing. Counting up from the time keeps the codes unique
+ * in this process and clear of the ones an earlier process left on the launcher.
+ */
+ public static int nextClickRequestCode() {
+ int now = (int) System.currentTimeMillis();
+ return lastRequestCode.updateAndGet(last -> now - last > 0 ? now : last + 1);
+ }
+
public static int getWidgetWidth(Context context, int widgetId) {
boolean isPortrait = context.getResources().getConfiguration().orientation == ORIENTATION_PORTRAIT;
if (isPortrait) {
+11 -9
View File
@@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document';
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted } from '../lib/trusted-senders';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
import { useHasContacts } from '../lib/capabilities';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
@@ -527,6 +528,8 @@ export default function EmailBodyView({
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const hasContacts = useHasContacts();
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
@@ -540,8 +543,8 @@ export default function EmailBodyView({
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark; the
@@ -561,7 +564,7 @@ export default function EmailBodyView({
const text = picked.text;
const trusted = isSenderContentTrusted(senderEmail, {
isLocallyTrusted: isSenderTrusted,
syncEnabled: trustedSendersAddressBook,
syncEnabled: syncTrustedSenders,
trustedBookEmails: trustedSenderEmails,
});
@@ -721,12 +724,11 @@ export default function EmailBodyView({
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect. The server-side "Trusted
// Senders" address book (synced across devices) is only written when
// the user opted into it - otherwise a book would silently appear in
// their contacts (matches the webmail).
// Keep the local allow-list for instant effect, and file the sender in
// the server-side "Trusted Senders" address book (synced across
// devices) unless the user turned that off.
addTrustedSender(senderEmail);
if (trustedSendersAddressBook) {
if (syncTrustedSenders) {
void addToTrustedSendersBook(senderEmail).catch(() => {});
}
setAllowOnce(true);
@@ -102,6 +102,9 @@ export function CalendarShareSheet({ calendar, onShare, onClose }: CalendarShare
);
const candidates = React.useMemo(() => {
// The sheet is mounted with the Calendar tab, before a session may exist
// (an offline cold start): nobody to list yet, and no own id to read.
if (principals.length === 0) return [];
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
+3
View File
@@ -98,6 +98,9 @@ export default function ShareSheet({ node, onClose, onChanged }: ShareSheetProps
);
const candidates = useMemo(() => {
// The sheet is mounted with the Files tab, before a session may exist
// (an offline cold start): nobody to list yet, and no own id to read.
if (principals.length === 0) return [];
const self = ownPrincipalId();
const q = search.trim().toLowerCase();
return principals
@@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { useLocaleStore } from '../../stores/locale-store';
interface TrustedRow {
@@ -49,9 +50,8 @@ export function ContentSendersSettings() {
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
// Sync stays off until the user turns it on below (null = never chosen):
// opening this pane must not change which senders the viewer trusts.
const syncEnabled = !!trustedSendersAddressBook && hasContacts;
// On unless the user turned it off below (null = never chosen).
const syncEnabled = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
useEffect(() => {
if (modalOpen && syncEnabled) void loadTrustedSendersBook();
@@ -179,7 +179,7 @@ export function ContentSendersSettings() {
description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')}
>
<ToggleSwitch
checked={!!trustedSendersAddressBook}
checked={syncEnabled}
onChange={(enabled) => {
updateSetting('trustedSendersAddressBook', enabled);
if (enabled) void loadTrustedSendersBook();
+9 -4
View File
@@ -32,6 +32,7 @@ function installButtonLabel(
): string {
if (!installing) return t('updates.install', 'Install');
if (progress?.phase === 'installing') return t('updates.installing', 'Installing…');
if (progress?.phase === 'verifying') return t('updates.verifying', 'Verifying…');
if (progress?.progress != null) {
return t('updates.downloading_pct', 'Downloading {pct}%', { pct: Math.round(progress.progress * 100) });
}
@@ -213,10 +214,12 @@ export function UpdatesSettings() {
style={[
styles.progressFill,
{
// The download is complete once it is verifying, so the
// bar stays full instead of restarting at 0.
width:
installProgress.phase === 'installing'
? '100%'
: `${Math.round((installProgress.progress ?? 0) * 100)}%`,
installProgress.phase === 'downloading'
? `${Math.round((installProgress.progress ?? 0) * 100)}%`
: '100%',
},
]}
/>
@@ -224,7 +227,9 @@ export function UpdatesSettings() {
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? t('updates.opening_installer', 'Opening installer…')
: formatProgressLabel(installProgress, t)}
: installProgress.phase === 'verifying'
? t('updates.verifying', 'Verifying…')
: formatProgressLabel(installProgress, t)}
</Text>
</View>
) : null}
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getContactPhotoForEmail, getContactPhotoIndex } from '../avatar-utils';
import { getAccountInitials, getContactPhotoForEmail, getContactPhotoIndex, getEmailInitials } from '../avatar-utils';
import type { ContactCard } from '../../api/types';
function card(id: string, emails: string[], photo?: string, mediaType?: string): ContactCard {
@@ -52,3 +52,25 @@ describe('contact photo avatars (#54)', () => {
expect(index.has('b@x.org')).toBe(false);
});
});
describe('initials, as the webmail Avatar works them out', () => {
it('skips punctuation and symbols in front of a name part', () => {
expect(getEmailInitials('"Sofia" Russo')).toBe('SR');
expect(getEmailInitials('(Dr.) Smith')).toBe('DS');
expect(getEmailInitials('Sofia - Russo')).toBe('SR');
expect(getEmailInitials('@team updates')).toBe('TU');
});
it('takes two letters of a single name and falls back to the address', () => {
expect(getEmailInitials('Madonna')).toBe('MA');
expect(getEmailInitials('', 'bob@example.org')).toBe('B');
expect(getEmailInitials('')).toBe('?');
expect(getEmailInitials('!!!')).toBe('!!');
});
it('works the same for account avatars, which the webmail draws with the same Avatar', () => {
expect(getAccountInitials('Ada Lovelace', 'ada@example.org')).toBe('AL');
expect(getAccountInitials('Ada', 'ada@example.org')).toBe('AD');
expect(getAccountInitials('', 'ada@example.org')).toBe('A');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'node:crypto';
const files = new Map<string, Uint8Array>();
const digest = vi.fn();
vi.mock('react-native', () => ({ Platform: { OS: 'android' } }));
vi.mock('expo-file-system', () => ({
File: class {
constructor(private uri: string) {}
async bytes() { return files.get(this.uri) ?? new Uint8Array(); }
},
Paths: { cache: 'file:///cache' },
}));
vi.mock('expo-file-system/legacy', () => ({}));
vi.mock('expo-intent-launcher', () => ({}));
vi.mock('expo-crypto', () => ({
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
digest: (...args: unknown[]) => digest(...args),
}));
import { hashFile } from '../install-update';
const sha256Hex = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex');
describe('hashFile', () => {
const apk = new TextEncoder().encode('not really an apk');
beforeEach(() => {
files.clear();
files.set('file:///cache/app.apk', apk);
digest.mockReset();
});
it('hashes the file natively in one call', async () => {
digest.mockImplementation(async (_alg: string, data: Uint8Array) => {
const out = createHash('sha256').update(data).digest();
return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength);
});
expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk));
expect(digest).toHaveBeenCalledTimes(1);
expect(digest).toHaveBeenCalledWith('SHA-256', apk);
});
it('falls back to the JS hasher when the native digest fails', async () => {
digest.mockRejectedValue(new Error('unavailable'));
expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk));
});
});
+17 -4
View File
@@ -1,11 +1,26 @@
import { describe, it, expect } from 'vitest';
import { isSenderContentTrusted } from '../trusted-senders';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../trusted-senders';
const local = (list: string[]) => (email: string) => list.includes(email.toLowerCase());
describe('isTrustedSendersSyncOn', () => {
it('is on unless the user opted out', () => {
expect(isTrustedSendersSyncOn(null, true)).toBe(true);
expect(isTrustedSendersSyncOn(undefined, true)).toBe(true);
expect(isTrustedSendersSyncOn(true, true)).toBe(true);
expect(isTrustedSendersSyncOn(false, true)).toBe(false);
});
it('is off on a server without contacts', () => {
for (const setting of [null, true, false]) {
expect(isTrustedSendersSyncOn(setting, false)).toBe(false);
}
});
});
describe('isSenderContentTrusted', () => {
it('trusts senders on the local allow-list whatever the sync setting', () => {
for (const syncEnabled of [null, false, true]) {
for (const syncEnabled of [false, true]) {
expect(isSenderContentTrusted('Alice@Example.com', {
isLocallyTrusted: local(['alice@example.com']),
syncEnabled,
@@ -18,8 +33,6 @@ describe('isSenderContentTrusted', () => {
const opts = { isLocallyTrusted: local([]), trustedBookEmails: ['bob@example.com'] };
expect(isSenderContentTrusted('Bob@Example.com', { ...opts, syncEnabled: true })).toBe(true);
expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: false })).toBe(false);
// Never chosen counts as off: the default must not widen trust.
expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: null })).toBe(false);
});
it('does not trust a sender just because some other list knows them', () => {
+17 -10
View File
@@ -28,16 +28,12 @@ export function generateAvatarColor(email: string): string {
return ACCOUNT_AVATAR_PALETTE[Math.abs(hash) % ACCOUNT_AVATAR_PALETTE.length];
}
/**
* Account avatars. The webmail draws them with the same `Avatar` as senders,
* so they get the same initials.
*/
export function getAccountInitials(name: string, email?: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return parts[0][0]?.toUpperCase() ?? '?';
}
if (email) return email[0]?.toUpperCase() ?? '?';
return '?';
return getEmailInitials(name, email);
}
// --- Email-row avatar -----------------------------------------------------
@@ -52,12 +48,23 @@ export function generateEmailAvatarColor(name: string, email?: string): string {
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Initials as the webmail's `Avatar` (components/ui/avatar.tsx) works them
* out: the first letters of the first and last name part, ignoring
* punctuation and symbols in front of a part (`"Sofia" Russo` is SR), two
* letters of a single part, else the first letter of the address.
*/
export function getEmailInitials(name: string, email?: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
const parts = name
.trim()
.split(/\s+/)
.map((p) => p.replace(/^[^\p{L}\p{N}]+/u, ''))
.filter((p) => p.length > 0);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
if (email) return email[0]?.toUpperCase() ?? '?';
+4 -8
View File
@@ -5,6 +5,7 @@ import type {
PartialDate,
Timestamp,
} from '../api/types';
import { getEmailInitials } from './avatar-utils';
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name) {
@@ -122,15 +123,10 @@ export function getCustomFullName(contact: ContactCard, orgName?: string): strin
return full;
}
/** The webmail's avatar initials for the contact's name, else its first address. */
export function getContactInitials(contact: ContactCard): string {
const name = getContactDisplayName(contact).trim();
if (!name) return '?';
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
const email = Object.values(contact.emails ?? {})[0]?.address;
return getEmailInitials(getContactDisplayName(contact).trim(), email);
}
// Some JMAP servers (notably Stalwart, see webmail #307) emit photo data URIs
+19 -32
View File
@@ -2,20 +2,16 @@ import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as FileSystemLegacy from 'expo-file-system/legacy';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Crypto from 'expo-crypto';
import type { ReleaseAsset } from '../api/updates';
import { Sha256 } from './sha256';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
// Bytes per file-read chunk while computing the post-download SHA-256. Larger
// chunks are faster but balloon the JS string buffer (base64 reads on this
// platform). 256 KiB keeps memory bounded while still amortising the bridge.
const HASH_CHUNK_BYTES = 256 * 1024;
export interface InstallProgress {
phase: 'downloading' | 'verifying' | 'installing';
/** 0-1 during download; undefined for installing phase */
/** 0-1 during download; undefined while verifying and installing */
progress?: number;
/** Bytes received during current download */
bytesWritten?: number;
@@ -31,34 +27,25 @@ export interface InstallParams {
expectedSha256?: string | null;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
function toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0')).join('');
}
async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise<string> {
const hasher = new Sha256();
let offset = 0;
while (offset < totalBytes || totalBytes === 0) {
const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES);
const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, {
encoding: FileSystemLegacy.EncodingType.Base64,
position: offset,
length,
});
if (!chunkB64) break;
const bytes = base64ToBytes(chunkB64);
if (bytes.length === 0) break;
/**
* SHA-256 of a downloaded file. The bytes are read and hashed natively:
* hashing base64 chunks in JS took longer than the download itself and
* looked like a second, slower download. The JS hasher stays as a fallback
* for a build without expo-crypto's digest.
*/
export async function hashFile(uri: string): Promise<string> {
const bytes = await new File(uri).bytes();
try {
return toHex(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, bytes));
} catch {
const hasher = new Sha256();
hasher.update(bytes);
offset += bytes.length;
if (totalBytes > 0) {
onProgress?.({ phase: 'verifying', progress: offset / totalBytes });
}
if (bytes.length < length) break;
return hasher.digestHex();
}
return hasher.digestHex();
}
export async function downloadAndInstallApk(
@@ -117,8 +104,8 @@ export async function downloadAndInstallApk(
// signed with a different key — but that fails late (after the user sees a
// confusing system dialog). Bailing here surfaces the problem cleanly.
if (expectedSha256) {
onProgress?.({ phase: 'verifying', progress: 0 });
const actual = await hashFile(result.uri, actualSize || asset.size, onProgress);
onProgress?.({ phase: 'verifying' });
const actual = await hashFile(result.uri);
if (actual.toLowerCase() !== expectedSha256.toLowerCase()) {
await tryDelete(result.uri);
throw new Error(
+4 -5
View File
@@ -1,8 +1,7 @@
// Minimal SHA-256 implementation. Used to verify APK update downloads against
// the checksum published in the GitHub release body — the JS runtime has no
// built-in `crypto.subtle.digest` and we don't currently depend on
// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per
// MB on a modern phone, which is fine for the rare update flow.
// Minimal SHA-256 implementation. The fallback for verifying APK update
// downloads when expo-crypto's native digest is unavailable (see
// `hashFile` in install-update.ts); far too slow to be the main path.
// Public-domain algorithm (FIPS 180-4).
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
+13 -3
View File
@@ -1,3 +1,13 @@
/**
* Whether "Sync trusted senders to address book" is in effect. Never chosen
* (null) counts as on, so trusted senders land in the dedicated "Trusted
* Senders" address book out of the box; only the user's opt-out (false), or
* a server without contacts, turns it off.
*/
export function isTrustedSendersSyncOn(setting: boolean | null | undefined, hasContacts: boolean): boolean {
return hasContacts && setting !== false;
}
/**
* Whether the viewer may load a sender's remote content (images, tracking
* pixels) without asking.
@@ -13,8 +23,8 @@ export function isSenderContentTrusted(
opts: {
/** The settings store's local allow-list check. */
isLocallyTrusted: (email: string) => boolean;
/** The "Sync trusted senders to address book" setting (null = never chosen, off). */
syncEnabled: boolean | null;
/** Whether address book sync is in effect (`isTrustedSendersSyncOn`). */
syncEnabled: boolean;
/** Lowercased addresses filed in the "Trusted Senders" book. */
trustedBookEmails: readonly string[];
},
@@ -22,5 +32,5 @@ export function isSenderContentTrusted(
const email = senderEmail?.trim();
if (!email) return false;
if (opts.isLocallyTrusted(email)) return true;
return !!opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
return opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
}
+15
View File
@@ -153,6 +153,21 @@ describe('handleDeepLink', () => {
expect(usePendingCalendarOpen.getState().consume()).toBeNull();
});
it('switches to the signed-in account a widget event link names first', async () => {
const navigation = nav();
const url = links.event({ serverId: 'j', accountId: 'usera@example.org@mail', jmapAccountId: 'd' });
const link = parseDeepLink(url);
expect(link).toEqual({ kind: 'calendar', eventId: 'j', jmapAccountId: 'd', accountId: 'usera@example.org@mail' });
const switchAccount = vi.fn(async () => true);
await handleDeepLink(link!, { navigation: navigation as never, resolveThreadId: async () => null, switchAccount });
expect(switchAccount).toHaveBeenCalledWith('usera@example.org@mail');
expect(usePendingCalendarOpen.getState().consume()).toMatchObject({ serverId: 'j', accountId: 'd' });
// An account that is no longer signed in opens nothing.
expect(await handleDeepLink(link!, {
navigation: navigation as never, resolveThreadId: async () => null, switchAccount: async () => false,
})).toBe(false);
});
it('parks the settings tab and opens the Settings tab', async () => {
const navigation = nav();
await handleDeepLink(
+10 -5
View File
@@ -36,8 +36,9 @@ export type DeepLink =
| { kind: 'scheduled' }
| { kind: 'search'; query: string }
// `jmapAccountId`: the JMAP account owning the event (`?account=`), for
// one on a calendar shared with the user.
| { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string }
// one on a calendar shared with the user; `accountId`: the signed-in
// account it belongs to (`?appAccount=`, from widgets).
| { kind: 'calendar'; eventId?: string; date?: string; jmapAccountId?: string; accountId?: string }
| { kind: 'contact'; contactId: string }
| { kind: 'contacts' }
| { kind: 'files' }
@@ -139,9 +140,13 @@ export function parseDeepLink(url: string): DeepLink | null {
case 'calendar': {
if (kind === 'event' && value) {
const jmapAccountId = search.get('account') ?? undefined;
return jmapAccountId
? { kind: 'calendar', eventId: decodeSegment(value), jmapAccountId }
: { kind: 'calendar', eventId: decodeSegment(value) };
const appAccountId = search.get('appAccount') ?? undefined;
return {
kind: 'calendar',
eventId: decodeSegment(value),
...(jmapAccountId ? { jmapAccountId } : {}),
...(appAccountId ? { accountId: appAccountId } : {}),
};
}
const date = [kind, value].find((s) => s && /^\d{4}-\d{2}-\d{2}$/.test(s));
return { kind: 'calendar', date };
+6 -6
View File
@@ -29,9 +29,12 @@ import RichTextEditor, {
} from '../components/RichTextEditor';
import { useEmailStore } from '../stores/email-store';
import { ownMailboxes } from '../lib/mailbox-tree';
import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useHasContacts } from '../lib/capabilities';
import { isTrustedSendersSyncOn } from '../lib/trusted-senders';
import { useAccountStore } from '../stores/account-store';
import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store';
@@ -218,11 +221,7 @@ function RecipientChip({
}
function initialsOf(name: string, email: string): string {
const source = name.trim() || email;
const parts = source.split(/[\s@._-]+/).filter(Boolean);
const first = parts[0]?.[0] ?? '?';
const second = parts.length > 1 ? parts[1][0] : '';
return `${first}${second}`.toUpperCase();
return getEmailInitials(name.trim(), email);
}
function SuggestionList({
@@ -480,6 +479,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const hasContacts = useHasContacts();
const updateSetting = useSettingsStore((s) => s.updateSetting);
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => quoteHeaderLabels(t), [t]);
@@ -2133,7 +2133,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const contacts = useContactsStore.getState();
for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) {
settings.addTrustedSender(r.email);
if (trustedSendersAddressBook) {
if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined);
}
}
+2 -4
View File
@@ -4,6 +4,7 @@ import { ClipboardPaste, QrCode, Mail, Plus, Server } from 'lucide-react-native'
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store';
import { getAccountInitials } from '../../lib/avatar-utils';
import OptionTile from './OptionTile';
import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
@@ -33,10 +34,7 @@ function hostOf(url: string): string {
}
function initialsOf(account: AccountEntry): string {
const source = account.displayName || account.email || account.username;
const parts = source.replace(/@.*$/, '').split(/[.\s_-]+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]);
return (letters.join('') || source[0] || '?').toUpperCase();
return getAccountInitials(account.displayName ?? '', account.email || account.username);
}
/**
+2 -4
View File
@@ -4,6 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { getAccountInitials } from '../../lib/avatar-utils';
// The white mark disappears on the light palette, so pick per theme.
const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png');
@@ -23,10 +24,7 @@ function hostOf(url: string | null): string {
}
function initialsOf(email: string): string {
const local = email.replace(/@.*$/, '');
const parts = local.split(/[.\s_-]+/).filter(Boolean);
const letters = parts.slice(0, 2).map((part) => part[0]);
return (letters.join('') || local[0] || '?').toUpperCase();
return getAccountInitials('', email);
}
/**
+2 -2
View File
@@ -127,8 +127,8 @@ interface PersistedSettings {
// Privacy & content
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
// null = not decided yet and treated as off; only the user's toggle in
// Content & Senders turns it on, false = user opted out.
// null = not decided yet and treated as on (isTrustedSendersSyncOn);
// false = the user turned it off in Content & Senders.
trustedSendersAddressBook: boolean | null;
senderFavicons: boolean;
hideInlineImageAttachments: boolean;
+181
View File
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { MailItem, WidgetSnapshot } from '../snapshot';
// A fake server and a fake refresh: the refresh reads the server when it
// starts and stores what it read when `release` lets it finish, which is how
// a slow refresh overlaps a second tap on the device.
const server = vi.hoisted(() => ({ inbox: [] as string[] }));
const calls = vi.hoisted(() => ({ moves: [] as Array<{ id: string; resolve: (ok: boolean) => void }> }));
vi.mock('../jmap', () => ({
openClient: vi.fn(async () => ({})),
markRead: vi.fn(async () => true),
rsvp: vi.fn(async () => true),
setTaskDone: vi.fn(async () => true),
moveTo: vi.fn((_client: unknown, id: string) => new Promise<boolean>((resolve) => {
calls.moves.push({
id,
resolve: (ok) => {
if (ok) server.inbox = server.inbox.filter((x) => x !== id);
resolve(ok);
},
});
})),
}));
vi.mock('../render', () => ({ redrawAll: vi.fn(async () => undefined) }));
vi.mock('../build', async () => {
const { storeRefresh } = await import('../state');
return {
singletonServes: () => false,
refreshSnapshot: vi.fn(async () => {
const startedAt = Date.now();
const read = [...server.inbox];
await new Promise((r) => setTimeout(r, 5));
await storeRefresh(snapshotOf(read), startedAt);
}),
};
});
function mail(id: string): MailItem {
return {
id, threadId: `t${id}`, accountId: 'acc', fromName: id, fromEmail: `${id}@x`, initials: 'X', color: '#123456',
subject: id, preview: '', receivedAt: 0, unread: true, starred: false, hasAttachment: false, threadSize: 1,
};
}
function snapshotOf(ids: string[]): WidgetSnapshot {
return {
version: 1, generatedAt: Date.now(), appDataAt: 0, signedIn: true, theme: 'light', locale: 'en', hour12: false,
weekStart: 1, accounts: [], activeAccountId: 'acc',
mail: {
folders: [{ role: 'inbox', name: 'Inbox', unread: ids.length, total: ids.length }],
inbox: ids.map(mail), unified: [], starred: [], starredCount: 0, drafts: [], draftCount: 0, scheduled: [],
pendingChanges: 0, favourites: [], recentSearches: [], tags: [], attachments: [],
},
calendar: { supported: true, events: [], invitations: [], birthdays: [] },
tasks: { supported: true, items: [] },
files: { supported: false, items: [] },
vacation: null,
quota: null,
};
}
const jmap = await import('../jmap');
const { handleWidgetAction } = await import('../actions');
const { currentView } = await import('../state');
const { saveSnapshot } = await import('../snapshot');
const shown = async () => (await currentView()).mail.inbox.map((m) => m.id);
const until = async (check: () => boolean) => {
for (let i = 0; i < 200 && !check(); i++) await new Promise((r) => setTimeout(r, 2));
expect(check()).toBe(true);
};
beforeEach(async () => {
await AsyncStorage.clear();
calls.moves = [];
server.inbox = ['m1', 'm2', 'm3'];
await saveSnapshot(snapshotOf(server.inbox));
});
describe('widget task ticks and RSVPs', () => {
it('go to the account the tapped drawing showed, not the one the widgets show now', async () => {
// The widgets moved on to acc2, which has a task with the same JMAP id.
const s = snapshotOf([]);
s.activeAccountId = 'acc2';
s.tasks.items = [{
id: 'e', serverId: 'e', accountId: 'acc2', title: 'Theirs', dueHasTime: false, done: false, calendarName: '', color: '#123456',
}];
await saveSnapshot(s);
vi.mocked(jmap.openClient).mockClear();
vi.mocked(jmap.setTaskDone).mockClear();
await handleWidgetAction('toggleTask', { id: 'e', done: true, serverId: 'e', accountId: 'acc', title: 'Mine' }, 1);
expect(jmap.openClient).toHaveBeenCalledWith('acc');
expect(jmap.setTaskDone).toHaveBeenCalledWith(expect.anything(), 'e', true, undefined);
});
it('mark a task done on both of two quick taps rather than undoing the first', async () => {
vi.mocked(jmap.setTaskDone).mockClear();
const tap = { id: 't', done: true, serverId: 't', accountId: 'acc', title: 'Task' };
await Promise.all([handleWidgetAction('toggleTask', tap, 1), handleWidgetAction('toggleTask', tap, 1)]);
expect(vi.mocked(jmap.setTaskDone).mock.calls.map((c) => c[2])).toEqual([true, true]);
});
it('send an RSVP with the ids the tap carries', async () => {
vi.mocked(jmap.rsvp).mockClear();
await handleWidgetAction('rsvp', {
id: 'h1', status: 'accepted', serverId: 'j', participantId: 'a', accountId: 'acc', jmapAccountId: 'd', title: 'Budget',
}, 1);
expect(jmap.rsvp).toHaveBeenCalledWith(expect.anything(), 'j', 'a', 'accepted', 'd');
});
});
describe('widget archive', () => {
it('keeps a message archived when a refresh read the server before its archive landed', async () => {
const first = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
const second = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 2);
expect(await shown()).toEqual(['m3']);
// The first archive lands; its refresh reads a server that still has m2.
calls.moves[0].resolve(true);
await first;
expect(server.inbox).toEqual(['m2', 'm3']);
expect(await shown()).toEqual(['m3']);
calls.moves[1].resolve(true);
await second;
expect(await shown()).toEqual(['m3']);
});
it('brings a message back when its archive did not reach the server', async () => {
const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 1);
expect(await shown()).toEqual(['m2', 'm3']);
calls.moves[0].resolve(false);
await tap;
expect(await shown()).toEqual(['m1', 'm2', 'm3']);
});
it('says so on the widget, with the tap to repeat, and shows the message again', async () => {
const tap = handleWidgetAction('archive', { id: 'm2', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 1);
calls.moves[0].resolve(false);
await tap;
const view = await currentView();
expect(view.notice).toMatchObject({ action: 'archive', label: 'm2', retry: { id: 'm2', accountId: 'acc' } });
expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' });
// Tapping the notice repeats the archive and clears it.
const retry = handleWidgetAction(view.notice!.action, view.notice!.retry, 7);
await until(() => calls.moves.length === 2);
expect((await currentView()).notice).toBeNull();
calls.moves[1].resolve(true);
await retry;
expect(await shown()).toEqual(['m1', 'm3']);
expect((await currentView()).notice).toBeNull();
});
it('does not lose either of two taps that land together', async () => {
const taps = [
handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7),
handleWidgetAction('trash', { id: 'm3', accountId: 'acc' }, 7),
];
await until(() => calls.moves.length === 2);
expect(await shown()).toEqual(['m2']);
for (const move of calls.moves) move.resolve(true);
await Promise.all(taps);
expect(await shown()).toEqual(['m2']);
});
it('moves the triage card on to the next unread message', async () => {
const tap = handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
await until(() => calls.moves.length === 1);
expect(JSON.parse((await AsyncStorage.getItem('widgets:local:7'))!)).toEqual({ triageId: 'm2' });
calls.moves[0].resolve(true);
await tap;
});
});
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { ContactCard } from '../../api/types';
import { avatarImageFor, imageType, resolveAvatarImages, toBase64 } from '../avatar-images';
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
const ICO = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10, 0, 0]);
const PHOTO = 'data:image/jpeg;base64,/9j/AAAA';
function card(email: string, uri: string): ContactCard {
return {
id: email,
emails: { e1: { address: email } },
media: { m1: { kind: 'photo', uri } },
} as unknown as ContactCard;
}
function answer(bytes: Uint8Array | null, ok = true) {
return {
ok,
arrayBuffer: async () => (bytes ?? new Uint8Array()).buffer,
};
}
const fetchMock = vi.fn();
beforeEach(async () => {
await AsyncStorage.clear();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('imageType', () => {
it('names the types BitmapFactory draws', () => {
expect(imageType(PNG)).toBe('image/png');
expect(imageType(ICO)).toBe('image/x-icon');
expect(imageType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]))).toBe('image/jpeg');
expect(imageType(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0, 0]))).toBe('image/gif');
expect(imageType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]))).toBe('image/webp');
});
it('refuses SVG, HTML and short answers', () => {
expect(imageType(new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>'))).toBeNull();
expect(imageType(new TextEncoder().encode('<!doctype html><html>'))).toBeNull();
expect(imageType(new Uint8Array([0x89, 0x50]))).toBeNull();
});
});
describe('toBase64', () => {
it('matches the standard encoding with padding', () => {
for (const text of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
expect(toBase64(new TextEncoder().encode(text))).toBe(Buffer.from(text).toString('base64'));
}
const all = new Uint8Array(256).map((_, i) => i);
expect(toBase64(all)).toBe(Buffer.from(all).toString('base64'));
});
});
describe('resolveAvatarImages', () => {
it('prefers a contact photo and does not fetch a logo for that sender', async () => {
const images = await resolveAvatarImages(['Ann@Example.com'], {
contacts: [card('ann@example.com', PHOTO)],
favicons: true,
});
expect(images.photos['ann@example.com']).toBe(PHOTO);
expect(fetchMock).not.toHaveBeenCalled();
expect(avatarImageFor(images, 'ann@example.com')).toEqual({ src: PHOTO, logo: false });
});
it('leaves out hosted contact photos (they need the account to load)', async () => {
fetchMock.mockResolvedValue(answer(null, false));
const images = await resolveAvatarImages(['ann@example.com'], {
contacts: [card('ann@example.com', 'https://mail.example.com/jmap/download/x')],
favicons: true,
});
expect(images.photos).toEqual({});
});
it('fetches a logo per business domain, not for personal providers', async () => {
fetchMock.mockResolvedValue(answer(PNG));
const images = await resolveAvatarImages(['news@shop.acme.io', 'bob@gmail.com', 'eve@acme.io'], {
contacts: [],
favicons: true,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('acme.io');
const logo = avatarImageFor(images, 'eve@acme.io');
expect(logo).toEqual({ src: `data:image/png;base64,${toBase64(PNG)}`, logo: true });
expect(avatarImageFor(images, 'bob@gmail.com')).toBeNull();
});
it('fetches no logos when sender favicons are off', async () => {
const images = await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: false });
expect(fetchMock).not.toHaveBeenCalled();
expect(images.favicons).toEqual({});
});
it('keeps logos that are not images or too big out', async () => {
fetchMock
.mockResolvedValueOnce(answer(new TextEncoder().encode('<!doctype html><html></html>')))
.mockResolvedValueOnce(answer(new Uint8Array(50 * 1024).fill(0).map((v, i) => (i < 8 ? PNG[i] : v))));
const images = await resolveAvatarImages(['a@one.dev', 'b@two.dev'], { contacts: [], favicons: true });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(images.favicons).toEqual({});
});
it('asks a domain again only after its cache time', async () => {
fetchMock.mockResolvedValue(answer(ICO));
const now = Date.UTC(2026, 9, 1);
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 6 * 86400000 });
expect(fetchMock).toHaveBeenCalledTimes(1);
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 8 * 86400000 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps the cached logo when offline', async () => {
const now = Date.UTC(2026, 9, 1);
fetchMock.mockResolvedValueOnce(answer(PNG));
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
fetchMock.mockRejectedValueOnce(new TypeError('Network request failed'));
const later = await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 30 * 86400000 });
expect(avatarImageFor(later, 'eve@acme.io')?.logo).toBe(true);
});
it('remembers a domain without a logo for a day', async () => {
const now = Date.UTC(2026, 9, 1);
fetchMock.mockResolvedValue(answer(null, false));
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now });
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 3600000 });
expect(fetchMock).toHaveBeenCalledTimes(1);
await resolveAvatarImages(['eve@acme.io'], { contacts: [], favicons: true, now: now + 2 * 86400000 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('avatarImageFor', () => {
it('has nothing without pictures or an address', () => {
expect(avatarImageFor(undefined, 'eve@acme.io')).toBeNull();
expect(avatarImageFor({ photos: {}, favicons: {} }, undefined)).toBeNull();
});
});
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { makeFmt } from '../format';
describe('count', () => {
it('keeps counts as they are below 10,000 and shortens bigger ones', () => {
const f = makeFmt('en', false);
expect(f.count(0)).toBe('0');
expect(f.count(9999)).toBe('9999');
expect(f.count(10007)).toBe('10K');
expect(f.count(12345)).toBe('12.3K');
expect(f.count(234567)).toBe('235K');
expect(f.count(2_500_000)).toBe('2.5M');
expect(makeFmt('de', false).count(12345)).toBe('12,3K');
});
});
+41
View File
@@ -41,8 +41,25 @@ vi.mock('react-native-android-widget', async () => {
if (typeof props.svg !== 'string') throw new Error('widgets draw inline SVG strings only');
return { ...convertCommonStyle(props.style ?? {}), ...convertClickAction(props), svgString: props.svg };
};
// The compiled ImageWidget requires react-native too; the widgets only draw data URIs.
function ImageWidget() {
return null;
}
ImageWidget.__name__ = 'ImageWidget';
ImageWidget.convertProps = (props: { style?: object; image: unknown; imageWidth: number; imageHeight: number; radius?: number }) => {
if (typeof props.image !== 'string' || !props.image.startsWith('data:image')) throw new Error('widgets draw data-URI images only');
return {
...convertCommonStyle(props.style ?? {}),
...convertClickAction(props),
imageWidth: props.imageWidth,
imageHeight: props.imageHeight,
image: { uri: props.image },
...(props.radius ? { radius: props.radius } : {}),
};
};
return {
...checked(await import('react-native-android-widget/lib/commonjs/widgets/FlexWidget' as string)),
...checked({ ImageWidget }),
...checked(await import('react-native-android-widget/lib/commonjs/widgets/ListWidget' as string)),
...checked(await import('react-native-android-widget/lib/commonjs/widgets/OverlapWidget' as string)),
...checked(await import('react-native-android-widget/lib/commonjs/widgets/TextWidget' as string)),
@@ -118,6 +135,30 @@ describe('widget layouts', () => {
}
});
it('builds every action widget with a failure notice at every size', () => {
const notices = [
['TriageWidget', 'archive'],
['TriageWidget', 'trash'],
['InvitationsWidget', 'rsvp'],
['TasksWidget', 'toggleTask'],
['TodayHubWidget', 'toggleTask'],
] as const;
for (const [name, action] of notices) {
const w = catalog.widgets.find((x) => x.name === name)!;
const s = { ...sample, notice: { action, label: 'A task with a rather long title', at: PREVIEW_NOW, retry: { id: 'x' } } };
for (const [cols, rows] of sizes(w)) build(name, s, cols, rows);
}
});
it('builds avatars with contact photos and sender logos', () => {
const png = 'data:image/png;base64,iVBORw0KGgo=';
const images = {
photos: Object.fromEntries(sample.mail.inbox.slice(0, 2).map((m) => [m.fromEmail.toLowerCase(), png])),
favicons: Object.fromEntries(sample.mail.inbox.map((m) => [m.fromEmail.split('@')[1], png])),
};
for (const w of catalog.widgets) build(w.name, { ...sample, images }, w.cols, w.rows);
});
it('builds the triage card on a message that is gone', () => {
build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' });
build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' });
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
applyPending,
confirmedBy,
DONE_TTL_MS,
expire,
IN_FLIGHT_TTL_MS,
NOTICE_TTL_MS,
type PendingOp,
} from '../pending';
import { PREVIEW_NOW, sampleSnapshot } from '../sample-snapshot';
const op = (change: PendingOp['change'], extra: Partial<PendingOp> = {}): PendingOp => ({
key: `${change.kind}:${change.id}`,
change,
at: 1000,
...extra,
});
describe('applyPending', () => {
it('takes an archived message out of every list and the inbox counts', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const target = s.mail.inbox.find((m) => m.unread)!;
const inbox = s.mail.folders.find((f) => f.role === 'inbox')!;
const shown = applyPending(s, [op({ kind: 'removeMail', id: target.id })]);
expect(shown.mail.inbox.some((m) => m.id === target.id)).toBe(false);
expect(shown.mail.unified.some((m) => m.id === target.id)).toBe(false);
const after = shown.mail.folders.find((f) => f.role === 'inbox')!;
expect([after.total, after.unread]).toEqual([inbox.total - 1, inbox.unread - 1]);
// The stored snapshot is left as the server described it.
expect(s.mail.inbox.some((m) => m.id === target.id)).toBe(true);
expect(s.mail.folders.find((f) => f.role === 'inbox')!.total).toBe(inbox.total);
});
it('marks a message read once', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const target = s.mail.inbox.find((m) => m.unread)!;
const before = s.mail.folders.find((f) => f.role === 'inbox')!.unread;
const twice = [op({ kind: 'markRead', id: target.id }), op({ kind: 'markRead', id: target.id }, { key: 'b' })];
const shown = applyPending(s, twice);
expect(shown.mail.inbox.find((m) => m.id === target.id)!.unread).toBe(false);
expect(shown.mail.folders.find((f) => f.role === 'inbox')!.unread).toBe(before - 1);
});
it('answers an invitation and ticks a task', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const inv = s.calendar.invitations[0];
const task = s.tasks.items.find((t) => !t.done)!;
const shown = applyPending(s, [
op({ kind: 'rsvp', id: inv.id, serverId: inv.serverId, status: 'accepted' }),
op({ kind: 'task', id: task.id, done: true }),
]);
expect(shown.calendar.invitations.some((i) => i.serverId === inv.serverId)).toBe(false);
expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(true);
expect(s.tasks.items.find((t) => t.id === task.id)!.done).toBe(false);
});
});
describe('applyPending across accounts', () => {
it('leaves an item of another account with the same id alone', () => {
const s = sampleSnapshot(PREVIEW_NOW);
const task = s.tasks.items.find((t) => !t.done)!;
const shown = applyPending(s, [op({ kind: 'task', id: task.id, done: true, accountId: 'not-this-one' })]);
expect(shown.tasks.items.find((t) => t.id === task.id)!.done).toBe(false);
const own = applyPending(s, [op({ kind: 'task', id: task.id, done: true, accountId: s.activeAccountId! })]);
expect(own.tasks.items.find((t) => t.id === task.id)!.done).toBe(true);
});
});
describe('confirmedBy', () => {
const mail = op({ kind: 'removeMail', id: 'm' }, { doneAt: 2000 });
const task = op({ kind: 'task', id: 't', done: true }, { doneAt: 2000 });
const inFlight = op({ kind: 'removeMail', id: 'n' });
it('keeps a change a refresh started before it reached the server', () => {
// The refresh read the server at 1500, before the archive landed at 2000:
// its data still has the message, so the op must keep hiding it.
expect(confirmedBy([mail, inFlight], 1500, true)).toEqual([mail, inFlight]);
});
it('drops a mail change once a later refresh has stored it', () => {
expect(confirmedBy([mail, inFlight], 2500, false)).toEqual([inFlight]);
});
it('keeps a task change until a refresh that loaded tasks', () => {
expect(confirmedBy([task], 2500, false)).toEqual([task]);
expect(confirmedBy([task], 2500, true)).toEqual([]);
});
});
describe('expire', () => {
it('drops stuck and stale ops and an old notice', () => {
const now = 10_000_000;
const stuck = op({ kind: 'removeMail', id: 'a' }, { at: now - IN_FLIGHT_TTL_MS - 1 });
const fresh = op({ kind: 'removeMail', id: 'b' }, { at: now - 1000 });
const stale = op({ kind: 'task', id: 'c', done: true }, { at: 0, doneAt: now - DONE_TTL_MS - 1 });
const recent = op({ kind: 'task', id: 'd', done: true }, { at: 0, doneAt: now - 1000 });
const notice = (at: number) => ({ action: 'archive' as const, label: 'x', at, retry: {} });
expect(expire({ ops: [stuck, fresh, stale, recent], notice: notice(now - NOTICE_TTL_MS - 1) }, now))
.toEqual({ ops: [fresh, recent], notice: null });
expect(expire({ ops: [], notice: notice(now - 1000) }, now).notice).toEqual(notice(now - 1000));
});
});
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const native = vi.hoisted(() => ({ BulwarkWidgets: undefined as undefined | { refreshInBackground: () => Promise<boolean> } }));
vi.mock('react-native', () => ({ Platform: { OS: 'android' }, AppState: {}, NativeModules: native }));
const refreshSnapshot = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock('../build', () => ({ refreshSnapshot, readRegistry: vi.fn() }));
vi.mock('../render', () => ({ hasPlacedWidgets: vi.fn(async () => true), redrawAll: vi.fn(async () => undefined) }));
vi.mock('../state', () => ({ replaceAll: vi.fn() }));
const { handleAppState } = await import('../sync');
afterEach(() => {
vi.useRealTimers();
refreshSnapshot.mockClear();
});
describe('widget sync', () => {
it('refreshes when the app goes to the background without waiting for a timer', async () => {
// React Native pauses JS timers while the activity is in the background,
// so not even a zero-delay timer may stand between leaving the app and
// the refresh. Fake timers that never advance stand in for that.
vi.useFakeTimers();
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
});
it('hands the refresh to a headless task where the app has one', async () => {
vi.useFakeTimers();
const refreshInBackground = vi.fn(async () => true);
native.BulwarkWidgets = { refreshInBackground };
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshInBackground).toHaveBeenCalledTimes(1);
expect(refreshSnapshot).not.toHaveBeenCalled();
// Android refused to start the service: refresh in place after all.
refreshInBackground.mockResolvedValueOnce(false);
handleAppState('background');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).toHaveBeenCalledTimes(1);
native.BulwarkWidgets = undefined;
});
it('leaves the app coming back to the scheduled refreshes', async () => {
vi.useFakeTimers();
handleAppState('active');
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(refreshSnapshot).not.toHaveBeenCalled();
});
});
+119 -108
View File
@@ -1,16 +1,21 @@
// Buttons that act without opening the app: archive / delete / next on the
// triage card, RSVP on the invitation card, ticking a task. Each one updates
// the stored snapshot first so every widget redraws at once, then makes the
// JMAP call, then refreshes from the server; a failed call is undone by that
// refresh.
// triage card, RSVP on the invitation card, ticking a task. Each one lays its
// change over the stored data at once (./state.ts) so every widget redraws
// without waiting, then makes the JMAP call. A change the server took stays
// laid over the data until a refresh has caught up with it; one that failed
// is dropped again, which shows the data as the server last described it,
// and the widget says so with a notice that repeats the tap.
import { jmapClient } from '../api/jmap-client';
import type { JMAPClient } from '../api/jmap-client';
import { noteLocalChange, refreshSnapshot, singletonServes } from './build';
import { refreshSnapshot, singletonServes } from './build';
import { markRead, moveTo, openClient, rsvp, setTaskDone } from './jmap';
import { loadLocal, saveLocal } from './local-state';
import { updateAllWidgets } from './render';
import { emptySnapshot, loadSnapshot, saveSnapshot, type MailItem, type WidgetSnapshot } from './snapshot';
import type { PendingChange } from './pending';
import type { ActionNotice } from './snapshot';
import { redrawAll } from './render';
import { serial } from './serial';
import { currentView, settle, track } from './state';
async function clientFor(registryAccountId: string | null | undefined): Promise<JMAPClient | null> {
if (!registryAccountId) return null;
@@ -18,52 +23,69 @@ async function clientFor(registryAccountId: string | null | undefined): Promise<
return openClient(registryAccountId);
}
async function applyLocally(mutate: (s: WidgetSnapshot) => void): Promise<WidgetSnapshot> {
noteLocalChange();
const s = (await loadSnapshot()) ?? emptySnapshot();
mutate(s);
await saveSnapshot(s);
await updateAllWidgets(s);
return s;
}
function withoutMessage(s: WidgetSnapshot, id: string): void {
const removed = s.mail.inbox.find((m) => m.id === id);
const drop = (list: MailItem[]) => list.filter((m) => m.id !== id);
s.mail.inbox = drop(s.mail.inbox);
s.mail.unified = drop(s.mail.unified);
s.mail.starred = drop(s.mail.starred);
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
if (inbox && removed) {
inbox.total = Math.max(0, inbox.total - 1);
if (removed.unread) inbox.unread = Math.max(0, inbox.unread - 1);
}
}
async function afterServerCall(ok: boolean): Promise<void> {
if (!ok) console.warn('[widgets] action was not applied on the server');
const fresh = await refreshSnapshot({ after: 'change' });
await updateAllWidgets(fresh);
}
type Data = Record<string, unknown>;
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
/**
* Lay `change` over the data, run `call`, and keep or drop the change by its
* answer. On failure the widget shows a notice about `label` that repeats
* the tap (`name` with `data`).
*/
async function apply(
name: ActionNotice['action'],
data: Data,
label: string,
change: PendingChange,
call: () => Promise<boolean>,
onFailed?: () => Promise<void>,
): Promise<boolean> {
const key = await track(change);
await redrawAll();
let ok = false;
try {
ok = await call();
} catch (err) {
console.warn(`[widgets] ${name} failed`, err);
}
if (!ok) console.warn(`[widgets] ${name} was not applied on the server`);
await settle(key, ok, { action: name, label, at: Date.now(), retry: data });
if (!ok) await onFailed?.();
await redrawAll();
// Catch up with the server: confirms a change that went through and picks
// up whatever else changed. Offline this fails quickly and keeps the data.
await refreshSnapshot({ after: 'change' });
await redrawAll();
return ok;
}
/** The triage card's message after `id`, among what the widget shows now. */
async function moveTriagePointer(widgetId: number, id: string): Promise<void> {
const view = await currentView();
const unread = view.mail.inbox.filter((m) => m.unread);
const index = unread.findIndex((m) => m.id === id);
const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined;
await pointTriage(widgetId, next?.id ?? null);
}
function pointTriage(widgetId: number, triageId: string | null): Promise<void> {
return serial(async () => saveLocal(widgetId, { ...(await loadLocal(widgetId)), triageId }));
}
export async function handleWidgetAction(name: string, data: Data, widgetId: number): Promise<void> {
switch (name) {
case 'refresh':
await afterServerCall(true);
await refreshSnapshot({ after: 'change' });
await redrawAll();
return;
case 'triageNext': {
const s = (await loadSnapshot()) ?? emptySnapshot();
const unread = s.mail.inbox.filter((m) => m.unread);
const view = await currentView();
const unread = view.mail.inbox.filter((m) => m.unread);
if (unread.length === 0) return;
const index = unread.findIndex((m) => m.id === data.id);
const next = unread[(index + 1) % unread.length];
const local = await loadLocal(widgetId);
await saveLocal(widgetId, { ...local, triageId: next.id });
await updateAllWidgets(s);
await pointTriage(widgetId, next.id);
await redrawAll();
return;
}
@@ -72,39 +94,23 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num
case 'markRead': {
const id = str(data.id);
if (!id) return;
if (name === 'markRead') {
await applyLocally((s) => {
const hit = s.mail.inbox.find((m) => m.id === id);
const inbox = s.mail.folders.find((f) => f.role === 'inbox');
if (hit?.unread && inbox) inbox.unread = Math.max(0, inbox.unread - 1);
for (const list of [s.mail.inbox, s.mail.unified, s.mail.starred]) {
for (const m of list) if (m.id === id) m.unread = false;
}
});
} else {
// The triage card moves on to the next unread message, not back to
// the first one.
const before = (await loadSnapshot()) ?? emptySnapshot();
const unread = before.mail.inbox.filter((m) => m.unread);
const index = unread.findIndex((m) => m.id === id);
const next = index >= 0 ? unread[index + 1] ?? unread[index - 1] : undefined;
if (typeof data.widgetId === 'number') {
await saveLocal(data.widgetId, { ...(await loadLocal(data.widgetId)), triageId: next?.id ?? null });
}
await applyLocally((s) => withoutMessage(s, id));
}
let ok = false;
try {
const client = await clientFor(str(data.accountId));
if (client) {
ok = name === 'markRead'
? await markRead(client, id, str(data.jmapAccountId))
: await moveTo(client, id, name, str(data.jmapAccountId));
}
} catch (err) {
console.warn(`[widgets] ${name} failed`, err);
}
await afterServerCall(ok);
const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? '';
// The triage card moves on to the next unread message, not back to
// the first one.
if (name !== 'markRead') await moveTriagePointer(widgetId, id);
const accountId = str(data.accountId);
const jmapAccountId = str(data.jmapAccountId);
const change: PendingChange = name === 'markRead' ? { kind: 'markRead', id } : { kind: 'removeMail', id };
await apply(name, data, subject, change, async () => {
const client = await clientFor(accountId);
if (!client) return false;
return name === 'markRead'
? markRead(client, id, jmapAccountId)
: moveTo(client, id, name, jmapAccountId);
}, async () => {
// The card goes back to the message, next to the notice about it.
if (name !== 'markRead') await pointTriage(widgetId, id);
});
return;
}
@@ -112,48 +118,53 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num
const id = str(data.id);
const status = data.status;
if (!id || (status !== 'accepted' && status !== 'tentative' && status !== 'declined')) return;
const before = (await loadSnapshot()) ?? emptySnapshot();
const invitation = before.calendar.invitations.find((i) => i.id === id);
if (!invitation) return;
await applyLocally((s) => {
s.calendar.invitations = s.calendar.invitations.filter((i) => i.id !== id);
for (const e of s.calendar.events) if (e.serverId === invitation.serverId) e.myStatus = status;
});
let ok = false;
try {
const client = await clientFor(before.activeAccountId);
if (client) ok = await rsvp(client, invitation.serverId, invitation.participantId, status, invitation.jmapAccountId);
} catch (err) {
console.warn('[widgets] rsvp failed', err);
// The tap carries the invitation (clicks.ts); a drawing made before it
// did is looked up in what the widgets show now.
const view = await currentView();
const shown = view.calendar.invitations.find((i) => i.id === id);
const accountId = str(data.accountId) ?? shown?.accountId ?? view.activeAccountId;
const serverId = str(data.serverId) ?? shown?.serverId;
const participantId = str(data.participantId) ?? shown?.participantId;
const jmapAccountId = str(data.jmapAccountId) ?? (str(data.serverId) ? undefined : shown?.jmapAccountId);
if (!serverId || !participantId) {
await redrawAll();
return;
}
await afterServerCall(ok);
const title = str(data.title) ?? shown?.title ?? '';
await apply('rsvp', data, title, { kind: 'rsvp', id, serverId, status, accountId: accountId ?? undefined }, async () => {
const client = await clientFor(accountId);
return !!client && rsvp(client, serverId, participantId, status, jmapAccountId);
});
return;
}
case 'toggleTask': {
const id = str(data.id);
if (!id) return;
const before = (await loadSnapshot()) ?? emptySnapshot();
const task = before.tasks.items.find((t) => t.id === id);
if (!task) return;
const done = !task.done;
await applyLocally((s) => {
for (const t of s.tasks.items) if (t.id === id) t.done = done;
});
let ok = false;
try {
const client = await clientFor(before.activeAccountId);
if (client) ok = await setTaskDone(client, task.serverId, done, task.jmapAccountId);
// In the live app the snapshot's tasks come from the calendar store,
// which did not see this change; reload it so the refresh keeps it.
if (ok && before.activeAccountId && singletonServes(before.activeAccountId)) {
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
await useCalendarStore.getState().fetchTasks();
}
} catch (err) {
console.warn('[widgets] task toggle failed', err);
const view = await currentView();
const shown = view.tasks.items.find((t) => t.id === id);
const accountId = str(data.accountId) ?? shown?.accountId ?? view.activeAccountId;
const serverId = str(data.serverId) ?? shown?.serverId;
const jmapAccountId = str(data.jmapAccountId) ?? (str(data.serverId) ? undefined : shown?.jmapAccountId);
// The state the user asked for; older drawings only named the task.
const done = typeof data.done === 'boolean' ? data.done : shown ? !shown.done : undefined;
if (!serverId || done === undefined) {
await redrawAll();
return;
}
await afterServerCall(ok);
const title = str(data.title) ?? shown?.title ?? '';
await apply('toggleTask', data, title, { kind: 'task', id, done, accountId: accountId ?? undefined }, async () => {
const client = await clientFor(accountId);
if (!client || !(await setTaskDone(client, serverId, done, jmapAccountId))) return false;
// In the live app the snapshot's tasks come from the calendar store,
// which did not see this change; reload it before the refresh that
// follows reads it, or that refresh would undo the tick.
if (client === jmapClient) {
const { useCalendarStore } = require('../stores/calendar-store') as typeof import('../stores/calendar-store');
await useCalendarStore.getState().fetchTasks().catch(() => undefined);
}
return true;
});
return;
}
+151
View File
@@ -0,0 +1,151 @@
// Pictures for the widgets' avatars, in the webmail Avatar's order: the
// sender's contact photo, then the logo of the sender's domain (when "sender
// favicons" is on and the domain is not a personal mail provider), else the
// initials. A widget draws from the stored snapshot and cannot fetch while it
// draws (offline it would come out blank), so the pictures are fetched when
// the snapshot is built and kept in it as small data URIs.
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { ContactCard } from '../api/types';
import { getContactPhotoForEmail, getFaviconDomain, getFaviconUrl } from '../lib/avatar-utils';
import type { AvatarImages } from './snapshot';
const CACHE_KEY = 'widgets:favicons:v1';
/** A logo is fetched again after a week, a domain without one after a day. */
const HIT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MISS_TTL_MS = 24 * 60 * 60 * 1000;
/** Pictures bigger than this stay out of the snapshot (it is read on every draw). */
const MAX_BYTES = 40 * 1024;
const MAX_DOMAINS = 24;
const FETCH_TIMEOUT_MS = 4000;
type FaviconCache = Record<string, { data: string | null; at: number }>;
async function loadCache(): Promise<FaviconCache> {
try {
const raw = await AsyncStorage.getItem(CACHE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? (parsed as FaviconCache) : {};
} catch {
return {};
}
}
/** The image type of `bytes` when BitmapFactory can draw it, from its first bytes. */
export function imageType(bytes: Uint8Array): string | null {
const b = (i: number) => bytes[i];
if (bytes.length < 8) return null;
if (b(0) === 0x89 && b(1) === 0x50 && b(2) === 0x4e && b(3) === 0x47) return 'image/png';
if (b(0) === 0x00 && b(1) === 0x00 && b(2) === 0x01 && b(3) === 0x00) return 'image/x-icon';
if (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff) return 'image/jpeg';
if (b(0) === 0x47 && b(1) === 0x49 && b(2) === 0x46) return 'image/gif';
if (b(0) === 0x52 && b(1) === 0x49 && b(2) === 0x46 && b(3) === 0x46 && b(8) === 0x57) return 'image/webp';
return null;
}
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function toBase64(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '=';
out += i + 2 < bytes.length ? B64[n & 63] : '=';
}
return out;
}
/** The domain's logo as a data URI; null when there is none; undefined when it could not be asked (offline). */
async function fetchFavicon(domain: string): Promise<string | null | undefined> {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = setTimeout(() => controller?.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(getFaviconUrl(domain), controller ? { signal: controller.signal } : undefined);
if (!res.ok) return null;
const bytes = new Uint8Array(await res.arrayBuffer());
const type = imageType(bytes);
if (!type || bytes.length > MAX_BYTES) return null;
return `data:${type};base64,${toBase64(bytes)}`;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
/** Contact photos small enough to carry, embedded ones only (a hosted photo needs the account's credentials). */
function contactPhoto(contacts: readonly ContactCard[], email: string): string | null {
const uri = getContactPhotoForEmail(contacts, email);
if (!uri || !uri.startsWith('data:image') || uri.length > (MAX_BYTES * 4) / 3 + 64) return null;
return uri;
}
/**
* Pictures for `emails`: contact photos by address, logos by domain. Logos
* come from a cache of their own, fetched for at most a few domains a run.
*/
export async function resolveAvatarImages(
emails: readonly string[],
opts: { contacts: readonly ContactCard[]; favicons: boolean; now?: number },
): Promise<AvatarImages> {
const now = opts.now ?? Date.now();
const photos: Record<string, string> = {};
const domains = new Set<string>();
for (const raw of emails) {
const email = raw.trim().toLowerCase();
if (!email || photos[email]) continue;
const photo = contactPhoto(opts.contacts, email);
if (photo) {
photos[email] = photo;
continue;
}
const domain = opts.favicons ? getFaviconDomain(email) : null;
if (domain) domains.add(domain);
}
const favicons: Record<string, string> = {};
if (domains.size > 0) {
const cache = await loadCache();
let changed = false;
const wanted = [...domains].slice(0, MAX_DOMAINS);
const stale = wanted.filter((d) => {
const hit = cache[d];
return !hit || now - hit.at > (hit.data ? HIT_TTL_MS : MISS_TTL_MS);
});
for (let i = 0; i < stale.length; i += 4) {
await Promise.all(stale.slice(i, i + 4).map(async (domain) => {
const data = await fetchFavicon(domain);
if (data === undefined) return; // offline: keep what we had
cache[domain] = { data, at: now };
changed = true;
}));
}
for (const domain of wanted) {
const data = cache[domain]?.data;
if (data) favicons[domain] = data;
}
if (changed) {
try {
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch {
// the next refresh asks again
}
}
}
return { photos, favicons };
}
/** The picture for `email` in `images`, and whether it is a logo (drawn on white). */
export function avatarImageFor(
images: AvatarImages | undefined,
email: string | undefined,
): { src: string; logo: boolean } | null {
if (!images || !email) return null;
const key = email.trim().toLowerCase();
const photo = images.photos[key];
if (photo) return { src: photo, logo: false };
const domain = getFaviconDomain(key);
const logo = domain ? images.favicons[domain] : undefined;
return logo ? { src: logo, logo: true } : null;
}

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