Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
|
||||
"severity_deprecated": "إلزامي",
|
||||
"severity_security": "أمني",
|
||||
"title": "تحديثات التطبيق"
|
||||
"title": "تحديثات التطبيق",
|
||||
"verifying": "جارٍ التحقق…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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í…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
|
||||
"severity_deprecated": "الزامی",
|
||||
"severity_security": "امنیتی",
|
||||
"title": "بهروزرسانیهای برنامه"
|
||||
"title": "بهروزرسانیهای برنامه",
|
||||
"verifying": "در حال بررسی…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
|
||||
"severity_deprecated": "חובה",
|
||||
"severity_security": "אבטחה",
|
||||
"title": "עדכוני אפליקציה"
|
||||
"title": "עדכוני אפליקציה",
|
||||
"verifying": "מאמת…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
|
||||
"severity_deprecated": "必須",
|
||||
"severity_security": "セキュリティ",
|
||||
"title": "アプリのアップデート"
|
||||
"title": "アプリのアップデート",
|
||||
"verifying": "検証中…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
|
||||
"severity_deprecated": "필수",
|
||||
"severity_security": "보안",
|
||||
"title": "앱 업데이트"
|
||||
"title": "앱 업데이트",
|
||||
"verifying": "확인 중…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
|
||||
"severity_deprecated": "Заавал",
|
||||
"severity_security": "Аюулгүй байдал",
|
||||
"title": "Аппын шинэчлэл"
|
||||
"title": "Аппын шинэчлэл",
|
||||
"verifying": "Шалгаж байна…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ă…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
|
||||
"severity_deprecated": "Обязательное",
|
||||
"severity_security": "Безопасность",
|
||||
"title": "Обновления приложения"
|
||||
"title": "Обновления приложения",
|
||||
"verifying": "Проверка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
|
||||
"severity_deprecated": "Обов'язкове",
|
||||
"severity_security": "Безпека",
|
||||
"title": "Оновлення застосунку"
|
||||
"title": "Оновлення застосунку",
|
||||
"verifying": "Перевірка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
|
||||
"severity_deprecated": "必要",
|
||||
"severity_security": "安全性",
|
||||
"title": "應用程式更新"
|
||||
"title": "應用程式更新",
|
||||
"verifying": "正在驗證…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
|
||||
"severity_deprecated": "必需",
|
||||
"severity_security": "安全",
|
||||
"title": "应用更新"
|
||||
"title": "应用更新",
|
||||
"verifying": "正在验证…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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() ?? '?';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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' });
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||