The app was locked to portrait in app.config.js and the hand-kept
AndroidManifest.xml. Android 16 ignores that lock on screens 600 dp and
wider, so tablets and foldables rotated into layouts that were never
tested. This removes the lock in both places, keeps React state across
rotation and split-screen resizes (`smallestScreenSize` joins
`configChanges`), and fixes what broke in landscape:
- the thread pager sizes its pages from its own width and snaps back to
the open message after a rotation instead of showing half a neighbour
- compose and the thread view share one keyboard tracker; a rotation
with the keyboard open closes it, since Android sends no new keyboard
frame and the old portrait one blanked the whole landscape composer
- in a short window with the keyboard up the composer hides the format
bar and tightens its header so a few lines of text stay visible
- inputs with suggestions or context around them opt out of Android's
full-screen landscape keyboard
- month views fit whole rows to the real height, keep the month across a
remount, and the month dropdown re-measures and caps its height
- the week grid's header follows the remounted list after a rotation
- the event sheet's actions stay on screen in landscape
- every RN Modal allows all orientations, or iOS forces portrait
The calendar header was crowded: four icon toggles, a separate row of
arrows and a Today button, and a small create button squeezed in next
to them. Navigation now follows Google Calendar's layout while keeping
the app's own look:
- the header shows the month; tapping it drops down a month you can
swipe through and pick a day from
- a calendar icon with today's date jumps to today
- Schedule, Day, 3 days, Week and Month are listed at the top of the
drawer, styled like the mail drawer's folders, with Refresh below
- a round create button sits in the corner like the mail compose
button; with tasks on it offers Event or Task
- a new 3 days view shows three columns of the time grid
The arrows stay in the header when free scrolling is off, since the
views can't be swiped then.
With no Trash mailbox, or when Mailbox/get came back as an error, the
Delete action fell through to destroying the email. A shared account
without its own Trash lost mail permanently on one tap.
- only move to Trash; do nothing when there is no Trash or the lookup
failed
- check that Email/set actually updated the email
- show "(No Subject)" in the collapsed text instead of a blank line
- require unlock for Delete (Android 12+)
Since releases ship an `.apk.sha256` file, every in-app update checks
the downloaded APK against it. The check read the 40 MB file in base64
chunks and hashed it in JS, which took longer than the download, and it
reset the progress bar to 0 under the "Downloading" label, so the update
looked like it downloaded twice, the second time slowly.
The file is now read and hashed natively through expo-crypto (the JS
hasher remains as a fallback), and the bar stays full under a
"Verifying…" label while the check runs.
The "Sync with address book" toggle under Content & Senders was off until
the user turned it on, so senders trusted on the phone stayed in the
local allow-list and never reached the "Trusted Senders" address book the
other devices read. A setting the user never chose now counts as on;
only an explicit opt-out, or a server without contacts, turns it off.
The app had six ways to turn a name into avatar initials, and none of
them was the webmail's. The webmail's Avatar skips punctuation and
symbols in front of each name part ("Sofia" Russo is SR, not "S), takes
two letters of a single name and the first letter of the address
without one. Senders, accounts (sidebar and sign-in), contacts, compose
suggestions and the home-screen widgets all use that now.
Brings feat/device-sync into main: on Android, each account's address
books and calendars can sync both ways with the phone's Contacts and
Calendar apps (Settings -> Contacts / Calendar -> Sync to this
device), through a "Bulwark" Android account with a contacts and a
calendar sync adapter.
- native side: the account, both sync adapters, provider access scoped
to the account, push routing to the syncs, sync problem notices
- the sync engine in `src/device-sync`, run as a headless task
- the settings sections, sign-out and removal handling, translations
- design, limitations and verification in `docs/device-sync.md`
The native push router and the push task decided separately whether a
push may carry mail, and disagreed. For an account entry that is not
an object, or a map that names no type, the router started the mail
task as possible mail, but the task's guard read the same payload as
"no mail" and returned, so new mail it announced was never looked for.
A push without a kind was judged by its map in the router but taken
for mail by the task.
Both now apply the same rules: anything but a StateChange, a map that
is missing, unreadable or names no type, an unreadable account entry,
or a mail type means the mail task runs. Only a readable StateChange
naming contact, calendar or other non-mail types skips it.
Checked with one table of payloads run against the router's decision
and the task's guard alike.
On main every push ran the mail task, whose legacy path notifies the
newest unread inbox mail. Device sync narrowed that to pushes naming
EmailDelivery, so contact and calendar changes never notify. But a
subscription made by an older build listens to Email and Mailbox until
the app is opened again, and a server without EmailDelivery reports
new mail only as those changes. Their new mail no longer showed a
notification while the app was closed.
Email and Mailbox now count as possible mail again, in the native push
router and in the push task alike. A push that names only contact or
calendar types still never takes the mail path.
Checked with the push router and push task tests: an Email or Mailbox
change starts the task and the legacy path notifies the message,
next to the syncs a mixed map asks for.
The push subscription got the contact and calendar types whenever the
account had device sync on, without asking whether its server has
contacts or calendars. Sync can be on for such an account: the settings
list every signed-in account, and a server can drop a capability later.
A server that refuses unknown types then refused the update and the
replacement, and push setup failed: a new account got no mail push, an
existing one lost it when its subscription expired.
The types are now added only for a capability the account's session
advertises, and a write the server refuses while it carries device
sync's types is retried with EmailDelivery alone, so mail push never
depends on device sync.
Checked with the push setup tests against a fake server that refuses
unknown types: a new subscription is created mail-only, an existing
one is kept and refreshed, and another account's subscription is left
alone when its server lacks the capability.
On Android, Settings > Contacts and Settings > Calendar get a "Sync to
this device" section with a row per signed-in account. This makes device
sync reachable:
- The switch asks for the contacts or calendar permission after a short
explanation (denied: sync stays off and says why, with a link to the
app's system settings once Android stops asking), creates the
account's Bulwark account in Android and syncs right away. Turning it
off uploads what the device changed first and asks before anything
that could not go up is lost. The switch shows Android's own state, so
sync turned off in Android Settings shows as paused.
- Which address books or calendars sync: the personal account's are on
by default, shared ones and the Trusted Senders book off; read-only
ones are marked, and a note says when another signed-in account
already syncs the same shared one.
- The address book new contacts from other apps go to, who reminds of
synced events (the calendar app or Bulwark, asked when calendar sync
is turned on while Bulwark's reminders are on), and how often Android
syncs on its own (every 15 minutes to every 6 hours, or not at all).
- "Sync now", the last sync with its result, or the error with what to
do about it ("Sign in again", "Grant access", "Review deletions"), and
a link to the account in Android Settings.
- Accounts the app signed out while they synced are listed with "Sign
in again" and "Remove from this device".
The settings search finds both sections. iOS, and Android builds without
the native module, show nothing new.
Checked: the collection listing and status tests, the settings search
and i18n tests, `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit, and on the API 32 emulator with usera on the local
server: the section, turning contacts sync on (Android account created,
152 contacts on the device, one per card) and the status after a run.
Any web page (one tap) or installed app (no tap) could fire a
`bulwarkmail://pair` link at the app, which then silently added the
sender's mailbox and switched to it, so the user might send or save mail
into an attacker's account. A `connect` link likewise opened any
server's sign-in page.
- Links that arrive from outside the app now ask first, naming the
webmail's host and whether the link adds an account. Cancel discards
the link for good; a link delivered again while the question is open
is dropped. Codes scanned or pasted inside the app still go straight
through.
- With the account registry full, a link shows the limit instead of
opening "Add account".
- A pairing link for a plain-http webmail says the connection would be
unencrypted instead of "That code isn't a Bulwark sign-in code", or
nothing at all for a tapped link.
Signing in connects the shared client to the new account and stores its
credentials before the account registry takes it. With ten accounts the
registry refused, but nothing undid the connect: every request then went
out as the new account while the app still showed the old one. A
sign-in link made this easy to reach, since it opened "Add account"
without the limit check the menus have.
- A failed registration now restores the previous connection and drops
credentials the registry never took.
- Adding an account to a full registry is refused before a sign-in code
is spent or a browser sign-in starts, with "Remove an account in
Settings to add another one."
When an account's calendars sync to the device and the user chose the
device's calendar app to remind them, that app gets the events'
reminders. Bulwark's own reminder scheduler now skips the events of
those calendars, which would otherwise remind the user twice. Tasks
never sync and keep Bulwark's reminders, and a change of who reminds, or
of which calendars sync, reschedules right away.
Checked: the reminder filter and scheduler tests, `npm run typecheck`
and `npm test` on this commit.
With device sync on, Android should hear about a change as soon as the
app does, not only at the next periodic sync. This adds what tells it:
- The app's own contact and calendar edits, StateChanges on the live
stream for `AddressBook`/`ContactCard` and `Calendar`/`CalendarEvent`,
and the app coming back to the foreground request a sync of that
account and authority. Requests within 5 s fold into one, and a
StateChange the engine caused itself (its own upload echoing back) is
skipped.
- While the app is closed, the account's push subscription carries
those types too. The FCM and UnifiedPush services hand every push to
the native router from f743ab0, which requests the syncs, and start
the mail push task only when a push has `EmailDelivery`; the task also
returns early for such pushes, so a contact change can never show the
"newest unread mail" notification.
- At launch, on foreground and when Android's accounts change, the app
checks Android's accounts against its own: an account removed in
Android Settings shows sync off and is not recreated, and one whose app
account is gone stops syncing and posts "Sign in again", keeping its
data.
The functions that turn sync on and off (`src/device-sync/app/lifecycle.ts`)
come with this; the settings that call them land in a later commit, so
nothing here runs before an account has device sync on.
Checked: the new store, lifecycle, trigger and push tests, `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit, and the
debug build.
Every failed redeem read "That code has expired": a code already used,
a webmail without pairing (404), a rate limit, a server error and a
network failure all looked the same, and a non-JSON answer surfaced as a
raw parse error. The app now reads the webmail's `invalid_code`,
`expired_code` and `used_code` answers (webmail 1.11's catch-all 400
reads as "expired or already used"), gives up after 15 s, and names the
host involved; a failure after the code was redeemed says the phone was
signed in but could not reach the mail server.
- `loginViaPairing` accepts `flow: 'password'` bundles (an app password,
or the account password from servers without OAuth) in normal and
add-account mode, and never redeems the same code twice.
- A `pair` link must point at an https webmail (loopback http for
development); `pair/?` is accepted too.
- A token refresh that answers 200 with a non-JSON body keeps the
account instead of signing it out.
A sweep of src for what the earlier passes missed:
- The message export errors ("Sharing is not available on this
device", "Download failed: 404") were English; they come from the
catalog now.
- Event cards and details glued the participant count to a lowercased
"Participants"; they use the webmail's participant plural.
- Contact activity dates ignored the app language.
- Imported filter rules from an unknown tool showed the parser's
English "External" instead of the translated label.
- The contact field remove button and the vacation editor's
bold/italic/underline/list buttons get screen reader labels.
Reminder notifications are worded when they are scheduled, outside
React, and were always English ("Starts in 15 min", "(No title)",
"Task due"). They now come from the catalog through the locale store,
with ICU plurals for the minutes, hours and days. Each scheduled
reminder records its language, so switching languages rewords the
pending ones, and the Android channel ("Calendar reminders", shown in
the system settings) is named and renamed in the app language.
The reminder labels picked the English form whenever a catalog entry
was an ICU plural, which is all of them; they now pass the count to
t(), and "minutes after" is one plural message instead of a number
glued to a fragment. The import refusal error, the exported alarm
text and the "sharing is not available" error are translated too.
- The custom recurrence editor and its summary were English throughout
(labels, units, weekday and month names, "on the third Thursday");
they now use the webmail's recurrence fragments and the app's date
locale. The interval and occurrence counts are ICU plurals, also in
the event details, which filled "Every {count} days" with
`.replace`.
- The iCal subscription sheet is translated with the webmail's
subscription strings, and its "last updated" time is relative in the
app language. The feed errors it shows come from the catalog too.
- The calendar share sheet asked for `calendar.share.role_*` keys no
catalog has; it now uses the webmail's sharing presets and title.
- Close, remove, refresh, unsubscribe, delete and new-task buttons get
screen reader labels, the event editor's switches are named, color
swatches read out their color, and task checkboxes and calendar rows
report whether they are checked.
The calendar i18n test now covers the sheets.
The message details panel built its received line, attachment summary
and spam score by gluing strings together; each is now one ICU message
(the attachment count is a proper plural). The SPF/DKIM/DMARC results
use the webmail's translated result names, and an invitation's date is
formatted in the app language instead of always in English.
The quick reply box wrote its own "date, name <email>:" quote header;
it now uses the composer's translated "On {date}, {from} wrote:" line
so both kinds of reply look the same.
The back button, the subject and header star buttons, the sender
avatar and the More/Tag sheet close buttons get screen reader labels,
and the screens i18n test covers the viewer files.
The Notifications pane shows a push setup failure as "<phase>: <message>"
with a translated phase but an English message, and the open-link
confirmation and the new-mail notification title were English too.
They now go through the locale store's t() (translate() in the
background task), with the technical detail passed as an argument.
The sign-in screen showed the auth store's raw English error ("Session
expired", "Invalid username or password") as the notice title. It now
runs it through describeLoginError like every other sign-in failure,
which also learns to explain an expired session with the webmail's
login.session_expired copy. The back and close buttons of the sign-in
steps get screen reader labels, and the email placeholders use the
webmail's translated example address.
The calendar placed events by the calendar time-zone setting, but
"today", the now-line and the "Today" button still went by the device
clock. With the setting away from the device zone, the grid could
mark yesterday as today, draw the now-line hours off and jump to the
wrong day.
- the views, the agenda's "Today"/"Tomorrow" headers and today anchor,
the selected day, the scroll window's start and the "Today" button
use `displayNow`, `isDisplayToday`, `isDisplayTomorrow` and
`displayNowMinutes`
- a long press at an hour already hands the editor a calendar-zone
time, which it converts when it saves
- changing the setting re-buckets the days and reloads the loaded
range, since the server reads floating times and the range bounds
in that zone
With the calendar time-zone setting away from the device zone, the
event and task editors showed device-local times but saved them
labelled with the calendar zone, so opening an event and saving it
unchanged moved it by the difference between the two zones.
The calendar now works in display dates like the webmail
(`toDisplayDate` / `fromDisplayDate`): Dates whose local fields read
as the wall clock in the calendar zone. They are the identity while
the calendar follows the device.
- `getEventStartDate` / `getEventEndDate` return display dates, so the
views, the detail sheet and the editor show calendar-zone times
- the editors convert back through `eventTimeFieldsToSave` and
`buildTaskEditorChanges`: a start, end or due the user left alone is
saved exactly as stored, a changed one is converted into the zone the
event or task lives in (new, floating and formerly all-day ones take
the calendar zone), and durations count whole days as calendar days
in that zone
- `getTaskDueDate` stays the real instant for reminders; the tasks
sheet uses `getTaskDueDisplayDate`, `displayNow` and
`isDisplayToday`
- wall clocks are parsed from their digits, so one inside the device's
own DST gap still converts right
- duplicating an event keeps its wall clock in its own zone instead of
reading the device time
When the pages beside the open message were let go, each asked for its
conversation on its own, so every open sent two `Thread/get` requests
for its neighbours.
Conversations asked for in the same tick for the same account now go
out together: `getThreadsHeaders` takes several thread ids in one
back-referenced Thread/get → Email/get, and the cache batches its
callers into it. A conversation the server does not know, or a failed
request, still falls back to the copy held from before.
`getThreadEmails`, which downloaded every member's full body, had no
callers left; it is removed and its tests moved to `getFullEmails` and
`getThreadsHeaders`.
The invitation banner loaded the .ics whenever it was handed a new
message object, so marking an invitation read or starring it
downloaded and parsed the calendar part again (and fetched its raw
text when the method had to be read from it).
It now loads once per message, account and calendar part
(`calendarInvitationKey`), and reads the current message only when the
load runs.
The viewer's message cache, the rendered documents of their bodies and
their measured heights stayed in memory after signing out or removing
an account. Their keys name the server and login, so no other account
saw them, but a signed-out user's mail should not stay around.
Signing out (one account or all) and removing an account now clear all
three. A load still in flight when the cache is cleared no longer
stores what it brings back.
An "All folders" search or a tag view holds rows of the own and the
shared accounts, and Stalwart hands out ids per account, so an own and a
team message can share an id. The list still named its rows by bare id:
selecting one of such a pair selected both, a batch action hit both, and
a swipe or single-row action took whichever came first, in the other
account.
The list now names every row by `rowKeyOf` (its id, scoped by its
account in a list spanning accounts; the bare id in a folder, so nothing
changes there) end to end:
- FlatList keys, row presses, swipes, selection and select-all
- thread expansion of a selection (`expandThreadSelection`)
- every store action the list calls: read, unread, star, pin, the
per-account keyword batch, move, archive, delete and spam, and the
rows they drop or keep in view
- the viewer's pages, which now come only from the rows of the opened
message's account (`listRowsOfAccount`), and its mark-read, which
goes through the store when the list holds that account's row
A key whose row is no longer loaded is ignored rather than guessed onto
the open folder's account. The unified inbox already keys its rows by
account and needed no change.
`buildTimedFullDayWeekSegments` tested every event against every day it
was given. That was fine for the paged week, but the scrolling week and
day grids hand in months of days. It now tests each event only on the
days it spans, with the same `isTimedEventFullDayOnDate` check, so the
strip and the timed grid still agree on every day (DST days included).
The time grid's own copy of this is gone.
The week view showed one week and only the arrows moved it, and there
was no day view at all (a "day" default view opened the agenda). With
free scrolling on (the default) both are now one strip of day columns
that scrolls sideways, as in the webmail (15a5497a):
- the week view shows seven columns and snaps to single days; the new
day view shows one column and moves a day per swipe; the title
follows the first day in view and the arrows step from it
- reaching either end adds days and loads only their events; days added
at the start keep the columns on screen in place
- the day headers and the all-day strip follow the sideways scroll on
the native thread; all-day bars are cut per week (per day in the day
view) so each part carries its title
- columns are windowed and lay out their events once per change
The day view is offered as a default view again. With free scrolling
off, or in right-to-left layouts, the week view pages as before and the
day view shows a single day.