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 | ||
|
|
dfaf1f3e5e | ||
|
|
97c4f58379 | ||
|
|
5dfe869acb |
@@ -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
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
# Fail in seconds instead of after a 20-minute build. There is no
|
||||
# unsigned or debug-signed fallback: anything signed with the debug key
|
||||
@@ -249,6 +251,7 @@ jobs:
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
sha256sum "$DEST" > "$DEST.sha256"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING=""
|
||||
@@ -266,10 +269,18 @@ jobs:
|
||||
{
|
||||
echo "files<<FILES_EOF"
|
||||
echo "$DEST"
|
||||
echo "$DEST.sha256"
|
||||
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
||||
echo "FILES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ties the APK to this commit and workflow run, which the signing key
|
||||
# alone does not: `gh attestation verify <apk> --repo bulwarkmail/native`.
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -1,7 +1,7 @@
|
||||
<resources>
|
||||
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#09090b</color>
|
||||
<color name="iconBackground">#db2d54</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#09090b</color>
|
||||
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,7 +58,7 @@ module.exports = {
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
backgroundColor: '#09090b',
|
||||
backgroundColor: '#db2d54',
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
predictiveBackGestureEnabled: false,
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 32 KiB |
@@ -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.68",
|
||||
"version": "0.1.72",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.68",
|
||||
"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.68",
|
||||
"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');
|
||||
});
|
||||
});
|
||||
|
||||