The agenda showed each day as a grey header bar above its events. It now
reads like a schedule: the weekday and date sit in a column on the left
of the day's first event (today in a filled circle), a heading marks
where each month starts and a quiet line names each week's range. An
empty today says "Nothing planned. Tap to create." and tapping it opens
a new event. In the drawer the view is now called Schedule.
Events that fill a whole day are listed as all day instead of
"00:00 – 00:00".
The month view used to be a small grid of dots with the selected day's
events listed underneath, so you only ever saw one day's events at a
time. It now fills the screen: each day is a cell with its events as
solid bars in the calendar colour and "+N" for the ones that don't fit.
Tapping a day opens it in the day view, and long-pressing still creates
an event there. Events that fill the whole day no longer show 00:00.
The dotted month stays for the month that drops down under the title.
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.
Uploads only reached internal testers. The public link's "external" group
still served 0.1.66, because nothing added later builds to it or
submitted them for Beta App Review.
A new `public-testflight` job runs after the upload on a Linux runner.
It waits for App Store Connect to process the build, adds it to the
external group, sets "What to Test" to the GitHub release and submits
it for review (`scripts/testflight-public.mjs`, safe to re-run). The
README links the TestFlight beta and the latest APK.
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.
Adds the phase 2 and 3 results to the verification checklist: the
corrections to phase 1 (two link failures were the test rig), every
action and link re-run on both servers, which commit fixes which cause,
the layout review and what is still open.
Widgets had 22dp corners, rounder than the launcher's own widgets and
the app's cards. They now use 16dp, the Pixel launcher's widget radius,
on the surface, its frame and the picker previews.
The widgets drew every sender as initials, while the app and the
webmail show the contact's photo or the sender's logo first. The
widget avatars now follow the same order: an embedded contact photo,
then the logo of a non-personal sender domain when sender favicons are
on, then the initials.
A widget draws from the stored snapshot and cannot load anything while
it draws, so the pictures are fetched when the snapshot is built and
kept in it as small data URIs. Logos have a cache of their own (a week
for a logo, a day for a domain without one) that survives being
offline, and pictures over 40 KB or in a format Android cannot decode
stay out.
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.
The picker previews still showed the layouts from before the fixes in
this series (Month's arrows, the Vacation switch, the old task rows and
triage header). Redrawn from the sample data at each widget's default
size, light theme, with the rounded corners and frame the launcher
draws.
The triage card kept its preview to two lines however tall it was
resized, leaving most of a large card empty, and Latest message showed
no preview at all. Both now use the room they have for the start of
the message.
Folder counts showed only its "Folders" heading when there was no
folder to list, which looks broken; it now says there are no folders to
show. A finished task past its due date still read "Overdue" on the
Tasks widget; it shows its date instead.
The Month widget drew back and forward arrows, and the Vacation widget
an on/off switch. A widget cannot page through months or switch the
vacation reply, so both only opened the app, which the rest of the
widget already does. The arrows are gone; the switch becomes a chevron,
and the Active/Inactive pill under the title still says which state
the reply is in.
Most widget buttons were 32dp tall (Archive, Delete, Reply and Next on
the triage card, Accept, Maybe and Decline, Send wishes, Mail my free
times), Up next's were 36dp and the round compose/new buttons in the
headers 28-32dp, all under the 48dp a finger needs, so taps landed
beside them and did nothing.
Button and Fab take a transparent tap area around what they draw
(`slop`), and the layouts give up the same amount of padding or gap
next to them, so the widgets look the same but the buttons take a 48dp
tap.
Checked every widget at its smallest size with long names, big counts
and German labels. What spilled over:
- the Inbox header at two cells: the count pushed the compose button
off the edge (search now goes at that width, the title truncates
first, and only the unread count stays);
- a long sender pushed the triage card's time and position out;
- German button labels were cut ("Archivie", "Lösc"); the triage
buttons now drop to icons, Archive and Delete first, when the labels
do not fit;
- five-digit counts overflowed Unread count, Tag and Mail and next, and
"/ 23000" wrapped a character per line; counts from 10,000 up are
shortened (10K, 12,3K) and the total goes where it does not fit;
- "ungelesen" was cut beside the number in Mail and next and moves
under it instead.
Every widget header now lets its title give way before the buttons on
its right.
The previous fix started the refresh as the app went to the background,
but it still stopped after its first request: fetch hands every response
over through a JS timer, and React Native pauses timers while the app's
activity is in the background, so the answer was only read once the app
came back. The widgets kept the previous account after a switch and
Home, and changes made in the app reached them late.
The refresh now runs as a headless task of its own,
BulwarkWidgetRefresh, started through BulwarkWidgetRefreshService. A
running headless task keeps the timers going, and the service keeps the
process from being frozen before the widgets are redrawn. Where Android
refuses to start the service the app refreshes in place as before. A
refresh started for a change also no longer joins one that began before
it.
The calendar, tasks, files and scheduled-send parts of the widgets only
refreshed while the app's UI was running, because they reuse the app's
stores, which are bound to its JMAP client. A widget event or push that
woke the app without its UI refreshed mail only, so invitations
answered elsewhere, tasks ticked on another device and new events
stayed as the app last left them. A tick or RSVP the server refused in
that state was also never undone, since nothing reloaded those parts.
When no UI has started in the runtime, nothing else uses that client,
so the widget refresh now signs it in to the active account and loads
calendar, tasks, files and scheduled sends the way the app does. Once
the UI starts it owns the client again.
Event links from the widgets named only the event (and the JMAP account
of a shared calendar), never the signed-in account it was shown for.
With another account active by then, the Calendar tab opened without
the event, or with an id both accounts use, on the other account's
event. The links now carry `appAccount`, and the app switches to that
account first, as it already did for message links.
An RSVP or task tick named only the item's id and looked it up in the
stored snapshot when the tap arrived, then used the snapshot's active
account. Between a refresh and the redraw that follows, a tap made on
the old drawing could reach a snapshot of another account, and JMAP ids
are short per-account sequences, so the lookup could match that
account's task or event with the same id. A tick also flipped whatever
the stored state was, so a quick second tap on a drawing that had not
caught up yet undid the first.
The tap now carries what the call needs: the item's account, its server
ids and, for a tick, the state the user asked for. The change laid over
the widget data only touches items of that account. Drawings made
before this keep working through the old lookup.
Leaving the app scheduled the widget refresh with a zero-delay timer.
React Native pauses JS timers while the activity is in the background
(timers in the bridgeless runtime have no immediate path for a zero
delay), so the refresh only ran once the app came back or a widget task
happened to start. Switching accounts and pressing Home left the
widgets on the previous account's mail. The refresh now starts directly
from the app state change.
A widget button whose JMAP call failed (offline, refused, the account's
credentials unavailable) only logged a console warning, so to the user
the button had simply done nothing, or had worked. The change is now
undone on the widget and a notice says what failed: "Couldn't archive.
Tap to try again." on the triage card, which goes back to the message,
and the same for RSVPs on the invitation card and task ticks on Tasks
and Today overview. Tapping the notice repeats the tap; any new tap or
ten minutes clear it.
The tick on the Tasks widget was a 22dp circle, and the rest of the row
did nothing: a tap on the title or just beside the circle was lost. It
now takes the row's full height and 50dp of its width, and the rest of
the row opens the calendar. Today overview's task rows grow from 32dp
to 48dp for the same reason; the widget fits fewer of them where space
is short.
Every widget tap runs as its own headless task in the same JS runtime,
and the tasks interleave at every await. Each one loaded the stored
snapshot, edited it and saved it back, then refreshed from the server.
That went wrong in three ways:
- two taps loaded the same snapshot and the second save dropped the
first change;
- the refresh of the first tap read the server before the second tap's
archive landed and was stored anyway, so the message came back (seen
on the emulator with a slow `Email/set`);
- a redraw of an older snapshot could land after a newer one, leaving
buttons that act on a message the widget no longer shows.
The snapshot now only holds what the server last said. A button's
change is kept as a pending op next to it and laid over it when a
widget draws; it stays until a refresh that started after the server
took it has been stored (calendar and task changes only count as caught
up by a refresh that loaded calendar and tasks). A call that fails
drops its op, which also undoes an archive made offline: before, the
failed refresh kept the edited snapshot and the message stayed hidden
although it was never archived. Reads, writes and redraws of the
widget data run one at a time, and every redraw draws the latest view.
react-native-android-widget registers every button's PendingIntent with
the current time in milliseconds as its request code and
FLAG_CANCEL_CURRENT. All buttons of a widget share the same action and
provider, so the request code is the only thing telling them apart: two
buttons registered within one millisecond got the same intent, and the
second registration cancelled the first. That button then did nothing
when tapped. The emulator takes 3-10 ms per button, a current phone well
under one, so which button died depended on timing.
The library is patched with patch-package (run on postinstall) to hand
out request codes that count up from the time, unique in the process
and clear of the codes an earlier process left on the launcher. The
patch keeps the library's CRLF line endings, hence `-text` for
`patches/*.patch`.
App store terms put limits on recipients, such as device caps and DRM,
that section 10 of the AGPL does not allow, so the app could not be
shipped through the Apple App Store or Google Play without breaking the
licence.
Add a "Bulwark Mobile" header and a section 7 additional permission to
LICENSE that allows app store distribution as long as the source of
every shipped version is public, free, linked from the listing or the
app, and not further restricted.
The permission is not final yet: not every earlier contributor has
answered the consent request. A reservation limits it to contributions
made on or after 30 September 2026 and to older parts whose authors
have agreed. The README licence line says the same.
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 fixes since the first device runs were checked on the emulators
too: moves that stay the same event, statuses, server deletions, items
moved away before a deletion, quiet runs, the permission flow,
uninstalling and reinstalling, and the settings' collection count. The
list of what only tests cover shrinks accordingly.
The device runs also showed that Stalwart stores a calendar's default
alerts on the series, so removing every reminder of an occurrence is
put back and reported for such a series as well, and that Google
Calendar's first edit of an occurrence brings its reminders back
without a report.
The planner tests that build meetings of 25 attendees on 52 overrides
take 0.3-1.8 s alone, but one of them passed the default 5 s limit in
a full run next to a release build. The two slowest tests of the file
already had 60 s; the other heavy ones get the same.
The account row in the sync settings counts the synced address books
or calendars from its own copy of the list, loaded when the row
appears. The chooser loads a fresh list when it opens, so a calendar
created meanwhile showed there but not in the count ("4 of 5 sync"
stayed after a sixth appeared, and turning it on or off changed
nothing) until the settings page was opened again.
Every fresh list now reaches every view of that account's collections,
so the count follows the chooser.
Brings the doc up to the code after the second round of fixes and the
code review: moves of meetings, deletions of items another client
moved away, big meetings written in several transactions, occurrence
colours and reminders, the stored status of runs that did nothing,
push types by server capability, reminders while the phone's calendar
can't sync, and sign-out waiting for a turn-on in progress.
The known limitations gain what the fixers and the device runs found
but left alone: pairs whose patch fails, a field the server changes
during a pending move, Google Calendar's copy-and-delete moves and its
copied reminders, group cards deleted after they moved away, and a
status line that shows item errors but not conflicts.
A contact or event deleted on the phone that another client meanwhile
filed in an address book or calendar the phone doesn't sync was
destroyed on the server by the phone's next sync, and the other
client's move with it. 0faee56 made the sync decide such a deletion on
the server's version, but the planners still destroyed it: the
contacts planner saw the card in no synced book and destroyed it, and
the calendar planner could not take the new version at all (it placed
the event before looking at the deletion), so the old one decided.
The deletion is now moot when the server's version holds none of the
collections the rows were written for and none the phone syncs: only
the rows go, nothing is uploaded, and the object stays where the other
client put it. A deletion still goes up when the book or calendar was
deselected on the phone before the upload, and when the object was
moved into another collection the phone syncs, since the phone would
otherwise miss an object it should show. The calendar planner now lets
a deleted row's shadow follow the server before it places the event.
A sync that had to start React Native left a listener on the React
host until a context came up. When the start failed (a debug build
without Metro) or never finished, every retried sync added another
one, each holding its run, account and extras, for the life of the
process, and they all fired at once when a context finally came up.
The wait now ends once, and removes its listener, for whatever
reason: a ready context, a start task that faulted, or 10 minutes
(no sync or push job waits longer; JobScheduler stops them). A
context that comes up later runs nothing of it. The adapter's own
check of its run in the callback still covers a run it gave up
sooner.
Turning device sync on retried with the app account's registry id as
the Android account name whenever adding the account failed. That
fallback is for one case only: the login name turned out to belong to
another app account's Android account, which the native module
reports with the `conflict` code. A passing AccountManager failure or
a permission error instead created a second account named after the
opaque id, which then showed in the Contacts and Calendar apps.
Only a `conflict` rejection takes the fallback now; any other failure
fails the turn-on as before.