Compare commits

...

249 Commits

Author SHA1 Message Date
Maurice e525e0b838 feat(flight-live): route mini map and a calmer live card; fix the CI gates
The live flight card shows a small real map behind the pass: the great
circle between the airports, solid where flown and dotted ahead, the
aircraft on the line turned along the route. The route is framed in the
free middle, the map fades out under the airports and times laid over it,
and its place names are hidden. Data and map credits sit on the map as its
hover text instead of a footer line.

The card head now follows the booking dialog: a tinted band with pills for
the state, what comes next and the delay; the numbers of the moment use the
dialog's fields on the desktop and the sheet's stat boxes on the phone; both
times sit on equal tiles in one line. Without live data the card, the strips
and the plan line show nothing, and the pulsing dots are gone.

Also fixes the failed dev run: unit symbols and the leg position are listed
as NOT_PLURAL, the aircraft label uses logical sides, the flight store is in
the test reset registry, the sync loop stops once no flight lies ahead (no
endless timers under fake timers), and timeCell and widgetFlight drop below
the max-params and complexity limits. Guards the GL bridge's resize frame
against a layer removed before it runs, and keeps the booking code pill as
tall as its neighbours.
2026-11-13 16:39:46 +01:00
Maurice 2d95bf6744 docs(wiki): live flight status and FLIGHT_LIVE_ENABLED
The transport page explains what TREK follows, where it shows and what open
data cannot give; the environment page documents the switch and what leaves
the server.
2026-11-13 15:36:43 +01:00
Maurice 41134cc455 fix(native): read the flight widget's direction from the locale
Android lint refuses a composition reading the Configuration through
LocalContext. The arc's mirroring now follows the locale's layout direction,
which is what the widget's language decides anyway.
2026-11-13 15:36:43 +01:00
Maurice 27f12c2245 feat(native): a flight widget and a boarding-pass Live Activity
A new Flight widget on iOS (small to large and the lock screen) and Android
shows the trip's next flight or the one in the air: both airports, the arc
with the aircraft, a countdown the system keeps running, and the delay. It
follows the feed's phase, so a late departure is not flown along the arc.
Around a flight the travel day's Live Activity becomes a boarding pass with
a running bar and countdown, in the Dynamic Island too.
2026-11-13 15:29:21 +01:00
Maurice 4796615439 feat(flight-live): a live boarding pass on the web, desktop and phone
The booking dialog and the phone's transport sheet show a flight's moment,
both airports with planned and expected times, the route as an arc with the
aircraft on it and the numbers of the flight; booking cards and day plan
rows get a one-line state with a progress bar. The trip map draws the
aircraft in the air on Leaflet and GL, and the admin settings carry the
switch. Translated into every language.
2026-11-13 15:29:10 +01:00
Maurice 154563f34d feat(flight-live): follow booked flights live from keyless ADS-B feeds
A new flight-live domain polls the open feeds adsb.lol and adsb.fi (no key,
no cost; adsb.fi only on self-hosted installs, its terms are non-commercial)
for every booked flight leg in its watch window, derives phase, take-off,
landing, delay and an arrival estimate, and pushes changes as flight:live.
GET /api/trips/:tripId/flights/live, the MCP tool get_trip_flight_status,
notifications for take-off, landing and delays, and the admin switch
flight_live_enabled (FLIGHT_LIVE_ENABLED wins) come with it.
2026-11-13 15:29:03 +01:00
Maurice 3365511b10 fix(bookings): the file buttons of a booking never wrap their label
"Vorhandene verknüpfen" broke onto two lines in the narrow half of the
booking dialog. The labels now truncate instead of wrapping, and the German
label is the shorter "Datei verknüpfen", matching "Datei anhängen".
2026-11-13 15:28:55 +01:00
Maurice c2c0535d52 fix(ci): list the packing share pair, and read a stored costs mode through its enum
The whole-list sharing runs on usePackingCategorySharing in the packing panel
and the phone tab, its sheet is markup fed by the tab. The trips service reads
a stored costs_mode as one of TripsCostsMode, which the export check wants used.
2026-11-13 13:30:37 +01:00
Maurice d6799d1205 fix(ci): the calendar query DTO as the contract it publishes, and two typed tests
feeds and trips import the calendar query DTO, so it lives in
calendar.contract.ts rather than as calendar's internal dto file. The places
controller tests pass the KML service in every call, and the revisit pill
test builds a whole list.
2026-11-13 13:13:13 +01:00
Maurice 9cbf31da27 fix(client): the trip form's review and the slow-open splash, and the tests that followed
Inside the trip settings, Escape and the backdrop no longer close the dialog
while the date question is up, and the embedded form does not load the
members it does not show. Every trip open is timed afresh for the splash.
The tests follow the cover image card, the danger button, the delayed splash
and the open and download mocks.
2026-11-13 12:57:57 +01:00
Maurice 71cc97822a fix(ci): the second round of the quick-win checks on the server
oidc_auto_redirect is managed-locked in the registry too, costs_mode is read
as the mode it is, the days of a dated trip are read through the factories,
and the dashboard's country order moves out of the atlas service into a
module of its own, so the service keeps its size.
2026-11-13 12:57:56 +01:00
Maurice c2ba78253d fix(client): lint, theme and size ratchets of the quick-win round, and the tests that followed the features
New warnings fixed at their source (refs written in effects, typed tile
options, no stray imports, smaller functions and components), the payer and
split heads hold their switch instead of letting it overhang, the baselines
lowered where the round removed warnings, and the tests updated to the
solo trip, the shares view and the revisit status.
2026-11-13 12:40:17 +01:00
Maurice 75aeba29ed feat(mobile): calmer trip settings, list editing, selecting places and the journey map switch
The trip settings sheet opens on a list of its areas, each a page of its own
with a way back, and the journal there is drawn in the phone's own cards. A
new packing list is typed into a card with its add and cancel, selecting
places keeps its way out in view with the actions as icons below, and the
journey's satellite switch sits in its bar beside the menu.
2026-11-13 12:40:16 +01:00
Maurice bd58f17ec0 feat(trip-settings): edit the trip inside its settings, in one look across the areas and the trip form
Edit trip is an area of the settings dialog, beside People, Sharing and
Journal, instead of a second dialog. The side column names the areas only, the
dialog keeps one height, and every area uses the same cards, fields and
buttons; the trip form on its own takes the same head, cards and buttons. A
trip that loads quickly opens without the splash on the desktop too.
2026-11-13 12:40:16 +01:00
Maurice 721a610a70 fix(ci): green the quick-win round on the server, the shared contracts and the plugin facts
The trips entity takes costs_mode as the generator writes it, the plugin
output contract publishes it, and the hand-built test schemas carry it. The
new MCP fields come from @trek/shared, the link tool tests join the collab
file that already mocks the database, oidc_auto_redirect is the operator's
on a managed install, and the tests follow the KML service, the dated trip
days and the zod pipe.
2026-11-13 12:40:15 +01:00
Maurice eda27f1e59 fix(costs): the receipt link is an icon, so the receipts title keeps one line (#2839) 2026-11-13 11:06:48 +01:00
Maurice a08d4b944e feat(mobile): icon-only action rows in the transport and booking sheets (#2585)
The transport sheet's row wrapped once a ticket and the wallet were there.
Its actions are icons now, named by their labels, and the attachments sit in
their own rows with a download, like the transports list. The wallet is a
black circle, beside the close button in the booking editor. A failed
download on the phone now says so.
2026-11-13 10:49:30 +01:00
Maurice 5137bcb86a docs(wiki): no dashes in the collab sub-feature sentence (#3076) 2026-11-13 10:49:30 +01:00
Maurice 7861006bf5 fix(journey): theme tokens for the album date range (#2859) 2026-11-13 10:49:29 +01:00
Maurice 5f6594f697 fix(tours): Google Maps follows a tour on foot or by bike, CoMaps only gets a route it can follow (#2812)
The Google link carried no mode, so a hike opened as a drive, and a stop on
the equator or the prime meridian was dropped. CoMaps routes a start and an
end only; a tour with stops between would reach it as unrouted pins.
2026-11-13 10:49:28 +01:00
Maurice fcbb538c24 fix(costs): the count beside the total adds up what it does, and its left-out chip counts all again (#2819, #2839)
With a category left out the total card still counted every expense, and in
the table view the only way back was to switch views; the chip now brings
everything back. The phone says what is left out once, under the total.
Linking a receipt offers pictures and PDFs only, like the upload.
2026-11-13 10:49:28 +01:00
Maurice 43a8984e4c feat(planner): a calmer place inspector footer, and a folded place opens in full again
The footer wrapped once a place had a day, a website and a collection. Now
only adding to the day, navigation and edit carry words, website and
collection are icons, and removing from the day and delete sit behind one
"…". The words drop out when the panel is narrow (a tablet) instead of the
row wrapping. Folding is kept for the place on show only (#3039).
2026-11-13 10:49:17 +01:00
Maurice e958a9a2bb feat(ui): icon footer button, and a button menu at the foot opens above its button
DialogIconButton is the square footer button DeleteButton already was, for
any action named by its tooltip. A "…" menu near the bottom of the window
used to be pushed up over its own button; it now opens above it.
2026-11-13 10:49:17 +01:00
Maurice 986dabf11f fix(categories): the new bus, station and airport icons count as transport (#2706) 2026-11-13 10:48:58 +01:00
Maurice 318e71aa0c fix(pdf): shrink place photos without AbortSignal.timeout and decode them near thumbnail size (#1501)
Safari before 16 has no AbortSignal.timeout, which cost the whole export; a
controller and a timer replace it. Photos decode at twice the thumbnail
height and three at a time, so a long trip no longer ends the web view.
2026-11-13 10:48:58 +01:00
Maurice 308daec14e feat(packing): change the sharing of a whole list at once (#2961)
The list menu on desktop and phone gets "Sharing for Whole List": pick
Shared, Personal or Shared with people once and every item of yours in
that list moves there through the existing per-item sharing write. Items
other people added stay as they are, and the dialog says so up front.
2026-11-13 10:17:35 +01:00
Maurice 3f68c8a50e feat(bookings): download button on booking and transport attachments (#2585)
Each attached file row now has a download icon next to its name, on the
desktop booking cards and detail dialog and on the phone's bookings and
transports cards. On the phone the file rows move out of the card body
button so open and download are two real sibling buttons.
2026-11-13 10:17:35 +01:00
Maurice 74af74fb5e feat(atlas): show the most recently visited countries first on the dashboard (#2648)
The place and endpoint reads behind the passport tile now carry the last day
of each started trip, and getTravelStats orders the countries by their latest
visit, with countries only marked by hand after them. The country set is
unchanged; repository, parity and service tests pin the order.
2026-11-13 10:17:26 +01:00
Maurice 924b4e0787 feat(auth): let admins keep the login page on OIDC-only instances (#2925)
A new app setting oidc_auto_redirect (default on) is part of the admin form
and the public app config. Off, an instance without password login no longer
sends visitors straight to the IdP: the login page shows its SSO button,
also while the cached config is read. Admin toggle on desktop and phone.
2026-11-13 10:17:26 +01:00
Maurice d5d9428cf2 feat(calendar): let a feed leave out the per-day summary events (#2602)
Both feed routes and the one-off export.ics take ?days=0, validated by a
shared query contract, and then skip the all-day "Day N" event per day; the
default is unchanged. The subscribe dialog gets a Day overview events switch
that adds the parameter to its links, export_trip_ics takes dayEvents and the
feed tools explain the parameter.
2026-11-13 10:17:25 +01:00
Maurice b9231ffb53 feat(mcp): manage the trip's shared links over MCP (#3076)
list_collab_links, create_collab_link, update_collab_link and
delete_collab_link run the Links panel's REST routes as tools: the shared
body contracts, trip access, collab_edit on writes and the collab🔗*
broadcasts, behind the links sub-feature flag. Unit, e2e and broadcast
parity cases cover them; the wiki lists the tools.
2026-11-13 10:17:15 +01:00
Maurice 489ba93c0c feat(mcp): let update_display_settings set the dashboard world clocks (#3044)
dashboard_timezones joins the display-preference allow-list as a list of
IANA zone ids, each checked with Intl like the widget does, without repeats
and capped at 24. The write goes through saveMany, so open tabs refresh, and
both tool descriptions name the widget's fallback zones.
2026-11-13 10:17:15 +01:00
Maurice 2a448722c6 feat(helm): make the PVC access modes configurable (#3061)
persistence.data.accessModes and persistence.uploads.accessModes default to
ReadWriteOnce, so existing installs render the same claims. ReadWriteMany lets a
backup pod mount the volumes; values.yaml and the docs warn that SQLite allows
only one writer of travel.db. CI renders the override too.
2026-11-13 10:17:14 +01:00
Maurice cd53d0a895 feat(tours): open a tour's route in Google Maps, Apple Maps or CoMaps (#2812)
The tour detail gets a Maps button beside Navigation that hands the whole route
to the apps that follow one, in the tour's own mode (walking or cycling). The
stops are spread evenly along the line and capped at ten, the most a Google
Maps directions link opens. Ski, paddle and horse tours are not offered, since
a map app would route them by road; the GPX export stays the way for those.
2026-11-13 10:17:14 +01:00
Maurice db6bb911c2 feat(roadtrip): show the place photo on the stop sheet and the stop dialog (#2629)
On the road a stop's picture is often what decides whether it is worth the
detour. The phone's stop sheet shows the place's picture full width under its
name and the desktop stop dialog shows it in place of the kind tile; both open
the place's photos on tap. The picture is the place's own image, else the
provider photo for its Google or OSM id through the shared photo cache.
2026-11-13 10:17:14 +01:00
Maurice 69552ffeef feat(journey): satellite base layer on every journey map, the shared page included (#2594)
The journey map gets the planner's round satellite switch in its top corner,
on the desktop aside, the phone views and the public page. Leaflet takes the
vector basemap off while the imagery shows and reattaches it on the way back;
the GL renderer slides the imagery under the journey's own layers. The choice
is per view and opens on the map, so no setting is read or written.
2026-11-13 10:17:13 +01:00
Maurice 1f710bd217 feat(journey): narrow an open photo album to a date range (#2859)
A shared Immich or Synology album can hold hundreds of photos from other
trips. Once an album is open the picker now offers a from/to range that
filters the already loaded assets by the day the grid files them under, so
Select all only takes the photos in range. No server change.
2026-11-13 10:17:13 +01:00
Maurice 11e4fe6ae3 feat(dawarich): preselect the stay's day in the accept dialog (#3068)
The accept dialog opened on "not on a day yet" although the stay carries its
local date. It now picks the trip day whose date matches, on open and when the
trip is switched, and falls back to no day when the trip has none that matches.
2026-11-13 10:17:13 +01:00
Maurice 3e98938c95 feat(export): places as KML for Google My Maps (#1866)
Google Maps takes no list from outside, but My Maps imports a KML layer.
The trip export dialog and the phone export sheet get "Places for Google
My Maps" (GET places/export.kml, MCP export_trip_kml), and a list's export
menu gets KML next to GPX. The KML codec gains a pin writer; the list file
mapping moves to collections.helpers to keep the service within its size.
2026-11-13 10:17:12 +01:00
Maurice 7283b75cac feat(mcp): list_collection_places pages a long list compactly (#2647)
A read tool that answers one list a page at a time (100 by default, up to
500) as compact one-line JSON rows, filtered by status, label or "no label",
with the list's labels, the matching total and a cursor. The cursor is the
last place's position (sort_order, created_at, id), so labelling a page
while paging through unlabelled places skips none. getCollection orders by
the same keys, id last, so both tools agree on ties.
2026-11-13 10:17:11 +01:00
Maurice cae04e83dd feat(collections): edit a saved place's provider ids, website, phone and price (#2646)
The update contract takes google_place_id, google_ftid, osm_id, website,
phone, price and currency like saving does, website with the same
normalisation and refusal. REST and update_collection_place share it, so an
agent can correct a place instead of re-saving it and losing its labels,
ratings, position and id. The assistant's change card names the new fields.
2026-11-13 10:17:11 +01:00
Maurice 72e8b1f8d9 feat(collections): "want to revisit" status for saved places (#2567)
A fourth status after visited, for a place already seen that the user wants
to go back to. It joins the one-tap cycle, the filters, the pickers, the
assistant cards and the MCP status tool, and counts as visited wherever
visits are counted (isVisitedCollectionStatus), so "visited everywhere"
keeps it. The status pickers go two by two where four would not fit.
2026-11-13 10:17:11 +01:00
Maurice 73ac4b29ad feat(collections): filter by category on the phone (#2618)
The phone toolbar gets a category chip beside the status and label chips,
fed by the same categoryOptions the desktop dropdown uses, and no longer
clears a category picked on desktop. Managing labels moves into the label
panel so the row keeps four chips at phone width.
2026-11-13 10:17:10 +01:00
Maurice 208af03212 feat(dialogs): save a dialog with Ctrl/Cmd+Enter (#1303)
Ctrl/Cmd+Enter in any field of a DialogShell presses its one primary footer
button, focusing it first so blur-committed fields keep their value. Dialogs
with no or several primaries leave the keys to the field, as does the journal
story editor, which opts out with data-keeps-ctrl-enter.
2026-11-13 10:17:07 +01:00
Maurice 8401428ca7 feat(pdf): bake place photos into the trip PDF at thumbnail size (#1501)
The print engine embedded every place photo at full resolution, so a trip of a
few hundred stops saved to a PDF of tens of megabytes. Same-origin photos are now
fetched six at a time, scaled to a 156 px short side and inlined as JPEG data
URLs; photos from other origins, failures and an aborted export keep the URL.
2026-11-13 10:17:06 +01:00
Maurice 12406bbbf4 feat(pdf): large text switch in the trip PDF preview (#2817)
A third switch next to the page break and transport notes switches scales the
day content's type by 1.35 and darkens its greys, for readers who print the
plan. Off by default and remembered per browser like the other two; the day
content's sizes and greys now come from three CSS variables on <body>.
2026-11-13 10:17:06 +01:00
Maurice 12ae32b69e feat(map): setting to hide the Mapbox basemap points of interest (#1761)
Mapbox Standard labels shops, restaurants and sights of its own; they look like trip
places but cannot be picked. A Hide Points of Interest switch next to the other Mapbox
options, on the desktop and the phone, turns them off on the trip map and the preview
without a rebuild. The Standard config handling moves into useStandardBasemapConfig.
2026-11-13 10:17:06 +01:00
Maurice 5176dc29d6 feat(places): say when a place has an address but no coordinates (#2837)
A place saved with a typed address and no search pick, link or map point gets no
coordinates, so it never shows on the map, and nothing said why. The place form warns
under the address while that is so, and the inspector, the phone's place sheet and
both place lists mark such a place. No geocoding on save; the form stays unchanged
for a service stop, which explains a missing position itself.
2026-11-13 10:17:05 +01:00
Maurice 0eac3a9f49 feat(planner): fold the place inspector to its head and keep the place selected (#3039)
On a landscape phone or a small tablet the inspector covered most of the map, and the
only way to clear it was closing it, which deselected the place. A chevron in the head
now folds the card to the avatar and name; the next place opens in full. The head band
moves to PlaceInspectorHead so the inspector stays inside its size budget.
2026-11-13 10:17:05 +01:00
Maurice 64b86c2403 feat(planner): edit the trip from its settings in the planner (#2811)
The trip form was mounted in the planner but nothing opened it, so title, dates,
cover and currency could only be changed from My Trips. Trip settings now lists
Edit Trip at the foot of its areas for anyone with trip_edit; it swaps the dialog
for the trip form. The phone already has Edit Trip in the More sheet.
2026-11-13 10:17:04 +01:00
Maurice 66d85729ff feat(categories): icons for parking, bus stops, stations, airports and fuel (#2706)
The category picker had no icon to tell a car park or a terminal apart from the
places a trip visits. Five transport icons join the shared table, which the admin
pickers, map pins and the PDF all read; the ship's tooltip now also says ferry.
2026-11-13 10:17:04 +01:00
Maurice e7bff6681e fix(roadtrip): a hire car picked up after a night sets off from the stay (#2993)
With hotel bookends on, a day whose first stop was a hire car pick-up got no morning
bookend, so the drive from the stay to the desk was missing. Only a landing means the
traveller was not at the hotel; the pick-up is the first drive of the day.
2026-11-13 10:17:04 +01:00
Maurice c7cfa5a64f feat(costs): read a trip in another currency from the Costs tab (#1683)
Costs always converted to the display currency from Settings, so a trip
that moves on to another country could not be read in its new local
currency. A picker in the Costs bar (beside the Expenses heading on the
phone) now reads one trip in any currency; the server already settles in
any base. The pick is per trip and kept in this browser, nothing stored.
2026-11-13 10:17:03 +01:00
Maurice 72fc61a016 feat(costs): leave a category out of the totals for a look (#2819)
Seeing what a trip cost without, say, food meant adding it up by hand. A
click (or tap) on a category in the breakdown now leaves it out of the
total, your share and what you paid, dimmed in place with a line naming it
and Count all to undo. View only: nothing is saved, and what is owed, open
or settled stays whole. The desktop breakdown moves into its own component.
2026-11-13 10:17:03 +01:00
Maurice a70eea5bf2 feat(costs): let a trip keep its costs as totals only (#1714)
Travellers who track what a trip costs but never split it saw an
Unfinished warning on every expense and a settle-up they had no use for.
A trip now has a costs mode (appended migration, trips.costs_mode, split
by default); totals leaves payers, shares and settling up out of Costs on
desktop and phone through the same gate as a trip of one, and saves new
expenses without a payer. PUT /api/trips/:id and update_trip take it on
budget_edit. The split chips move into their own component.
2026-11-13 10:17:03 +01:00
Maurice c9c419b4ad feat(costs): leave out owing and settling on a trip of one (#2689)
With one traveler every share is the whole amount, yet Costs showed owe,
owed, outstanding, settle-up, balances and the final budget. A shared gate
(showsSplitting) now hides that side on desktop and phone, unless the ledger
still names someone else; the expense editor drops who paid and the split
while the expense holds nothing more. The payment dialog moves into its own
file to keep the panel under the size ratchet.
2026-11-13 10:17:03 +01:00
Maurice 6759277c3b feat(costs): link a trip file as an expense receipt (#2839)
A booking confirmation that is also the invoice had to be uploaded a second
time to sit on the expense. The expense editor (desktop and phone) now lists
the trip's files to link, the booking's own first; the save sends the id in
receipt_file_ids, which the server already links. The desktop receipts block
moves into its own component.
2026-11-13 10:17:02 +01:00
Maurice 10d5f3ed46 feat(costs): split a custom expense by shares (#1165)
A stay shared by people who slept a different number of nights had to be
worked out by hand. The custom split now takes shares next to amounts and
percent: five nights against four split the total 5/9 to 4/9, cent-exact by
largest remainder. Only the amounts are saved, so server and MCP are
unchanged; the phone sheet gains the same unit switch, percent included.
2026-11-13 10:17:02 +01:00
Maurice 2934319722 feat(costs): start a new expense in the trip currency (#2807)
A new expense opened in the display currency, so every bill on the road
needed the trip currency picked from the full list. It now starts in the
trip's own currency (a scanned receipt keeps the one it read), and the
trip and display currencies lead the picker on desktop and phone.
2026-11-13 10:17:02 +01:00
Maurice d4c81bc5f2 chore(lint): lower the warning baseline for the two promise rules the dock change cleared 2026-11-13 08:42:01 +01:00
Maurice 35e0613986 test(native): read the listener names as strings, so the appLinks case typechecks 2026-11-13 08:34:05 +01:00
Maurice ccfa92f650 feat(mobile): the assistant is an entry of the dock, sorted like the pages
The home assistant sat behind the More button on every page outside a
trip, so a dock with free slots showed "..." for a single entry. It is now
an entry of the dock list while its addon is on and it can run for the
user: an account that never sorted its dock gets it in a slot the addons
leave free, and Settings > Appearance > Mobile can move it between the bar
and More like any page.
2026-11-13 08:27:31 +01:00
Maurice fdb2095ced fix(native): a widget tap opens its page every time, also after iOS ended the page
A widget or Live Activity tap reached the UI as Capacitor's appUrlOpen, or
as the launch URL. When iOS had ended the web view's process in the
background, the event went to the old page's listeners and was lost, and
the reloaded page found the same link in getLaunchUrl that it had already
followed once and skipped it: the second tap on the packing widget opened
the start page.

The binary now takes com.liketrek.trek://navigate links itself and hands
them over like a quick action, held until the UI takes it and kept for a
page that is still loading. It reports the feature appLinks, and the UI
only follows the link through Capacitor for binaries without it.
2026-11-13 08:27:23 +01:00
Maurice d6a6b49b6b test(planner): the #3074 case in a file of its own, and the category pick inside guessedCategory, so both files stay within their size 2026-11-12 23:18:51 +01:00
Maurice 0855bafca7 fix(search): place searches find a name typed without its accents (#3075)
"Obidos" did not find "Óbidos", nor "Sao Bras" "São Brás". The search of the
places list, a collection, the saved-place picker, the import from a trip
and the location picker now compare without case and Latin accents, with
letters such as ß, ø or ł folded to their plain spelling. Marks outside Latin
script stay, since there they change the word.
2026-11-12 23:06:36 +01:00
Maurice a39f3fe40c fix(planner): a place added from a collection keeps its category (#3074)
The place form guessed the category of a picked place from its kind, so a
place taken from a collection inside a trip lost the category it was saved
under, while adding it from the collection kept it. A picked place that
carries a category this server has now brings it along; a category chosen
in the form by hand still wins.
2026-11-12 23:06:35 +01:00
Maurice f13a0aa6ee fix(collections): pins on the map show their place's category (#3073)
The server nests a saved place's category, while the markers and the hover
card read the flat fields a trip place carries, so every pin on a
collection's map was the grey default. The map now hands its places on with
the category's colour, icon and name where the markers look for them.
2026-11-12 22:02:05 +01:00
Maurice 10d1a61614 fix(ios): a running TREK Cloud subscription is found on a fresh install too
The check before a purchase read only the entitlements on the device. Those
can be empty while the subscription runs, so the app offered the plans and
the purchase changed the running subscription's plan in the App Store. The
status of the subscription group now decides when the device has none. A
renewal the device has not finished yet no longer counts as a purchase
waiting for its instance either, which also let the check through.
2026-11-12 21:21:51 +01:00
github-actions[bot] 2bcfc1bfcb chore: bump version to 5.1.4 [skip ci] 2026-11-12 19:51:06 +00:00
Maurice b6e3c9bf39 feat(native): the app needs TREK 5.1.4 on the server
nativeApi goes to 3 with 5.1.4, whose UI follows a widget or Live Activity
tap that starts the app. The apps refuse an older server with the hint to
update, and check a server they remember once more.
2026-11-12 20:21:16 +01:00
Maurice 736dc5dda9 chore(lint): lower the non-null assertion baseline the AirTrail test change freed 2026-11-12 17:47:05 +01:00
Maurice ae26a6c61c refactor(airtrail): save the settings in one transaction
The address and the key it may no longer carry now change together, as
they do for Dawarich, so the transaction ratchet drops the AirTrail entry.
The tests that pinned the old no-transaction shape now pin the new one.
2026-11-12 17:31:56 +01:00
Maurice 8979bd1fcd test(airtrail): one case per key field instead of a loop of awaits 2026-11-12 17:14:54 +01:00
Maurice 74d243d8e0 fix(airtrail): a new host does not inherit the stored API key
Saving the AirTrail settings with a different URL and the key field left
masked or blank kept the stored key, so the next sync sent the old
instance's key to the new host. The key now goes with the host, as it
already did for the connection test and for Dawarich: a new origin without
a new key clears it, a path edit on the same origin keeps it. The key is
cleared before the new address is written.
2026-11-12 16:58:24 +01:00
Maurice 56b9bac031 fix(native): a widget tap that starts the app opens what it points at
A tap on a widget or a Live Activity hands the app
com.liketrek.trek://navigate/<path>. A running app gets it as appUrlOpen;
an app started by the tap gets it as its launch URL, which nothing read, so
the path was lost and the app opened its start page (the packing widget
landed on the trip's plan instead of its lists). The launch link is now
followed once per launch.
2026-11-12 16:53:04 +01:00
Maurice 4983b3407c fix(planner): a flight without airports keeps its day and times on save
A flight created with a day and times but no endpoints (over MCP) showed
them in the form, but buildTransportPayload took a flight's span only from
rows with an airport, so a save sent day, end day and both times as null.
Like a train without stations, such a flight now takes its day, times,
airline, flight number and seat from the raw rows. Endpoints and legs still
come only from rows with an airport.

Fixes #3070
2026-11-12 16:49:29 +01:00
Maurice ed1728feec refactor(admin): the SSO unlink section is a component of its own
The edit dialog's component crossed the complexity limit with the section's
conditions; the section and its confirm step now live in SsoUnlinkSection,
which decides itself when it shows.
2026-11-12 16:14:34 +01:00
Maurice 11472c05ff fix(native): a failed SSO round trip from the app still hands back on the retry
A sign-in the app starts runs through the server's handoff page in the
system browser. When the SSO callback failed (an expired or overwritten
state), the login page dropped the way back to that page, so the retry from
there was a plain web login and ended in the web app inside the app's
browser sheet instead of signing in the app. The login page now keeps a way
back that points at the handoff page, and only that, and renews its
fallback copy for a slow retry. State, nonce, PKCE and the handoff verifier
are unchanged; the handoff page still asks for a tap before it passes
anything on.
2026-11-12 16:10:14 +01:00
Maurice f2ac12ca4f fix: hold the ratchets after the SSO unlink and the day pager window
- The SSO unlink answers with a response contract (adminSsoUnlinkResponseSchema).
- It reuses linkOidcIdentity with nulls instead of a new repository method,
  so UsersRepository keeps its method count and size.
- The client calls the route through apiClient, so api/client.ts does not
  grow, and its confirm dialog takes no async callback.
- The pager's preview step and pagesFor keep their complexity.
2026-11-12 15:56:59 +01:00
Maurice b53130ca0d feat(admin): remove the SSO link of an account whose provider changed its subject
A sign-in with another subject of the same provider is refused, so an
account whose provider re-issued the subject (a migrated or rebuilt IdP, a
changed sub format) was locked out: editing the email, as the wiki advised,
never touched the stored link. The edit dialog now offers "Remove SSO link"
for other accounts while SSO is set up (DELETE /api/admin/users/:id/sso,
audit admin.user_sso_unlink). The next SSO sign-in links again under the
rules of a first one, so it still needs a verified, not self-set email.
The log line names the remedy, and the wiki tells the two refusals apart.

Fixes #3065
2026-11-12 15:39:21 +01:00
Maurice a3a4f7cf79 fix(mobile): days drawn ahead of a fast swipe through the day pager
Only the selected day and two on either side were drawn, and the selection
moves only once the strip comes to rest, which iOS takes a while for. A run
of fast swipes ran past the drawn pages into blank ones until it settled.
The drawn window now also follows the day the swipe is heading for, so the
next days are there before they slide in. At most two windows of five pages
are mounted, however long the trip.

Fixes #3067
2026-11-12 15:39:20 +01:00
Maurice ac7094df05 fix(planner): a refresh of the trip no longer wipes an open transport or booking form
The transport form re-seeded itself whenever the trip's costs arrived as a
new array, and the booking form whenever days, places or stays did. A
socket reconnect re-reads the whole trip, so behind a proxy that drops idle
sockets every 15 to 30 seconds everything typed so far was wiped. Both forms
now re-seed only for what opens a different form; the data is read when the
form is seeded, as before.

Fixes #3069
2026-11-12 15:39:20 +01:00
Maurice 80130f7946 fix: the provisioning line reads as a key reference, the safe-area test follows margin-inline
lint:i18n-keys took the spread in front of t() for a member call and did not
see native.provisioning.sub; the sentence is its own constant now. The
shell chrome test asks for the logical margins and their right-to-left swap.
2026-11-12 14:52:42 +01:00
Maurice fc648bc7e4 fix: green checks after the first-screen redesign and the safe-area floor
- native-shell/amount.ts: the no-break spaces in the price pattern were
  written out as characters; \s already matches them.
- widgets.ts: the Live Activity's lead time stays inside the module.
- i18n: the count strings of the new plan tiles replace the removed ones
  on NOT_PLURAL, and the address placeholders of the non-Latin locales
  are their own instead of English copies.
- mobile.css: the side safe-area margins of the floating bars use
  margin-inline, swapped for right to left, instead of physical sides.
2026-11-12 14:36:13 +01:00
Maurice 9005d6b333 fix(native): name TREK without an article in the first screens
TREK is the application, not a thing one has a copy of: "I already have
TREK", not "a TREK". The same for the not-TREK messages in German and
Dutch and the agreement title in French, Spanish, Italian and Catalan.
2026-11-12 14:18:42 +01:00
Maurice 52f0fb882f feat(native): redesign the app's first screens around a living landscape
The screens the app ships itself (welcome, plans, address, own server and
the states between) move from the dark card layout to a light design on an
animated aerial landscape drawn by a WebGL shader (native-shell/scenery.ts):
an island, a coastal town, farmland and mountains in one world.

- Welcome: the landscape with the TREK wordmark, three perks in one card,
  "Explore cloud plans" and "I already have a TREK" as two buttons.
- Plans: one tab per plan; choosing one flies the camera to its place,
  further inland with every plan. Each plan reads as a kind of trip, the
  price sits in a card with a monthly/yearly switch and counts to its new
  amount in the store's own format (native-shell/amount.ts), and a yearly
  price shows what it makes a month. Restore purchases, the renewal terms
  and the links stay as the store rules require.
- Address: the address writes itself on a glass card over the landscape;
  free, the password rules and the agreement sit on white cards.
- Own server: the server address on the glass card; a failed check turns
  the card red with what went wrong, proxy sign-in and client certificate
  panels in the same style.
- States: server not reachable, certificate needed, already subscribed,
  setting up (with its steps, then "Arrived") and the slow setup.
- iPad, an unfolded phone and a phone on its side get two columns, the
  landscape beside the content; on a dual-screen device each half takes
  one screen. Arabic and Hebrew mirror through logical sides only.
- The shader caps the pixel ratio at 2, drops to a lower resolution on a
  slow device, stops in the background and is one still frame with
  reduced motion.
- The sign-in and error page bars over a proxy's page (iOS and Android)
  take the same look: a white card and a dark button.
- Plus Jakarta Sans is bundled for the text, with Montserrat standing in
  for Cyrillic; the wordmark fonts and the old preview images are gone.
- New strings in all 28 languages; the keys of the old screens removed.
- The dev sample plans follow the cloud's plans (start, basis, plus, pro).
2026-11-12 14:18:42 +01:00
Maurice b8ec428ad1 fix(native): show the travel day's Live Activity from three hours before its first stop
The Live Activity started with the first sync of a travel day and stayed on
the lock screen all day, so a day whose first stop is an evening flight held
it from the early morning on. It now appears three hours before the day's
first timed stop and stays until the day is done; liveDayDue() decides.
2026-11-12 14:00:47 +01:00
Maurice a1d3ffc2ce feat(native): the 'server not reachable' screen names the cause, small, for a report
A load that failed sent the app to the address screen with a general message only, and a tester's screenshot told nothing. The web view's own error (net::ERR_..., an HTTP status, NSURLErrorDomain and its code, a client certificate the proxy asked for) is kept natively and shown under the message once.
2026-11-12 10:11:01 +01:00
Maurice 46b9df54bf feat(native): the TREK Cloud screens show the app and make the choice of a plan easy
The cloud screen shows real screens of the app. The plans are a choice now: one tap marks a plan, the recommended one is marked, what every plan has is said once above them, storage says how many photos it holds, and the yearly switch shows what a year saves; one button at the foot carries the chosen plan on. The claim screen names the plan with a way to change it, shows the address as it will be once it is free, ticks off the password rules while typing, says in three words where the servers are, that it can be cancelled and that there are no ads, and puts the photo behind the form away.
2026-11-12 10:04:31 +01:00
Maurice 3c0c3b3f6e fix(native): a store account with a running TREK Cloud subscription buys no second one
One subscription per Apple ID or Google account. Choosing another plan in the app changed the running subscription on the App Store, and the redeem then failed with 'We could not set up your TREK'. The app now asks the store first and shows a screen of its own: the account already has TREK Cloud, a new one can be taken out once it has ended, with Open my TREK and the store's page to manage it. The native purchase refuses as well; a paid one that was never set up is still redeemed. On the address screens the link 'What is a TREK server?' is gone, and the small print under a purchase is set in the system text face at regular weight.
2026-11-12 09:28:56 +01:00
Maurice 5395d53332 fix(native): the top bar keeps clear of a camera in the corner of the screen
The iOS app hands the page the safe area it measures, a floor under env() where WebKit reports none, and on a phone the floating bars keep clear of the side insets as well.
2026-11-12 08:42:17 +01:00
Maurice ec08032e98 fix(native): SSO in the Android app survives Android ending the app while the browser is open
A Samsung phone may end the app while the sign-in runs in the browser. The browser's return then started the app anew without the call that waited for it, the first page load failed while the network was still held back, and the user came back to 'server not reachable' and then to the sign-in page, in a loop. The return is now kept natively and redeemed by the sign-in page with the verifier kept until then, and a page that failed on the network is loaded once more before the app gives up.
2026-11-12 08:42:17 +01:00
Maurice cc5eca5553 feat(native): a client certificate can be added on the address screen before connecting
The import was only offered once the check saw the proxy ask for a certificate during the handshake. A proxy that refuses without asking left no way in. The wiki also points to the certificate's new place in Settings and explains why a certificate from an iOS configuration profile cannot be used by the app.
2026-11-12 08:42:16 +01:00
Maurice 1323183cce fix(assistant): Claude Sonnet 5.5 no longer ends a turn with 'came back without an answer'
Sonnet 5.5 hands back the words written before a tool call as a hidden thinking block. After the ask for the answer, an empty step got the general push with tools open again; the model answered, offered follow-ups once more, and the answer was lost a second time. That push now asks for the answer as text and stays text-only.
2026-11-12 08:42:16 +01:00
Maurice 009be05d22 fix(mobile): a sheet no longer shows the page through it while it opens
The dim faded in on the sheet's own parent, and opacity multiplies down the tree, so for its first frames even the opaque sheet let the list behind read through as ghost text (Android tester, expense editor). The dim now fades on a layer of its own beside the sheet, and the card is opaque halfway through its pop.
2026-11-12 08:42:16 +01:00
Maurice 860ffc1dee fix(planner): the drag state setters are stable, so the sidebar's dragend listener lists them and is still set up once 2026-11-12 08:42:15 +01:00
Maurice c515f95d17 fix(ci): the drag hook lists its dependencies, closeOnDone stays module-private, and the weight test waits for its save 2026-11-12 07:10:18 +00:00
Maurice 855bf8610a refactor(planner): the day plan's drag state lives in a hook of its own; the holiday provider cases get a suite of their own
DayPlanSidebar grew past its size baseline with the Firefox drag fix. Its drag state (what is dragged, the drop line, the day under the drag, whether a drag happened) and the effect that ends a lost drag move into useDayPlanDragState, typed, which also lowers the file's strict-mode count; size and strict baselines are lowered with it.

vacay.service.test.ts went past the 2000-line limit once the Nager v4 cases were back in it. They move into vacay.holiday-provider.test.ts, which takes VacayService from a Nest testing module over a snapshot db, so neither the db mock nor a hand-built service is needed.
2026-11-11 22:38:59 +00:00
Maurice 742edc4a79 fix(ci): today's changes pass the release gates
- iOS: the navigation proxy reports an error page through a callback the controller sets; it called the controller's method on itself and did not build.
- keyboard: the done key reads the field's text in the capture phase, before the field's own handler, so a list that adds an item on Enter and clears itself keeps the keyboard; the tests wire it as the app does.
- Ireland migration: the queued writes use addSql instead of awaiting inside loops; only table constants and integer ids reach the SQL.
- tests: the Dawarich GL test types its two layer specs once (strict baseline lowered), the collab test reads the id off the listed message, the reasoning test runs its four servers in parallel, and a wasm copy covers the precompressed branch without a charset.
- en wording: the deviations agreement no longer calls TREK self-hosted.
2026-11-11 22:06:50 +00:00
Maurice 1975a23aab fix(mobile): the settings group headings follow the text size setting
They used a fixed 10px size, which theme:lint refuses; they take the caption tier now.
2026-11-11 21:32:05 +00:00
Maurice 80c8d9c602 feat(mobile): settings group their sections by what they reach: your account or this device
A tester could not tell which settings follow the account to every device and which stay on the phone, and looked for offline mode for a while. The section list and dropdown now stand under two headings, "Your account" and "This device". The server the app opens, the app lock and a client certificate move out of Account into a section of their own, "App", shown only in the app, next to Offline; About stands under TREK. Labels in all locales. TestFlight feedback, 5.1.3.
2026-11-11 21:31:14 +00:00
Maurice 948c9ef57b fix(atlas): show Ireland's 26 counties instead of its four provinces
geoBoundaries has no county layer for Ireland: its ADM1 is the four
provinces and its ADM2 the 166 local electoral areas, so the Atlas showed
Ireland as four provinces next to a UK of counties.

The Atlas builder can now take a country's regions from another source.
Ireland's come from Tailte Éireann's 26 counties (CC BY 4.0), with Dublin,
Cork, Galway and Limerick each one county, under their ISO 3166-2:IE
codes. Those outlines come at full detail, so the builder simplifies them
itself and keeps the borders two counties share identical on both sides,
so no point between them falls into neither. ATLAS_ONLY rebuilds just the
named countries without downloading the whole world.

A migration clears Ireland's cached place regions so they are worked out
again as counties, moves visited rows that name a county onto its code,
and hides each county of a province a user had hidden. Provinces a user
marked as visited are kept as they are.

Fixes #2922
2026-11-11 21:26:31 +00:00
Maurice bac968c0bf fix(ai): retry chat requests with reasoning_effort none when a server refuses tools with reasoning
Azure AI Foundry reasoning deployments behind an "Own server" connection
reject function tools on /v1/chat/completions at their default reasoning
effort and answer HTTP 400 naming reasoning_effort. The Responses API
switch from #2792 only covers direct OpenAI keys, so the assistant could
not run on these deployments at all.

The chat client still never sends reasoning_effort, so servers that do not
know the field (Ollama, LM Studio, vLLM, OpenRouter) see unchanged
requests. When a request with tools is refused over reasoning_effort, it
goes out once more with reasoning_effort "none", and the rest of the turn
keeps sending it. The refused field is now carried on AiProviderError
instead of only being logged.

Fixes #3043
2026-11-11 21:26:20 +00:00
Maurice 520562ef86 fix(planner): keep day plan drags alive in Firefox
Firefox fires dragstart while it handles the mouse move that starts the
drag and delivers that move to the page afterwards, button still held.
The listener that clears a drag whose dragend never arrived took that
move as the end of the drag and dropped the drag data at pick-up, so a
stop could only be reordered with its arrows. The same held-button moves
come from the touch polyfill on hybrid laptops throughout a drag. Only a
move with no button held now counts as the drag being over.

The day list also switched its stagger animation back on whenever a drag
ended, which replayed the entrance of every day card ("weird reload").
It now plays once on mount and stays off after the first drag.

Fixes #3041
2026-11-11 21:26:15 +00:00
Maurice 001253c39b fix(ios): the strip above the page carries the page colour on releases that lay the web view out below the status bar
On iOS 27.0.x the web view started below the status bar, and the strip above it showed the view controller's black instead of the screen background. The colour behind the web view now follows the theme the UI reports through StatusBar.setStyle (the top colour of --m-scr), so the strip reads as part of the page on every release. TestFlight feedback, 5.1.3.
2026-11-11 21:24:35 +00:00
Maurice 5345b50d17 test(vacay): the holiday provider cases live in the existing suites again
The two new test files mocked the db module and built VacayService by hand,
which the test-mocks and new-service ratchets refuse for files without a
baseline entry. The cases move back into vacay.service.test.ts and
vacay.test.ts unchanged; VACAY-037 asks for a year no other case caches. The
unrelated admin addon test change is reverted and two comments name the new
provider.
2026-11-11 21:06:29 +00:00
Luffy a3622e91b6 feat(vacay): implement Nager.Holidays v4 integration and adapt holiday data structure 2026-11-11 21:04:07 +00:00
Luffy be305c3daf fix(vacay): move holiday lookups to Nager.Holidays v4 2026-11-11 21:04:07 +00:00
Sebastian Fischer ec78cfbdd0 feat(tours): find bakeries and groceries on the map and along a hike
A multi-day hike restocks on the way: bread in the morning, food for
lunch and supper every day or two. Neither could be searched for.

- Explore pill: new `bakery` and `supermarket` chips. `supermarket`
  already existed on the server (shop=supermarket|convenience) but no
  pill offered it; `bakery` is new (shop=bakery).
- Along the tour: `bakery` and `supermarket` are offered to a hike,
  after its existing kinds so a new hike still starts on the same three.
- TREK places index: `bakery` maps to food_and_drink>casual_eatery>bakery;
  `supermarket` is narrowed to grocery and convenience stores, since the
  whole food_and_beverage_store branch also brought tobacconists and
  wine shops. Both paths checked against a live answer of the index.
  As the more specific term, a bakery is no longer listed as a place to
  eat when only `food` is asked along a tour.
- Category guess: a bakery or grocer picked from the map was preselected
  as Bar/Cafe or Shopping even where the admin had made a Bakery or
  Groceries category. The guess now looks for such a category first, by
  name in the usual languages or by icon, and keeps the broad one when
  there is none. The category icon picker offers Croissant and
  ShoppingBasket, the icons the map chips use.
- Labels in every locale; MCP tool description names the new kinds.
2026-11-11 20:59:59 +00:00
Ibrahim Roshdy 8023be452a fix(atlas): give a point no polygon contains to the nearest border
A point just off a simplified coastline lies in no admin0 polygon, and the
fallback then took the candidate with the smallest bounding box. Nice and
Menton sit ~80 m outside France's coast and 20+ km from Italy, but Italy's
box is the smaller one, so they resolved to IT. The fallback now ranks the
candidates by distance to their border. A deferred loose box (PS/XK) keeps
its priority. The parity test's reference lookup mirrors the new rule.

Closes #3048
2026-11-11 20:59:53 +00:00
John Jahn 5112c96c05 perf(places): the country lookup skips polygons whose box misses the point and remembers its answers
The places list and the trip bundle resolve the country of every place
without a cached region on each read, and that lookup was about 90% of
their CPU time: it ray-cast every polygon of each candidate country, which
is thousands of rings for a country that is mostly islands.

A polygon whose outer ring's box does not hold the point is now skipped
without walking it, and an answer is kept by coordinate pair (20,000 pairs,
oldest out first), since the borders never change while the process runs.
The answers are the ones the ray-cast alone gives; ATLAS-SVC-051 and
ATLAS-SVC-055 compare every country against the reference lookup.

Measured on a production build with a 360-place trip: GET places
22.7 ms -> 3.5 ms, GET bundle 27.5 ms -> 6.2 ms. A first lookup of a
pair is about 1.8 times faster (90 us -> 50 us over 20 cities).
2026-11-11 20:59:47 +00:00
John Jahn 04b1fb72bb perf(static): serve the hashed assets from Brotli copies written at build time
Every file under assets/ was compressed again on each download, at the low
level a request path can afford. They carry their content hash in the name
and never change, so the client build now writes a Brotli copy beside each
compressible one (postbuild, scripts/precompress.mjs) and production points
a browser that accepts Brotli at that copy before express.static serves it.

Measured on a production build of 5.1.2, fetching the 652 precached files
the way a service worker install does: 16.3 MB -> 13.9 MB on the wire and
about 1.0 s -> 0.2 s of server CPU per install. A client without Brotli, a
range request and a build without copies are served exactly as before.
2026-11-11 20:59:40 +00:00
John Jahn a9d11ccb2e fix(plugins): grant plugin children the network on Node 25 and newer
From Node 25 the permission model covers the network, so a plugin child
forked with --permission and no --allow-net gets ERR_ACCESS_DENIED on every
lookup and connect, and no plugin can reach a host it was granted. Node 24
is unaffected and rejects the flag as a bad option, so it is passed only
where process.allowedNodeEnvironmentFlags knows it.

Which hosts a plugin may reach is still decided by the egress guard inside
the child.

Fixes #3045
2026-11-11 20:59:34 +00:00
John Jahn 848e3f90e1 perf(client): the icons the first load needs ship as one chunk instead of 187
Every lucide icon is its own module, and the plugin icon lookup reaches all
of them by name from the navigation. The bundler therefore put each icon
another chunk also uses into a chunk of its own: 187 files of a few hundred
bytes, each one a request on a first visit and an entry in the precache.

A `vendor-icons` group collects them. The first load asks for 32 script
files instead of 224 (the bytes stay the same, 3.9 MB before compression),
and the service worker installs 460 files instead of 652. With 50 ms added
to every response the login page reaches DOMContentLoaded in 0.5 s
instead of 3.8 s.
2026-11-11 20:59:27 +00:00
Botond Rakoczi e83b6741ad docs: add TrueNAS SCALE installation guide 2026-11-11 20:57:42 +00:00
Maurice 0a3d93b59b fix(collab): a deleted chat message keeps no text
Deleting a message set the flag and kept the text in the row and in every backup; only the client hid it. The delete now blanks the text with the flag, and a migration clears what earlier deletes left. The row stays for the placeholder.
2026-11-11 20:41:25 +00:00
Maurice 102e2141ec fix(mobile): screens with pull-to-refresh bounce like every other iOS list
The dashboard, journeys and notifications switched the iOS bounce off entirely so it would not race the pull. The pull now holds only a clearly downward move from the very top, from its first pixel, and the page bounces everywhere else, its end included. TestFlight feedback, 5.1.3.
2026-11-11 20:41:25 +00:00
Maurice 768fd6f439 fix(mobile): a plugin's page in the phone shell sits below the status bar and above the dock
The page took 100vh with no top inset inside the touch shell, so the plugin's header ran under the status bar and the frame reached past the screen and under the dock. It now takes the visible height (100dvh) less the dock, after the safe area and the rail, and the plugin's own document scrolls. TestFlight feedback, 5.1.3 (Trip Importer).
2026-11-11 20:41:24 +00:00
Maurice 231a610466 fix(map): the recorded Dawarich route reads on a busy street map
A thin, translucent dash over a nearly opaque white casing read as a pale trace and was barely visible on past trips. Full colour, a wider line and longer dashes, shared by the Leaflet and GL layers. TestFlight feedback, 5.1.3.
2026-11-11 20:41:24 +00:00
Maurice 7db5ec0721 fix(native): the keyboard's done key closes the keyboard
A field outside a form gets enterkeyhint="done", but a web view only sends Enter and nothing listened, so the key did nothing. When no handler took the Enter (same text, still focused a moment later) the field lets go of the focus; a field that acts on Enter and clears itself keeps it. TestFlight feedback, 5.1.3.
2026-11-11 20:41:24 +00:00
Maurice a2b9d96e86 fix(ios): the system sign-in prompt names the app TREK, not "App"
ASWebAuthenticationSession's prompt reads CFBundleName, which was $(PRODUCT_NAME), the Xcode target "App". TestFlight feedback, 5.1.3.
2026-11-11 20:41:24 +00:00
Maurice e0022c12c4 fix(native): an error page in place of the server's keeps a way back to the address screen
A proxy's block page or a refused client certificate (Cloudflare's "Sorry, you have been blocked") left the app on a page without TREK's UI and no way to change the server. Any 4xx main-frame page that the client-certificate path does not handle now shows the sign-in bar with its own hint and "Change server", on iOS and Android. TestFlight feedback, 5.1.2.
2026-11-11 20:41:24 +00:00
Maurice 66b8f2c906 feat(native): ask for the separate TREK Cloud deviations agreement before an in-app purchase
The claim screen loads the agreement text and its version from /api/iap/consent, shows it with its own checkbox and buys nothing until it is ticked. The redeem sends consent { acceptedDeviations, version }; a refusal for an outdated text reloads it and asks again without a second purchase.
2026-11-11 20:41:23 +00:00
github-actions[bot] 6fbe10fe63 chore: bump version to 5.1.3 [skip ci] 2026-11-10 09:56:54 +00:00
Maurice 278871a31e chore(theme): the login's fixed light colours move with its code step into MfaCodeStep
The login card is a fixed light surface whose text colours are written out,
not tokens. Four of them moved with the two-factor step, so the baseline moves
them too: 58 to 54 for the page, 4 for the new component, the same total.
2026-11-10 10:23:53 +01:00
Maurice bdcef33a3e test(bookings): two test names no longer break their string on an apostrophe 2026-11-10 10:10:24 +01:00
John Jahn 8e8cff8105 fix(reservations): keep day_id on partial updates of time-only bookings
A partial update that left day_id out (a notes-only PUT, an MCP
update_reservation or update_transport call) re-derived the day from the
stored reservation_time whenever one was set. A time-only value such as
"12:00" resolves to no day, so the booking's day_id was written as null and
it vanished from its day in the planner. The end_day_id branch did the same
when a time-only reservation_end_time was sent.

The day is now derived only when the time carries a date that resolves to a
trip day; otherwise an omitted day_id or end_day_id keeps the stored value.
A dated time still re-pins the day, an explicit day_id null still clears it,
and a cleared end time still clears end_day_id. REST and both MCP update
tools share updateInTx, so the fix covers all three.

Tests: service unit cases (RESV-SVC-044..048), an e2e case per behaviour
on PUT /api/trips/:tripId/reservations/:id, and an update_reservation MCP
case, all seeded with the ORM factories.

Fixes #3025
2026-11-10 10:05:22 +01:00
Maurice 6b086eaed5 refactor: the login's code step and the reservation row shapes get modules of their own
Both files had grown past their size limit with this round's fixes. The
two-factor step moves to pages/login/MfaCodeStep.tsx and the reservation row
types to reservation-rows.ts, re-exported from the repository, and both size
baselines come down to match.
2026-11-10 09:55:36 +01:00
Maurice 0ac06555cb test(native): reset the file preview mock between the file opening tests
The second test asserted the preview was not called while the first one's call
was still counted, so it failed whenever both ran in order.
2026-11-10 09:55:36 +01:00
Maurice 1b87c62847 chore: keep the API client within its size and the native API minimum module-private 2026-11-10 09:55:36 +01:00
Maurice 98eae61b90 chore(plugin-sdk): regenerate the host facts for the stay times on reservations 2026-11-10 09:50:30 +01:00
Maurice 19956f2742 fix(native): a leg without an arrival day lands on the day it leaves in the Live Activity 2026-11-10 09:50:30 +01:00
Maurice 7506eaa4aa fix(native): the sign-in bar's room for the keyboard holds, and Android 7 to 10 see the keyboard
On iOS the keyboard plugin sizes the body and zeroes the scroll inset in a
frame update that runs after the keyboard is up, so the room made for it was
taken back. The inset is now kept while a sign-in page shows, the body gets its
height back after that update, and with the keyboard down the page can scroll
past the bar. On Android before 11 the keyboard went unseen in the insets
Capacitor passes on, so the bar stayed over the form; it is read off the
window's own insets now.
2026-11-10 09:50:29 +01:00
Maurice 57c9c9d5f2 style: format the Japanese and Korean app strings and two tests as Prettier wants them 2026-11-10 09:43:01 +01:00
Maurice 0b3d2873c2 feat(native): a proxy's sign-in page in the app has a way out and says what cannot work there
Behind Pangolin or authentik the app shows the proxy's sign-in page itself, and
nothing on it led back to the address screen: a failed passkey left users stuck
on every launch. A bar over that page now offers to change the server and says
that passkeys and security keys do not work there, as a web view cannot use them
for a domain the app is not tied to. It steps aside while the keyboard is up.
On iOS the page also gets the room the keyboard takes, so a button under it can
be scrolled into view. The address screen hands the bar its words in the user's
language when it connects.
2026-11-10 09:36:50 +01:00
Maurice 1454b58ea1 fix(mobile): quick swipes through the days no longer stop after two
The pager held the selected day and two either side, and moved that window
only once the strip had come to rest. Swiping on before the snap settled ran
into the end of the strip after two days and sprang back until the animation
was over. Every day now has its page, only the selection and two either side
draw their content, so a run of swipes goes as far as the trip does.
2026-11-10 09:30:29 +01:00
Maurice 2ab4459bc3 fix(native): the Live Activity keeps a day with flights ahead open instead of calling it done
A booking with stopovers was one stop at its first departure, and a stop
counted as done the moment it began. With a car at one and a connecting flight
at two, the Live Activity said all done for today mid-afternoon. Each leg is
now a stop of its own, as the planner shows it, and a flight or anything else
with an end time counts as under way until it lands or ends.
2026-11-10 09:29:40 +01:00
Maurice 6ebb92af6c fix(bookings): a hotel booking's card shows check-in and check-out when only the stay has them
The edit form fell back to the linked stay's times, the cards read only the
booking's metadata, so a hotel showed 15:00 and 11:00 when edited and no times
in the list. The reservation list now carries the stay's check-in window and
check-out, and the desktop facts and the phone card fall back to them. On the
phone they come first, so a price or a seat cannot push them off the card.
2026-11-10 09:28:43 +01:00
Maurice 3ab4927cb7 fix(native): the app asks for notifications on iOS too, and again if the question went unanswered
The prompt looked at the app's features once, when the user counted as signed
in. After the sign-in the page loads with the session already there, and on
iOS the bridge had not named its features yet, so the prompt gave up for good.
It now waits for them. It also counted as asked the moment it opened, so a
sheet that went under another one never came back; it counts once answered.
2026-11-10 09:26:59 +01:00
Maurice 6a0ae482d7 fix(login): the two-factor code field is set up for one-time codes and a password manager
The code step drops the email field, so a password manager had no login to
match a one-time code to; the step now keeps the address in a hidden username
field. The code field names itself and sets its keyboard hints directly
(characters, no autocorrect), as autoFocus gave it focus before anything could
prepare it, and its placeholder is translated.
2026-11-10 09:23:16 +01:00
Maurice 20eeaeb61f fix(native): the iOS app builds again, with Quick Look in an extension of its own
The file preview kept its data source in a stored property inside an
extension, which Swift does not allow, so every iOS build since failed. It is
a static now, in its own extension, and the Wallet comment it had split is
whole again.
2026-11-10 09:18:20 +01:00
Maurice 5cbe4e2fa3 feat(native): the app needs TREK 5.1.3 on the server and checks a remembered one once after the update
The fixes that keep the app's sign-in in the app live in the server's UI,
so an updated app on a 5.1.2 server still ended in the browser. The server now
reports nativeApi 2 and the app refuses anything below it, naming 5.1.3.

A server the app remembers from before is not loaded unchecked: the first
launch after the update shows the address screen filled in with it, says why
and checks it right away. A server that is new enough loads again with its
session; an older one gets the update hint.
2026-11-10 09:17:26 +01:00
Maurice b124e1bc9c fix(mobile): a day tapped in the day rail moves the pager on short trips too
The strip re-centred only when its window of pages changed. On a trip of up
to five days a tap can keep the same pages, so the rail showed the new day
while the card below stayed on the old one.
2026-11-10 09:17:26 +01:00
Maurice 08034c4402 fix(map): the iOS app's tile store is allowed by the CSP, so its maps draw tiles again
The iOS app rewrites raster tiles to trek-tile:// and answers them from its
store. WebKit checks that scheme against img-src, which had only https:, so
every tile was refused and the app showed routes and markers on a blank map.
2026-11-10 09:17:25 +01:00
Maurice 0577a9ccb9 fix(native): the hand-off page goes straight to SSO instead of TREK's login first
The app's browser sign-in opens /native-handoff, which sends the user on to
the identity provider by itself. The page was not on the list of public auth
pages, so the 401 from loading the user sent the browser to /login with the
hand-off as its return path first. On a server that also offers passwords the
user then had to pick SSO a second time before getting back to the app.
2026-11-10 09:17:22 +01:00
Maurice 2f03554a60 feat(native): files open in the system viewer instead of the share sheet
A PDF, image or document opened in the app went straight to the share sheet.
It now opens in Quick Look on iOS, to page through and zoom with its own share
button, and in the app that views its type on Android. An app without the
viewer (feature filePreview), or a type nothing on the device shows, gets the
share sheet as before.
2026-11-10 00:21:28 +01:00
Maurice 28079197e7 fix(assistant): an answer that hands the talk on to a trip or journey chat ends the turn
The home assistant wrote its answer and offered the trip's own chat in the
same step. The loop asked the model once more after the card, and it wrote
the same answer a second time below it. A step that has its answer and only
hands the conversation on now finishes the turn; a hand-on without an answer,
or beside a read, still asks the model again.
2026-11-10 00:14:44 +01:00
Maurice 0b46b89b62 fix(mobile): swipe actions are rounded tiles beside the row, icon only; expenses keep their own buttons
The actions under a swiped row were square blocks to the row's edge, unlike
the rounded cards around them. They are now tiles of their own with the
card's radius and a gap, inset within a grouped card such as the packing
list, and show only their icon (the label names them for a screen reader).
An expense already shows edit and delete beside its card, so it no longer
swipes to a second, larger pair of the same buttons.
2026-11-10 00:11:50 +01:00
Maurice 05caf67bc4 chore(notices): the release popup comes back for 5.1.3 2026-11-09 23:43:24 +01:00
Maurice 6d3c400e4a ci(native): build the iOS app with Xcode 27.1 on the xcode-27 image
An app linked against an SDK older than iOS 27.1 runs letterboxed on the inner
display of an iPhone Duo. The iOS build and the release now run on GitHub's
xcode-27 image and select Xcode 27.1; the previews stay on Xcode 26.
2026-11-09 23:33:23 +01:00
Maurice 9932288b04 chore(deps): bump the npm minor and patch group, with React's override and one MikroORM
From #3032, adjusted where it broke:
- react and react-dom are pinned in the root overrides; the group raised the
  workspaces to 19.3.1 and left the override at 19.3.0, so two react-dom
  versions met in every client test. The override moves along.
- @mikro-orm/nestjs kept the old @mikro-orm/core at the root while the server
  got 7.2.8 of its own, two copies whose types do not match; the lockfile now
  holds one 7.2.8.
- mapbox-gl stays at 3.31: 3.34 drops setRTLTextPlugin from its types, which
  the map engine uses.
- the musl pins of rollup stay at 4.62, the rollup version the lockfile holds.
2026-11-09 23:31:38 +01:00
Maurice 56b13541bd chore(native): keep the handoff start path inside browserSignIn, nothing else reads it 2026-11-09 23:29:38 +01:00
Maurice d568e20f02 chore(plugin-sdk): bump vitest and @vitest/coverage-v8 to 5 together
The two only resolve as a pair; one at a time (#3028, #3029) npm refuses the
peer range.
2026-11-09 23:28:26 +01:00
Maurice f03d59bc22 chore(deps): bump @capacitor/cli to 8.6, vite-plugin-pwa to 2 and @nestjs/schedule to 12
@capacitor/cli 8.6 drops the old uuid 7 through its xcode dependency (#3012).
vite-plugin-pwa 2 keeps the registerSW.js in public/ in place of its own, as
1.x did (#3033). @nestjs/schedule 12 still runs on Nest 11 (#3035).
2026-11-09 23:27:18 +01:00
dependabot[bot] 0833ca28c2 chore(plugin-sdk): bump semver
Bumps the plugin-sdk-minor-and-patch group in /plugin-sdk with 1 update: [semver](https://github.com/npm/node-semver).


Updates `semver` from 7.8.5 to 7.8.6
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.5...v7.8.6)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: plugin-sdk-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 3ed5f21a48 ci: bump actions/setup-java from 5 to 6
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5 to 6.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 2eb6b79bea ci: bump the actions-minor-and-patch group with 2 updates
Bumps the actions-minor-and-patch group with 2 updates: [docker/login-action](https://github.com/docker/login-action) and [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action).


Updates `docker/login-action` from 4.7.0 to 4.8.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550)

Updates `SonarSource/sonarqube-scan-action` from 8.4.0 to 8.4.1
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/4d1dc0c91ee400dcf8a6dddf2f705ac36542e304...e805d875275f728fac1885b413934be6d7c63f65)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-and-patch
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:17 +01:00
Maurice 8dd2e9716d test(native): fix the sign-in handoff tests' quoting and formatting 2026-11-09 23:15:09 +01:00
Maurice 1cdde6e9a4 fix(native): a field on a page as tall as the screen comes up above the keyboard on iOS
The login on an iPhone scrolls in no pane of its own, and the keyboard plugin
holds the body's height, so the page had nothing to scroll by and the password
field stayed under the keyboard, most of all with an error shown above it. Such
a page now gets room as tall as the keyboard at its end and scrolls the field
above it; the room goes with the keyboard.
2026-11-09 23:12:27 +01:00
Maurice 034cfb539a fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK
A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
2026-11-09 23:02:33 +01:00
Maurice 5420077b98 fix(native): a proxy sign-in the address check never met loads in the app instead of the browser
A proxy that lets /api through (an Authelia or Authentik bypass for the app)
still sends the page itself to its sign-in. The app took that redirect for a
foreign link and handed it to the browser, so the sign-in happened there and
the app stayed black on every launch. A redirect away from the server while
its first page loads is now kept as the proxy's sign-in host, on Android and
iOS, and the iOS bridge guard is always in place for hosts learned that way.
2026-11-09 20:17:37 +01:00
github-actions[bot] 5cfc7a602a chore: bump version to 5.1.2 [skip ci] 2026-11-09 12:58:07 +00:00
Maurice b48e344749 test(planner): the booking dialog's map button reads Map 2026-11-09 13:17:22 +01:00
Maurice e58a880f34 fix(mobile): the detail sheet reads its edit permission off the live booking, not the held one 2026-11-09 13:07:50 +01:00
Maurice 17ff7f6bd8 fix(i18n): the short wallet label in the non-Latin locales is their own word for it
A Latin word in those scripts counts as English whatever it is, so
same-as-en is not honoured there.
2026-11-09 13:00:09 +01:00
Maurice f03bb97d50 fix(mobile): a booking opens its detail sheet first, as a transport and the desktop do; Wallet and Map buttons read short
Bookings in the Bookings tab, the day sheet, a place's sheet and the road trip
opened their editor straight away, while a transport showed its details with
a pencil to edit. Every booking now opens the same detail sheet; its edit and
delete follow the booking's own permission (reservation_edit for a stay, a
table or a ticket, day_edit for a transport). Notes keep opening for editing,
as on the desktop.

The wallet button reads "Wallet" (its accessible name still says which one),
and "On map" reads "Map" in every language.
2026-11-09 12:56:50 +01:00
Maurice ed0142be7d chore(client): list the client certificate card as a phone-only screen 2026-11-09 12:38:52 +01:00
Maurice 6e9f89a25f fix(map): a GL map follows its box, not only the window, so closing the plan pane on a tablet leaves no black band (#3023)
Mapbox GL measures its container on window resizes only. In the app on a
tablet the trip's plan pane closes beside the map without one: the box grew
to the full width while the canvas kept its old size, and the rest stayed
black. Every GL map built through buildGlMap now watches its container and
resizes once per frame while it changes; the observer goes with the map.

Closes #3023
2026-11-09 12:29:37 +01:00
Maurice a3c7c3d76a fix(memories): mark the Synology lookups and repair as independent writes for the transaction gate
Each photo's write follows its own network lookup, the repair's marker only
records that the pass ran, and a lookup may renew the Synology session on
its own; none of these can share one transaction.
2026-11-09 12:25:40 +01:00
Maurice c043ca4bba chore(notices): the release popup comes back for 5.1.2 2026-11-09 12:18:17 +01:00
Maurice 004c6212c0 fix(i18n): mark the client certificate strings that stay English on purpose
"Server" and Italian "Password" are the words those languages use; the
untranslated-strings ratchet counts them unless they carry same-as-en.
2026-11-09 12:14:38 +01:00
Maurice 07448e662c feat(native): client certificates for servers behind mutual TLS
A proxy in front of TREK (Cloudflare, nginx, Caddy, Traefik, HAProxy) can
let in only devices that show a client certificate. The apps could not, so
such a server was unusable from them.

- Address screen: the server check runs natively and notices when the
  server asks for a certificate. Instead of "no server answered" it says so
  and offers to add a .p12/.pfx file; a wrong password asks again, an
  expired, not yet valid or keyless file is named as such.
- The certificate is shown only to the host and port it was added for.
  iOS keeps it in the keychain (this device only), Android seals the key
  with an Android Keystore key. The file's password is never stored.
- The web view answers the server's certificate request on both platforms;
  WebSocket, service worker and API requests go through with it. The
  offline copy, watch requests and the check use it too.
- When the server stops letting the app in (certificate removed, expired
  or no longer trusted) while the page still comes from the service worker
  or the offline copy, a native check sends the app to the address screen,
  which offers to add another. A server that only asks and lets everyone
  in is left alone.
- Cloudflare-style 403 and nginx-style 400 refusals without a certificate
  lead to the same screen instead of staying up as an error page.
- Settings, Account: a Client certificate card (only with one stored)
  shows subject, issuer, server and expiry, with Replace and Remove.
- Wiki: Native-Apps "Client certificates (mTLS)" with Cloudflare, nginx,
  Caddy and Traefik setup and how to make a certificate for the app.
- The app icon on the unreachable screen is black on the light surface.
2026-11-09 12:03:31 +01:00
Maurice d3bdc6de62 fix(memories): keep the Synology repair's progress through the settings domain
The repair job wrote its marker rows through the AppSettings repository,
which the settings domain owns. It now registers both keys and goes through
SettingsService, as the import boundary check asks.
2026-11-09 12:03:27 +01:00
Maurice a9bfcd0fd5 fix(memories): log capture reconfirm failures through the Nest logger
Keeps the no-console and test-new-service ratchets at their baselines: the
reconfirm tests build the service through the suite's existing helper.
2026-11-09 11:32:27 +01:00
Maurice 12caa7fbde fix(synology): an unreachable library is reported, not asked a second time for the next id 2026-11-09 11:08:54 +01:00
Maurice 1cbd4acf01 feat(synology): photos attached before the fix get their own capture time and place once
Until #3019 a Synology photo's capture time and GPS came from the item after
it, which on many libraries is the next photo, and no one can edit a photo's
capture time to put it right. On the first start after this, every Synology
photo is asked again through its owner's library, held to the backfill's few
lookups per NAS, and what Synology confirms for that very photo replaces what
was stored; a photo it cannot confirm keeps its values. While a library could
not answer, the pass runs again on the next start, at most three times.
2026-11-09 10:59:43 +01:00
Maurice 687e477ca5 fix(synology): the asset lookup asks its two candidates without an await in a loop 2026-11-09 10:59:41 +01:00
Maurice 3a66f2a8da fix(native): the keyboard on the claim screen closes again
On "Almost there" nothing put the keyboard away: the web view keeps it open on
a tap beside the fields, and "Next" sent the half-filled form, whose check
focused a field again. A tap beside the fields or a drag of the screen now
closes it, as in an iOS app, on every first screen. "Next" walks from the
address to the email to the password, "Go" buys, and the keyboard goes as the
purchase starts.
2026-11-09 10:51:20 +01:00
Maurice 7f52e424e8 fix(native): a double tap on the app's first screens no longer zooms them on iOS 2026-11-09 10:49:57 +01:00
Maurice 2479e5661b fix(mobile): swiping days ticks as the swipe is decided, and a quick second swipe reaches the day after
The detent came only once the strip had settled, which on iOS is a good while
after the finger lifts, so it felt late. It now ticks as soon as another day
becomes the nearer page, and no second time as the snap lands.

The strip held the selected day and one on either side. A second swipe made
while the first still snapped ran into the end of the strip and sprang back,
so paging several days took a pause after each. It now holds two on either
side, and the second swipe carries on to the day after.
2026-11-09 10:49:55 +01:00
Maurice bc2fb1ce2d fix(synology): journey photos keep their own capture date and place, not the next photo's
getSynologyAssetInfo asked Browse.Item for the unit id plus one, a fixed
offset that holds on some libraries and on others names the next photo, whose
date and GPS were then stored for the attached one. It now asks for the unit
id, then for the one after, and takes only an item whose own thumbnail belongs
to the requested unit; when neither does, it answers not found instead of a
neighbour's metadata.

Closes #3019
2026-11-09 10:47:22 +01:00
Maurice ffa07b3d05 chore(i18n): two task keys are in use again, the unused baseline goes down 2026-11-09 10:25:47 +01:00
Maurice 306753d6a1 fix(native): placeholders pass the tests and the theme, the swipe actions use tokens, the task card stays simple 2026-11-09 10:02:02 +01:00
Maurice 80c04df4fc feat(native): swipe rows, long-press menus, placeholders while loading, fading pictures, iOS bounce and app icon quick actions
Packing items, tasks and expenses swipe sideways to their actions, as in
Mail or Reminders: done or open one way, delete and edit the other, the
first one run by a full swipe. They are second ways to what each row
already offers; an expense still asks before it goes.

A long press on a trip card lifts it and opens its menu with the card's own
actions. Plain confirmations in MConfirmSheet now also ask through the
system where the app can.

Lists, the atlas and a journey show grey placeholders in their shape while
loading instead of a spinner in an empty screen (MSkeleton); buttons keep
theirs. A picture still loading fades in once it is there; one from the
cache shows at once. In the iOS app the page bounces at its ends again,
except on screens with pull-to-refresh, whose pull it would race; Android
keeps its stretch off.

On iOS a long press on the app icon offers "New trip" and the trip coming
up by its name, set by the UI in its language (TrekShell.setQuickActions,
feature quickActions), and a tap opens it, also from a cold start.

The app's own CSS moves out of index.css into native/nativeApp.css, which
had outgrown its size budget.
2026-11-09 09:37:36 +01:00
Maurice c9a91bbdad fix(native): the native question reads no ref while rendering, and its tests type and lint clean 2026-11-09 09:16:58 +01:00
Maurice d06168d94a feat(native): the app answers a finger like a native app, not like a web page
After a tap, WebKit and Chrome keep :hover on what was tapped and send a
made-up mouseover, so buttons and rows stayed tinted until the next tap.
While a finger drives the app <html> carries data-touch-input: a PostCSS
step guards every :hover rule with it (no specificity change), and the
made-up mouseover and mouseout stop before any handler. A trackpad or mouse
clears the mark and gets hover back; a browser never sets it.

A control without a press of its own dims the moment a finger lands on it,
after a 60 ms wait so a scroll does not flash rows, and only the innermost
pressed control. A focus: ring no longer stays round a tapped button.

Fields get a fitting return key ("done", "go" in a form, "search" on the
search fields), and addresses, codes and numbers are no longer capitalised
or corrected. Ticking off packing items and tasks, a picker chip, a select
option, a picked date and dragging a plan row now give the haptics a native
list gives.

Confirmations ask through the system where the binary has the new
@capacitor/action-sheet and @capacitor/dialog plugins: a sheet with the
action in red when it destroys something, an alert otherwise, also for
"discard changes" in a sheet. An older app, a browser and a question with a
list under it keep the HTML dialog, as does a system that could not ask.

iOS no longer lifts a page preview on a long press on a link.
2026-11-09 08:54:51 +01:00
Maurice d9baaa8957 fix(native): a long press in the app selects no text and opens no callout
In the iOS and Android app a long press on a label, card or image started a text
selection, the magnifier or a "save image" menu, the clearest sign of a web page
in a frame. startNativeApp marks <html> with data-native-app, and index.css then
turns selection and the callout off for the UI. Inputs, textareas, selects and
editors stay selectable so typing, pasting and the caret work; text worth copying
opts in with .trek-selectable. A browser keeps its default.
2026-11-09 08:32:24 +01:00
Maurice fede671f3b fix(managed): bootstrap hands the managed routes to the boot gate, so the shared kernel imports no domain 2026-11-09 00:09:27 +00:00
Maurice b3113e66b9 feat(native): buying TREK Cloud in the app asks for the password of the new TREK and opens it signed in
The claim screen takes the password (TREK's policy, checked before anything is
bought) and sends it with the purchase; the control plane answers with a
one-time welcome code, which the app hands to setServer. iOS and Android open
the instance's welcome page with it once after the first page, which signs the
buyer in. Without the code (a restore, an older control plane) the app opens
the instance as before.
2026-11-08 23:43:15 +00:00
Maurice 286b0b09ec feat(managed): the managed attachment point declares the routes it serves without a session, and the boot gate allows exactly those
Empty in this repository, as the module itself. An install that replaces the
module adds its anonymous routes in the same file, and the gate still fails on a
route missing there or an entry that went stale.
2026-11-08 23:33:52 +00:00
Maurice c0b39abe18 style(i18n): the German native strings formatted as Prettier asks 2026-11-08 23:19:39 +00:00
Maurice 6e3a6386b8 fix(i18n): German setup screens speak of "dein TREK", as everywhere else, and the proxy note reads naturally 2026-11-08 22:24:29 +01:00
Maurice 5e7d778740 fix(assistant): the step after the ask for the answer may only write text, so Claude Sonnet 5.5 can no longer offer its follow-ups a second time instead (#3016) 2026-11-08 21:16:09 +01:00
Maurice 3be87343b8 fix: the gate texts formatted as the locales are, gate detection as a table of rules, and no non-null assertions in its tests 2026-11-08 20:58:49 +01:00
Maurice 517efabbca feat(native): the address screen recognises a sign-in proxy in front of TREK and signs in to it inside the app, so its session works as in a browser 2026-11-08 20:44:26 +01:00
Maurice 1866dca818 test(dashboard): the pair-change test stubs fetch with its own mock instead of a promise-returning mockImplementation 2026-11-08 20:10:22 +01:00
Maurice 1400b5d308 fix: the iOS tile trim handles its own failure instead of an empty catch, and the weight test's slow row saves without an async prop 2026-11-08 19:57:43 +01:00
Maurice b529cf4e57 fix: the checks the layout and weight fixes broke: a quote in a test name, state set inside effects, the misused-promise baseline, and tests that render the converter before settings load 2026-11-08 19:44:50 +01:00
Maurice 6cc3d235b1 fix(offline): on an iPhone or iPad the tile prefetch keeps to 2,048 tiles and a fuller cache is trimmed once a day, so the home screen app no longer starts on a black screen (#3008) 2026-11-08 19:33:13 +01:00
Maurice a5b1156612 fix(dashboard): the hero, the atlas cards and the collections widget hold their size while they load, and the currency widget shows only the saved pair and its own rates (#3009) 2026-11-08 19:26:18 +01:00
Maurice c6ffd48e26 fix(packing): the weight field keeps what is typed and saves it once, on blur or Enter, instead of a fragment per keystroke on a slow connection (#3005) 2026-11-08 19:19:04 +01:00
Maurice afb6eae4c8 fix(native): the handoff's passkey sign-in lives in native/browserSignIn, so the page hook reaches no API on its own 2026-11-08 18:33:00 +01:00
Maurice cd1f39377d test(native): the handoff page's fixture knows the passkey step, and the hook test keeps the real API client around its passkey mocks 2026-11-08 18:10:55 +01:00
Maurice 3a99e8576d fix(android): the sign-in browser opens from the UI thread, falls back to the plain browser where no Custom Tab comes up, and says so when none opens 2026-11-08 17:54:43 +01:00
Maurice 36c8c5bc48 fix(native): a passkey sign-in from the app asks for the passkey straight away in the browser and returns to the app, instead of showing the whole login a second time 2026-11-08 17:50:50 +01:00
Maurice 4ba7db90e6 fix(native): SSO in the system browser hands back to the app even when the provider finishes in another tab, and the handoff button's label shows 2026-11-08 17:43:28 +01:00
Maurice ab6cd7009e fix(login): the language picker's safe-area offset as classes, within the file's size baseline 2026-11-08 17:14:25 +01:00
Maurice b16ac683cf fix(native): Android's back gesture steps back through the first screens, the login's language picker clears the status bar, and a slow trip open shows its splash sooner 2026-11-08 16:51:28 +01:00
github-actions[bot] 95d62b7a02 chore: bump version to 5.1.1 [skip ci] 2026-11-08 14:52:31 +00:00
Maurice e444e855c0 fix(ci): the release counts only plain vX.Y.Z tags, not the apps' TestFlight tags [skip ci] 2026-11-08 15:48:30 +01:00
Maurice 4ab42c06c9 fix(native): spell out the welcome screen's translucent mixes, which older Android web views drew opaque as halos and rings 2026-11-08 15:07:41 +01:00
Maurice 9f6ab11839 fix(native): no logo shadow on the Android welcome screen, where the web view drew it as a dark halo 2026-11-08 14:36:12 +01:00
Maurice 5f711d5374 fix: format the floating panel stylesheet and keep the locate-button room private 2026-11-08 13:33:01 +01:00
Maurice 4d550112a1 fix(mobile): Studio's inspector stays docked on an iPad in landscape, the linked journal's buttons sit beside its switch, and the iOS app draws Leaflet's basemap as raster tiles 2026-11-08 13:12:53 +01:00
Maurice 26dc88181d fix: the file size, theme and reading direction ratchets the last push tipped; widget previews and watch screens run by hand only 2026-11-08 13:09:53 +01:00
Maurice 3d4f8bfe12 fix(docker): take the base image's pending Debian security fixes, so the image scan passes 2026-11-08 12:48:19 +01:00
Maurice 5fb1838c7a fix(mobile): switching collection lists changes the content in place instead of sliding a new page in 2026-11-08 12:41:17 +01:00
Maurice 38e5d76ad2 fix(mobile): Studio's floating panels are opaque over the page, the journal header lays out in one row on a tablet, and the journal a trip links opens Studio too 2026-11-08 12:38:39 +01:00
Maurice a9af19f02a fix(mobile): the journal as a trip tab keeps its switch and menu in the shell's top row, like the tour tab 2026-11-08 12:34:41 +01:00
Maurice 305b00ae01 fix(mobile): on a narrow 3D map the flyover controls and grade legend stand as a column of icons on the side, the layer pill keeps the bottom row; and the lint ratchets the last push tipped 2026-11-08 12:30:12 +01:00
Maurice ed6724035e fix(offline): a Cache Storage open that never settles is given up on, so preparing for offline and the offline map no longer wait on it forever 2026-11-08 12:26:07 +01:00
Maurice eea83bb1c9 fix: GL maps read the stored copy again while offline, and a switch that is on keeps a visible knob in the dark scheme 2026-11-08 12:21:52 +01:00
Maurice 06c03cbded fix(mobile): the 3D grade legend goes compact on the side, and a field under the keyboard gets room to scroll above it 2026-11-08 12:15:34 +01:00
Maurice b73f749880 fix(mobile): no how-to line on the 3D map on a tablet 2026-11-08 12:13:20 +01:00
Maurice 53c18b861a fix(native): GL maps load straight from their servers in the app, past the schemes they drew nothing through on iPadOS 26 2026-11-08 12:11:23 +01:00
Maurice 9b1bf89382 fix(native): a field low on a screen scrolls above the keyboard when it comes up 2026-11-08 12:07:57 +01:00
Maurice c7f144d3ff fix: the map falls back to raster where the vector style cannot load, the tour layer pill fits narrow maps and stands on the side in 3D, the dashboard's widget column scrolls on its own, and the CI checks the watch sync and journey write-up broke 2026-11-08 12:05:59 +01:00
Maurice cb41f08199 fix(mobile): the tour tab's rail heads are rounded bands like the cards under them 2026-11-08 11:50:25 +01:00
Maurice eafb30555c fix(mobile): the iPad's tour tab opens saved tours, keeps its switch in the top row, and a tap shows no tooltip 2026-11-08 11:43:45 +01:00
Maurice 4f1f48eec5 fix: watch sync reads the roster through trip membership, image viewer on theme tokens, retry flaky release downloads in CI 2026-11-08 11:30:45 +01:00
Maurice 42341c2c76 fix(mobile): iPad feedback: columns start level, map controls sit low, no day-rail chevrons, no bottom-bar settings on a tablet, and a planned place on the journey map can be written up 2026-11-08 11:30:45 +01:00
Maurice 6a60fc183a feat(native): the app asks for notifications by itself, once
A moment after signing in, the app asks whether to turn notifications on, once per device and only where the server offers push and the device is not signed up yet. Turning them on goes the way of the settings card, which brings up the system's question. iOS asked only when someone found that card.
2026-11-08 11:05:07 +01:00
Maurice 236b4c34ac fix(mobile): the chat's composer stays above the keyboard, and its pictures open large
In the app the keyboard resizes only the body, which leaves the trip shell's fixed layers as they were, so the composer sat under the keys. The keyboard inset now also follows the height the app reports, and the chat lifts its composer by it. A picture in the chat opens full screen on a tap and zooms on a second; it sits below its bubble, keeps the long press for the message's actions, and a picture sent alone has no empty bubble beside it.
2026-11-08 11:05:07 +01:00
Maurice c6cb3dc64a feat(native): a silent push keeps every watch on a trip current
A change to a trip's packing lists or to-dos (any writer: REST, MCP, plugin RPC) is noted from the realtime broadcast and, a couple of seconds later, each member with the iPhone app gets a silent push through the app relay, marked as a background push. The push carries no content: the woken app reads both lists of the trip its watch shows from the server with its own session and hands them to the watch. The app gains the remote-notification background mode for it.
2026-11-08 11:05:06 +01:00
Maurice 3fc7487b58 fix(ci): the lint ratchet, and SwiftPM's half-downloaded artifacts on the iOS runners
The import sheet no longer sets state from its preset effect and is below the complexity limit; the trip shell's splash tests await their timers. The iOS and watch builds resolve their Swift packages first on a clean artifact cache, up to three times: a failed download from GitHub left a partial Capacitor xcframework behind and every later attempt stopped on it.
2026-11-08 11:05:06 +01:00
Maurice ac055d531f fix: the checks the tour tab, the shared links and the watch sync broke on dev
The tour tab keeps desktopOnly as a plain property, so the tab list's type still has it. The wiring test follows the tab's new gate. The import sheet keeps the choice and the save with the item they were made for instead of resetting them in an effect, takes the share sheet's trip through an effect event instead of a ref read while rendering, and is split into its parts. The tours tab sets its pane while rendering, as React has it for state that follows a prop. The sheet's tests look for the texts it shows. The Android link test reads the host with java.net.URI, which a JVM unit test can run. Two locale files take Prettier's quotes.
2026-11-08 10:37:41 +01:00
Maurice 0ef3cbf520 feat(mobile): a trip opens without its loading screen on the phone and the tablet
The touch shell waited for the desktop's minimum splash of a second and a half after every trip load. It no longer does: while the trip loads it shows only its background, marked as a route fallback, so the page transition holds the dashboard until the trip is there and the trip slides in whole, as the dashboard slides back on the way out. The splash only comes up when a load takes longer than a second. The trip opened before, still in the store for a frame, is never shown for the one opening.
2026-11-08 10:33:49 +01:00
Maurice 3458238db0 feat(native): a link shared to TREK can go to the trip's links
A page or a link shared from another app now carries its address. The app asks whether to keep it among the trip's links (Collab, Links) or to read a booking from it. A link whose page the phone cannot read is no longer refused: it is handed over as the link alone, which can be kept. With Collab off a page is read as a booking, as before.
2026-11-08 10:15:26 +01:00
Maurice afaacec61a fix(native): a tick on the watch, the phone or another member's device reaches every watch at once
The watch's snapshot was rebuilt every fifteen minutes, so a tick in the app or one arriving over the WebSocket did not reach the watch until then. It is now rebuilt as soon as the packing lists or the to-dos change in the offline tables. A tick on the watch goes as a message while the phone is in reach, which wakes the phone app, and a phone app in the background writes it to the server itself, so the trip's other devices hear of it at once instead of when the phone app is next opened.
2026-11-08 10:15:20 +01:00
Maurice 5f4fb5dae7 feat(mobile): the tour editor as a tab of its own on an iPad and an unfolded foldable
On the wide and split size classes the touch shell no longer sends the tours tab back to the plan. It gets the desktop's tour editor, wired over the shell's own planner (useTripPlannerPageOf): on split the trip's tours and the editor share a pane beside the map, on wide the pane and the map take turns. A phone keeps its tours in the places browser.
2026-11-08 09:58:33 +01:00
Maurice 788b1f63d0 fix(mobile): the trip rail's More wears the dock's ring while its sheet is open 2026-11-08 03:07:24 +00:00
Maurice bfb1b04f8d feat(mobile): TREK Studio opens over the journey on an iPad on its side and an unfolded Duo
Studio is a child route of the journey, opened on top of it. The touch
shell's journey now renders that child in the split size class, where the
header offers Studio beside the menu, as the desktop's bar does. The editor
is the desktop's: tapping a panel, adding an element and dragging it with a
finger work through its pointer events. On the phone and in a single pane
the journey stays alone, whatever the address says, and offers no Studio.
2026-11-08 03:05:56 +00:00
Maurice a77ec6e225 fix(ci): the track sheet reads the distance unit as a DistanceUnit, and knip's baseline drops the type useFollowTrack no longer leaves unused 2026-11-08 03:00:39 +00:00
Maurice 2851eb7afe feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo
The desktop's import dialog keeps its markup and moves its logic into
useGoogleRouteImport, unchanged: the link previewed, its stops in order with
the unresolved ones named, and the resolved ones appended to the day picked,
at least two, under one request id per opening and day. The large screens
get the same flow as a sheet, offered on the stage card under the track
offer to a member who may add places to days. The phone has no entry to it.
2026-11-08 02:57:43 +00:00
Maurice 6c7605b4a6 fix(mobile): the driving form's vehicle card says what you drive, not a second "Vehicle" under the range card 2026-11-08 02:54:39 +00:00
Maurice 3b286d04c3 feat(mobile): the tour detail edits on an iPad and an unfolded Duo
The phone's tour sheet reads the tour and plans it onto a day. In the wide
and split size classes it now edits what the desktop's tour card edits,
through the same useTourDetailActions: the name and the colour, a copy saved
as a new tour, the recorded tracks, the packing list it points at and its
files, for a member who may edit places. The phone is unchanged.
2026-11-08 02:45:34 +00:00
Maurice 21afe89b13 feat(mobile): a road trip day follows an imported track on an iPad and an unfolded Duo
The desktop's track dialog comes to the large screens as a sheet over the
planner's followTrack, the hook RoadtripTrackModal runs on: the trip's tracks
nearest to the day first, the drive fitted to the one picked with via points,
and those vias dropped again. The stage card offers it in the wide and split
size classes, to an editor of days, on a trip that holds a track, and names
the track the day follows. The phone and the folded Duo have no entry to it.
2026-11-08 02:42:37 +00:00
Maurice 060a43ba64 feat(mobile): the driving figures are a form on an iPad and an unfolded Duo
On the phone the road trip's driving figures stay a read-out, set at the
desktop. A large screen is that table, so in the wide and split size classes
the same sheet edits the two driving limits, the day's window and where it
ends, the vehicle with its range and fill, and what the route may avoid,
through the planner's saveRoadtripLimit, the desktop dialog's own save. A
range worked out from the vehicle's own figures stays read-only, as on the
desktop, and a reader without the right to edit days sees every field
disabled. The phone and the folded Duo are unchanged.
2026-11-08 02:37:26 +00:00
Maurice dedbf16f03 fix(native): Android offers TREK Cloud while orders are closed and says a refused purchase is refunded
The licence testers of the internal test buy from a release build, so the
plan screen no longer hides the purchase on Android before the opening.
Their purchases are Play test purchases, which the cloud serves. A real
purchase made before the opening is refused with ordering-closed and never
acknowledged, so Google Play refunds it after three days; the claim and the
restore now say that instead of a general error. The app still acknowledges
a purchase only after a redeem that returned an instance. iOS is unchanged.
2026-11-08 02:31:13 +00:00
1603 changed files with 50084 additions and 8523 deletions
+2 -2
View File
@@ -175,7 +175,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+5 -3
View File
@@ -140,7 +140,9 @@ jobs:
set -eu set -eu
# Derive version from git tags, no package.json dependency # Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1) # Only a plain vX.Y.Z is a release: the glob alone also matches the apps' TestFlight tags
# (v5.1.0-tf.15), whose suffix then broke the arithmetic below.
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}" STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}" STABLE="${STABLE:-0.0.0}"
@@ -342,7 +344,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -401,7 +403,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+8 -6
View File
@@ -49,7 +49,7 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- uses: actions/setup-java@v5 - uses: actions/setup-java@v6
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@@ -111,19 +111,21 @@ jobs:
ios: ios:
if: github.repository == 'liketrek/TREK' if: github.repository == 'liketrek/TREK'
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 60 timeout-minutes: 60
environment: store environment: store
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older. # The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
- name: Select Xcode 26 # runs letterboxed on the inner display of an iPhone Duo.
- name: Select Xcode 27.1
run: | run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) XCODE=/Applications/Xcode_27.1.app
test -n "$XCODE" test -d "$XCODE"
sudo xcode-select -s "$XCODE" sudo xcode-select -s "$XCODE"
xcodebuild -version xcodebuild -version
xcodebuild -showsdks | grep iphoneos27.1
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
+43 -6
View File
@@ -60,7 +60,7 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: npm cache: npm
- uses: actions/setup-java@v5 - uses: actions/setup-java@v6
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
@@ -90,18 +90,20 @@ jobs:
retention-days: 14 retention-days: 14
ios: ios:
runs-on: macos-15 runs-on: xcode-27
timeout-minutes: 40 timeout-minutes: 40
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older. # The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
- name: Select Xcode 26 # runs letterboxed on the inner display of an iPhone Duo.
- name: Select Xcode 27.1
run: | run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) XCODE=/Applications/Xcode_27.1.app
test -n "$XCODE" test -d "$XCODE"
sudo xcode-select -s "$XCODE" sudo xcode-select -s "$XCODE"
xcodebuild -version xcodebuild -version
xcodebuild -showsdks | grep iphoneos27.1
- name: Check the property lists - name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
@@ -121,6 +123,21 @@ jobs:
working-directory: client working-directory: client
run: npm run cap:sync -- ios run: npm run cap:sync -- ios
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
# No -sdk: the scheme builds the embedded watch app and its complications # No -sdk: the scheme builds the embedded watch app and its complications
# against the watchOS simulator SDK and the rest against the iOS one. # against the watchOS simulator SDK and the rest against the iOS one.
- name: Build for the simulator - name: Build for the simulator
@@ -136,7 +153,10 @@ jobs:
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and # The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
# dark, for a trip ahead, a trip under way and none, plus a home screen and # dark, for a trip ahead, a trip under way and none, plus a home screen and
# a lock screen of them. Nobody has to install a build to see a widget change. # a lock screen of them. Nobody has to install a build to see a widget change.
# Started by hand only (Run workflow): the apps are on TestFlight and Play now,
# where a change is looked at on a device; on every PR it only held the queue.
widget-previews: widget-previews:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
@@ -193,7 +213,9 @@ jobs:
# The watch app on watchOS simulators in its screenshot mode (DemoMode.swift): # The watch app on watchOS simulators in its screenshot mode (DemoMode.swift):
# every screen on the largest and the smallest watch, the main ones in every # every screen on the largest and the smallest watch, the main ones in every
# language, and the complications, as PNGs to look at without a watch. # language, and the complications, as PNGs to look at without a watch.
# Started by hand only, like the widget previews: up to 90 minutes of macOS.
watch-screens: watch-screens:
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 90 timeout-minutes: 90
steps: steps:
@@ -220,6 +242,21 @@ jobs:
xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS xcrun simctl list runtimes | grep -q watchOS || xcodebuild -downloadPlatform watchOS
xcrun simctl list runtimes xcrun simctl list runtimes
# Capacitor's xcframeworks come from GitHub release downloads, which answer
# 504 now and then; SwiftPM then reports the half-written file as "already
# exists in file system". Resolve on a clean artifact cache, a few times
# with growing pauses, before the build needs the packages.
- name: Resolve the Swift packages
working-directory: client/ios/App
run: |
for attempt in 1 2 3 4 5; do
rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
if xcodebuild -resolvePackageDependencies -project App.xcodeproj -scheme App; then exit 0; fi
echo "Resolving the Swift packages failed (attempt $attempt of 5)"
sleep $((attempt * 30))
done
exit 1
- name: Build the watch app for the simulator - name: Build the watch app for the simulator
working-directory: client/ios/App working-directory: client/ios/App
run: | run: |
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
load: true load: true
tags: trek:scan tags: trek:scan
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+9 -3
View File
@@ -1100,7 +1100,7 @@ jobs:
path: plugin-sdk/coverage/ path: plugin-sdk/coverage/
- name: Scan - name: Scan
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0 uses: SonarSource/sonarqube-scan-action@e805d875275f728fac1885b413934be6d7c63f65 # v8.4.1
env: env:
# SonarQube Cloud is the scan action's default host, so no # SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the # SONAR_HOST_URL — the token is a sonarcloud.io token for the
@@ -1236,10 +1236,12 @@ jobs:
bin="$RUNNER_TEMP/bin" bin="$RUNNER_TEMP/bin"
mkdir -p "$bin" mkdir -p "$bin"
cd "$RUNNER_TEMP" cd "$RUNNER_TEMP"
curl -sSfLo helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz" # GitHub's release downloads answer 504 now and then: retry, the checksum still decides.
fetch() { curl -sSfL --retry 5 --retry-delay 15 --retry-all-errors -o "$1" "$2"; }
fetch helm.tgz "https://get.helm.sh/helm-$HELM_VERSION-linux-amd64.tar.gz"
echo "$HELM_SHA256 helm.tgz" | sha256sum -c - echo "$HELM_SHA256 helm.tgz" | sha256sum -c -
tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm tar -xzf helm.tgz --strip-components=1 -C "$bin" linux-amd64/helm
curl -sSfLo kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz" fetch kubeconform.tgz "https://github.com/yannh/kubeconform/releases/download/$KUBECONFORM_VERSION/kubeconform-linux-amd64.tar.gz"
echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c - echo "$KUBECONFORM_SHA256 kubeconform.tgz" | sha256sum -c -
tar -xzf kubeconform.tgz -C "$bin" kubeconform tar -xzf kubeconform.tgz -C "$bin" kubeconform
echo "$bin" >> "$GITHUB_PATH" echo "$bin" >> "$GITHUB_PATH"
@@ -1274,6 +1276,10 @@ jobs:
--set httpRoute.enabled=true \ --set httpRoute.enabled=true \
--set 'httpRoute.parentRefs[0].name=gateway' \ --set 'httpRoute.parentRefs[0].name=gateway' \
> "$out/http-route.yaml" > "$out/http-route.yaml"
helm template trek charts/trek \
--set 'persistence.data.accessModes[0]=ReadWriteMany' \
--set 'persistence.uploads.accessModes[0]=ReadWriteMany' \
> "$out/access-modes.yaml"
helm template trek charts/trek \ helm template trek charts/trek \
--set podSecurityContext.runAsUser=1000 \ --set podSecurityContext.runAsUser=1000 \
--set podSecurityContext.runAsGroup=1000 \ --set podSecurityContext.runAsGroup=1000 \
+3
View File
@@ -70,7 +70,10 @@ COPY server/package.json ./server/
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime # gypfile:false (npm/cli#9837). The only other install scripts among the runtime
# deps are esbuild's binary check, redundant next to its optional platform # deps are esbuild's binary check, redundant next to its optional platform
# package, and a telemetry ping. # package, and a telemetry ping.
# The upgrade takes the Debian security fixes the base image was built before
# (zlib, 11/2026, failed the image scan until node:24-trixie-slim caught up).
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \ apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \ libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \ npm ci --workspace=server --omit=dev --ignore-scripts && \
+17 -1
View File
@@ -20,7 +20,23 @@ geoBoundaries is licensed under **CC BY 4.0**
The bundled files are simplified (coordinate-quantized) and re-tagged with the The bundled files are simplified (coordinate-quantized) and re-tagged with the
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
(gbOpen) release. (gbOpen) release, except for Ireland (below).
## Tailte Éireann — counties of Ireland
Ireland's regions in the same files (`admin1/IE.geojson.gz`, and its features in
`admin1.geojson.gz`) are the 26 counties from Tailte Éireann's *Counties - National
Statutory Boundaries - 2019 - Generalised 20m*, because geoBoundaries has no county
layer for Ireland.
Contains Tailte Éireann data, © Tailte Éireann, licensed under **CC BY 4.0**
(https://creativecommons.org/licenses/by/4.0/). Source:
https://data-osi.opendata.arcgis.com/
The outlines are simplified, coordinate-quantized and re-tagged with ISO 3166-2:IE
codes by `server/scripts/build-atlas-geo.mjs`. Tailte Éireann publishes the
generalised boundaries for reference only; they may not match the statutory
boundaries exactly.
## OpenStreetMap — geocoding, place details & descriptions ## OpenStreetMap — geocoding, place details & descriptions
+7 -4
View File
@@ -146,7 +146,7 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
#### 🗺️ Atlas, Vacay and Collections #### 🗺️ Atlas, Vacay and Collections
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals - **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing - **Vacay**: leave calendar with half days, public holidays from [nagerholidays.com](https://nagerholidays.com), school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite - **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas - **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
@@ -276,6 +276,8 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it. `COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
**TrueNAS SCALE:** Install TREK directly from the Community catalog. See the [TrueNAS SCALE installation guide](https://github.com/liketrek/TREK/wiki/Install-TrueNAS-SCALE).
> [!TIP] > [!TIP]
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute. > **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
@@ -348,7 +350,7 @@ TREK then launches fullscreen with its own icon, just like a native app.
## Updating ## Updating
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose, See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note. Docker run, Helm, Portainer, Unraid, TrueNAS SCALE and Proxmox, plus the encryption-key note.
<br /> <br />
@@ -437,8 +439,9 @@ Every variable, its default and what it does: see
The Atlas map's country and sub-national (province/county) boundaries come from The Atlas map's country and sub-national (province/county) boundaries come from
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed [**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md) [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/); Ireland's counties come from
for full third-party attributions. [**Tailte Éireann**](https://data-osi.opendata.arcgis.com/), also CC BY 4.0. See
[NOTICE.md](NOTICE.md) for full third-party attributions.
<br /> <br />
+2 -1
View File
@@ -43,7 +43,8 @@ See `values.yaml` for more options.
## Notes ## Notes
- Ingress is off by default. Enable and configure hosts for your domain. - Ingress is off by default. Enable and configure hosts for your domain.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class. - PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored. - Both PVCs are `ReadWriteOnce`. Set `persistence.data.accessModes` and/or `persistence.uploads.accessModes` to override, for example `[ReadWriteMany]` so a backup pod can mount the volume. On the data volume such a pod may only read: SQLite corrupts `travel.db` when a second process writes it over a network filesystem.
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, accessModes, annotations) are then ignored.
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only. - With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed. - `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 5.1.0 version: 5.1.4
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "5.1.0" appVersion: "5.1.4"
+2 -2
View File
@@ -11,7 +11,7 @@ metadata:
{{- end }} {{- end }}
spec: spec:
accessModes: accessModes:
- ReadWriteOnce {{- toYaml (.Values.persistence.data.accessModes | default (list "ReadWriteOnce")) | nindent 4 }}
{{- with .Values.persistence.data.storageClassName }} {{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }} storageClassName: {{ . | quote }}
{{- end }} {{- end }}
@@ -33,7 +33,7 @@ metadata:
{{- end }} {{- end }}
spec: spec:
accessModes: accessModes:
- ReadWriteOnce {{- toYaml (.Values.persistence.uploads.accessModes | default (list "ReadWriteOnce")) | nindent 4 }}
{{- with .Values.persistence.uploads.storageClassName }} {{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }} storageClassName: {{ . | quote }}
{{- end }} {{- end }}
+10 -1
View File
@@ -247,12 +247,21 @@ persistence:
size: 1Gi size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class. # Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: "" storageClassName: ""
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored. # Access modes of the claim. ReadWriteMany lets another pod mount the volume, for
# example a backup sidecar, but only for reading: SQLite needs every writer on one
# host, so two pods writing travel.db over a network filesystem corrupt it.
accessModes:
- ReadWriteOnce
# Bind an existing PVC. The other values (size, storageClassName, accessModes,
# annotations) are then ignored.
existingClaim: "" existingClaim: ""
annotations: {} annotations: {}
uploads: uploads:
size: 1Gi size: 1Gi
storageClassName: "" storageClassName: ""
# Access modes of the claim; ReadWriteMany lets other pods read or add uploads.
accessModes:
- ReadWriteOnce
# Specify an existing PVC to bind. The other values are then ignored. # Specify an existing PVC to bind. The other values are then ignored.
existingClaim: "" existingClaim: ""
annotations: {} annotations: {}
+1 -1
View File
@@ -6,7 +6,7 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
```bash ```bash
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001 npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build npm run build # prebuild generates PWA icons, then vite build; postbuild writes a Brotli copy beside each hashed asset for the server to hand out
npm run typecheck # tsc --noEmit (CI) npm run typecheck # tsc --noEmit (CI)
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it) npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors) npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
@@ -9,7 +9,9 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-action-sheet')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-dialog')
implementation project(':capacitor-filesystem') implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics') implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard') implementation project(':capacitor-keyboard')
@@ -15,6 +15,7 @@ import androidx.glance.appwidget.GlanceRemoteViews
import com.liketrek.trek.tripday.TripDayNotification import com.liketrek.trek.tripday.TripDayNotification
import com.liketrek.trek.widget.AtlasWidget import com.liketrek.trek.widget.AtlasWidget
import com.liketrek.trek.widget.BookingsWidget import com.liketrek.trek.widget.BookingsWidget
import com.liketrek.trek.widget.FlightWidget
import com.liketrek.trek.widget.CollabWidget import com.liketrek.trek.widget.CollabWidget
import com.liketrek.trek.widget.PackingWidget import com.liketrek.trek.widget.PackingWidget
import com.liketrek.trek.widget.TodayWidget import com.liketrek.trek.widget.TodayWidget
@@ -74,6 +75,7 @@ class WidgetGalleryActivity : Activity() {
"trip" to TripWidget(), "trip" to TripWidget(),
"today" to TodayWidget(), "today" to TodayWidget(),
"bookings" to BookingsWidget(), "bookings" to BookingsWidget(),
"flight" to FlightWidget(),
"todos" to TodosWidget(), "todos" to TodosWidget(),
"packing" to PackingWidget(), "packing" to PackingWidget(),
"atlas" to AtlasWidget(), "atlas" to AtlasWidget(),
@@ -142,6 +142,15 @@
</intent-filter> </intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bookings" /> <meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_bookings" />
</receiver> </receiver>
<receiver
android:name=".widget.FlightWidgetReceiver"
android:label="@string/widget_flight_name"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_flight" />
</receiver>
<receiver <receiver
android:name=".widget.TodosWidgetReceiver" android:name=".widget.TodosWidgetReceiver"
android:label="@string/widget_todos_name" android:label="@string/widget_todos_name"
@@ -1,11 +1,19 @@
package com.liketrek.trek; package com.liketrek.trek;
import android.content.Intent; import android.content.Intent;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle; import android.os.Bundle;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.KeyboardShortcutGroup; import android.view.KeyboardShortcutGroup;
import android.view.Menu; import android.view.Menu;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import com.liketrek.trek.screen.TrekKeyCommands; import com.liketrek.trek.screen.TrekKeyCommands;
import com.liketrek.trek.tiles.TrekTileStore; import com.liketrek.trek.tiles.TrekTileStore;
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
// runs and on its own origin. BridgeActivity reads `config` while it // runs and on its own origin. BridgeActivity reads `config` while it
// builds the bridge inside super.onCreate(). // builds the bridge inside super.onCreate().
boolean restarting = TrekServerStore.consumeRestart(this); boolean restarting = TrekServerStore.consumeRestart(this);
TrekServerStore.recheckAfterUpdate(this);
String server = TrekServerStore.getUrl(this); String server = TrekServerStore.getUrl(this);
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this); boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
config = TrekServerStore.configFor(this, onServer ? server : null, restarting); config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
@@ -36,7 +45,17 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included. // scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER); bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread. // Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen)); if (onServer) {
bridge.setWebViewClient(
new TrekWebViewClient(
bridge,
this::showAddressScreen,
this::showSignInBar,
this::showErrorPageBar,
TrekServerStore.getSignInHosts(this)
)
);
}
TrekSharedFiles.receive(this, getIntent()); TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip. // Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this); TrekWidgetUpdater.publishPreviews(this);
@@ -68,6 +87,122 @@ public class MainActivity extends BridgeActivity {
private boolean leavingServer; private boolean leavingServer;
private View signInBar;
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
private boolean onSignInPage;
/** Whether the page is an error page in place of the server's (TrekWebViewClient.onReceivedHttpError). */
private boolean onErrorPage;
private boolean keyboardUp;
private TextView signInHint;
/**
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
* not there, so nothing else would lead back to the address screen, and a
* passkey cannot work in a web view for a domain the app is not tied to.
* The bar says so and offers to change the server.
*/
private void showSignInBar(boolean show) {
runOnUiThread(() -> {
onSignInPage = show;
if (show && signInBar == null) signInBar = buildSignInBar();
updateSignInBar();
});
}
/** The same bar over an error page in place of the server's, with words for that. */
private void showErrorPageBar(boolean show) {
runOnUiThread(() -> {
onErrorPage = show;
if (show && signInBar == null) signInBar = buildSignInBar();
updateSignInBar();
});
}
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
private void updateSignInBar() {
if (signInBar == null) return;
if (signInHint != null) {
signInHint.setText(onSignInPage ? TrekServerStore.signInHintLabel(this) : TrekServerStore.errorPageHintLabel(this));
}
signInBar.setVisibility((onSignInPage || onErrorPage) && !keyboardUp ? View.VISIBLE : View.GONE);
}
private View buildSignInBar() {
float dp = getResources().getDisplayMetrics().density;
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPaddingRelative(Math.round(16 * dp), Math.round(10 * dp), Math.round(10 * dp), Math.round(10 * dp));
// The look of the app's own first screens: a white card with a sand edge,
// warm grey text and a dark button, over whatever page the proxy shows.
GradientDrawable background = new GradientDrawable();
background.setColor(0xF7FFFFFF);
background.setCornerRadius(18 * dp);
background.setStroke(Math.max(1, Math.round(dp)), 0xFFEFE4D8);
bar.setBackground(background);
bar.setElevation(8 * dp);
TextView hint = new TextView(this);
signInHint = hint;
hint.setText(TrekServerStore.signInHintLabel(this));
hint.setTextColor(0xFF6B5F57);
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
change.setText(TrekServerStore.changeServerLabel(this));
change.setAllCaps(false);
change.setTextColor(0xFFFFFFFF);
GradientDrawable pill = new GradientDrawable();
pill.setColor(0xFF111827);
pill.setCornerRadius(12 * dp);
change.setBackground(pill);
change.setMinHeight(0);
change.setMinimumHeight(0);
change.setPaddingRelative(Math.round(14 * dp), Math.round(9 * dp), Math.round(14 * dp), Math.round(9 * dp));
LinearLayout.LayoutParams changeParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
changeParams.setMarginStart(Math.round(10 * dp));
change.setOnClickListener((v) -> {
TrekServerStore.setUrl(this, null);
TrekServerStore.markRestart(this);
recreate();
});
bar.addView(change, changeParams);
int margin = Math.round(12 * dp);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM
);
params.setMargins(margin, 0, margin, margin);
FrameLayout root = findViewById(android.R.id.content);
root.addView(bar, params);
// Above the gesture bar, and above the keyboard while it is up.
bar.setOnApplyWindowInsetsListener((v, insets) -> {
// Read off the window's own insets: Capacitor hands the views below the
// decor a copy with the system bars zeroed, and before Android 11 that
// takes the keyboard with it.
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
int bottom = window != null
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
: insets.getStableInsetBottom();
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
if (imeVisible != keyboardUp) {
keyboardUp = imeVisible;
updateSignInBar();
}
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
if (p.bottomMargin != margin + bottom) {
p.bottomMargin = margin + bottom;
v.setLayoutParams(p);
}
return insets;
});
bar.requestApplyInsets();
return bar;
}
/** /**
* The server did not load: back to the address screen, which offers retry * The server did not load: back to the address screen, which offers retry
* and change. A fresh task rather than recreate(), because this usually * and change. A fresh task rather than recreate(), because this usually
@@ -0,0 +1,57 @@
package com.liketrek.trek;
import java.net.URI;
import java.util.Locale;
/**
* A sign-in page in front of the server that the address screen never saw.
* A proxy can let the app's check through (an Authelia or Authentik rule that
* bypasses /api) and still send the page itself to its sign-in. Handed to the
* browser, that sign-in leaves its session there and the app stays blank on
* every launch. So a redirect away from the server, while the server's first
* page is still loading, is taken as the proxy's sign-in: it loads in the web
* view and its host is kept, the way the address screen keeps the ones it saw.
* A link the user follows is no redirect, and once a page of the server has
* shown, nothing is learned any more.
*/
final class SignInRedirect {
private SignInRedirect() {}
/** The host (host:port with an explicit port) to keep as a sign-in host, or null. */
static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) {
if (!mainFrame || !redirect || serverShown || server == null || target == null) return null;
URI from;
URI to;
try {
from = new URI(server);
to = new URI(target);
} catch (Exception e) {
return null;
}
String fromHost = lower(from.getHost());
String toHost = lower(to.getHost());
String toScheme = lower(to.getScheme());
if (fromHost == null || toHost == null) return null;
if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null;
// Never from https down to plain http: the sign-in would send a password in the clear.
if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null;
if (toHost.equals(fromHost)) return null;
return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort();
}
/** Whether `url` is on the server's host, where the app's own pages come from. */
static boolean onServer(String server, String url) {
if (server == null || url == null) return false;
try {
String serverHost = lower(new URI(server).getHost());
return serverHost != null && serverHost.equals(lower(new URI(url).getHost()));
} catch (Exception e) {
return false;
}
}
private static String lower(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
}
@@ -10,6 +10,10 @@ import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
@@ -18,27 +22,158 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell"; private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url"; private static final String KEY_URL = "server_url";
private static final String KEY_PREVIOUS_URL = "previous_server_url";
private static final String KEY_SERVER_CHECK = "server_check";
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
private static final String KEY_LABEL_ERROR_PAGE_HINT = "label_error_page_hint";
/**
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
* server remembered under a lower value is checked once more: the first
* launch after an update that raised it puts the address screen back up,
* filled in with that server (getPreviousUrl). Raise both together.
*/
private static final int SERVER_CHECK = 3;
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
private static final String KEY_SHELL_ONCE = "shell_once"; private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart"; private static final String KEY_RESTART = "restart";
private static final String KEY_WELCOME = "welcome";
private static final String CONFIG_DIR = "trek-server-config"; private static final String CONFIG_DIR = "trek-server-config";
private TrekServerStore() {} private TrekServerStore() {}
/**
* The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
* server's first page opens its welcome page with it once, which signs the
* buyer in (TrekWebViewClient). Kept only until the restart has loaded.
*/
static void setWelcome(Context context, String code) {
SharedPreferences.Editor editor = prefs(context).edit();
if (code == null) editor.remove(KEY_WELCOME);
else editor.putString(KEY_WELCOME, code);
editor.commit();
}
/** The welcome page for the pending code, handed out once; null when there is none. */
static String takeWelcomeUrl(Context context) {
String code = prefs(context).getString(KEY_WELCOME, null);
String server = getUrl(context);
if (code == null) return null;
prefs(context).edit().remove(KEY_WELCOME).commit();
if (server == null) return null;
return (server.endsWith("/") ? server : server + "/") + "api/managed/welcome#" + code;
}
static String getUrl(Context context) { static String getUrl(Context context) {
return prefs(context).getString(KEY_URL, null); return prefs(context).getString(KEY_URL, null);
} }
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
static void recheckAfterUpdate(Context context) {
SharedPreferences prefs = prefs(context);
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
String server = getUrl(context);
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
editor.commit();
}
/**
* The words of the bar over a proxy's sign-in page, in the user's language,
* as the address screen passed them (setServer's `labels`): the server's UI
* is not there to say them. Null keeps the English fallback.
*/
static void setLabels(Context context, String signInHint, String changeServer, String errorPageHint) {
SharedPreferences.Editor editor = prefs(context).edit();
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
if (errorPageHint != null) editor.putString(KEY_LABEL_ERROR_PAGE_HINT, errorPageHint);
editor.commit();
}
static String errorPageHintLabel(Context context) {
return prefs(context).getString(
KEY_LABEL_ERROR_PAGE_HINT,
"This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
);
}
static String signInHintLabel(Context context) {
return prefs(context).getString(
KEY_LABEL_SIGN_IN_HINT,
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
);
}
static String changeServerLabel(Context context) {
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
}
/** The server an update asked to check again, until a server is set. */
static String getPreviousUrl(Context context) {
return prefs(context).getString(KEY_PREVIOUS_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) { static void setUrl(Context context, String url) {
setServer(context, url, null);
}
static void setServer(Context context, String url, Set<String> signInHosts) {
SharedPreferences.Editor editor = prefs(context).edit(); SharedPreferences.Editor editor = prefs(context).edit();
if (url == null) { if (url == null) {
editor.remove(KEY_URL); editor.remove(KEY_URL);
} else { } else {
editor.putString(KEY_URL, url); editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
}
if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS);
} else {
Set<String> lower = new HashSet<>();
for (String host : signInHosts) lower.add(host.toLowerCase(Locale.ROOT));
editor.putStringSet(KEY_SIGN_IN_HOSTS, lower);
} }
editor.commit(); editor.commit();
} }
/**
* The hosts of a sign-in page that stands in front of the server (a proxy
* such as Cloudflare Access or Pangolin), as the address screen found them.
* Only these may load inside the web view besides the server itself, so
* the proxy's session lands where the app can use it (TrekWebViewClient).
*/
static Set<String> getSignInHosts(Context context) {
Set<String> hosts = prefs(context).getStringSet(KEY_SIGN_IN_HOSTS, null);
return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts));
}
/** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */
static void addSignInHost(Context context, String host) {
Set<String> hosts = new HashSet<>(getSignInHosts(context));
if (!hosts.add(host.toLowerCase(Locale.ROOT))) return;
prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit();
}
/** Start the next launch on the bundled address screen, even with a server remembered. */ /** Start the next launch on the bundled address screen, even with a server remembered. */
private static final String KEY_FAILURE = "last_failure";
/**
* Why the server's page did not load, in the WebView's own terms
* ("net::ERR_NAME_NOT_RESOLVED (-2)", "HTTP 503"): the address screen shows
* it small under its message, so a report carries the cause.
*/
static void setFailure(Context context, String failure) {
prefs(context).edit().putString(KEY_FAILURE, failure).commit();
}
/** The failure behind this address screen, once. */
static String takeFailure(Context context) {
String failure = prefs(context).getString(KEY_FAILURE, null);
if (failure != null) prefs(context).edit().remove(KEY_FAILURE).commit();
return failure;
}
static void showShellOnce(Context context) { static void showShellOnce(Context context) {
prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit(); prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit();
} }
@@ -83,6 +83,12 @@ final class TrekSharedFiles {
file.put("mimeType", meta.optString("mimeType", "application/octet-stream")); file.put("mimeType", meta.optString("mimeType", "application/octet-stream"));
file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP)); file.put("data", Base64.encodeToString(item.getSecond(), Base64.NO_WRAP));
if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId")); if (meta.has("tripId")) file.put("tripId", meta.optLong("tripId"));
// A page or a link: where it came from, so the UI can keep it among the trip's links.
String url = meta.optString("url", "");
if (url.startsWith("https://") || url.startsWith("http://")) {
file.put("url", url);
file.put("title", meta.optString("title", url));
}
pending = file; pending = file;
if (listener != null) listener.onFile(file); if (listener != null) listener.onFile(file);
} }
@@ -1,22 +1,32 @@
package com.liketrek.trek; package com.liketrek.trek;
import android.Manifest; import android.Manifest;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import com.liketrek.trek.clientcert.ClientCertActions;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat; import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject; import com.getcapacitor.JSObject;
import com.getcapacitor.PermissionState; import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin; import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall; import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod; import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission; import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback; import com.getcapacitor.annotation.PermissionCallback;
import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessaging;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONObject; import org.json.JSONObject;
/** The native half of client/src/native/trekShell.ts. */ /** The native half of client/src/native/trekShell.ts. */
@@ -29,6 +39,8 @@ import org.json.JSONObject;
) )
public class TrekShellPlugin extends Plugin { public class TrekShellPlugin extends Plugin {
private static final String TAG = "TrekShell";
/** How long to wait after returning from the browser for the redirect to land. */ /** How long to wait after returning from the browser for the redirect to land. */
private static final long CANCEL_GRACE_MS = 600; private static final long CANCEL_GRACE_MS = 600;
@@ -59,6 +71,8 @@ public class TrekShellPlugin extends Plugin {
"googleWallet", "googleWallet",
"keyCommands", "keyCommands",
"windowLayout", "windowLayout",
"clientCertificate",
"filePreview",
}; };
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */ /** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
@@ -256,11 +270,53 @@ public class TrekShellPlugin extends Plugin {
call.resolve(); call.resolve();
} }
/**
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
* app on the device views the type; the UI then hands it to the share sheet.
*/
@PluginMethod
public void previewFile(PluginCall call) {
String path = call.getString("path");
Uri parsed = path == null ? null : Uri.parse(path);
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
java.io.File cache = getContext().getCacheDir();
boolean inCache;
try {
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
} catch (java.io.IOException e) {
inCache = false;
}
if (!inCache || !file.isFile()) {
call.reject("a file in the app's cache is required", "INVALID");
return;
}
String type = call.getString("mimeType", "application/octet-stream");
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
Intent view = new Intent(Intent.ACTION_VIEW)
.setDataAndType(content, type)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
getActivity().runOnUiThread(() -> {
JSObject result = new JSObject();
try {
getActivity().startActivity(view);
result.put("result", "shown");
} catch (ActivityNotFoundException e) {
result.put("result", "unsupported");
}
call.resolve(result);
});
}
@PluginMethod @PluginMethod
public void getServer(PluginCall call) { public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext()); String url = TrekServerStore.getUrl(getContext());
String previous = TrekServerStore.getPreviousUrl(getContext());
JSObject result = new JSObject(); JSObject result = new JSObject();
result.put("url", url == null ? JSONObject.NULL : url); result.put("url", url == null ? JSONObject.NULL : url);
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
String failure = TrekServerStore.takeFailure(getContext());
result.put("failure", failure == null ? JSONObject.NULL : failure);
call.resolve(result); call.resolve(result);
} }
@@ -272,11 +328,35 @@ public class TrekShellPlugin extends Plugin {
call.reject("A http(s) URL is required"); call.reject("A http(s) URL is required");
return; return;
} }
TrekServerStore.setUrl(getContext(), url); TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
JSObject labels = call.getObject("labels");
if (labels != null) {
TrekServerStore.setLabels(
getContext(),
labels.getString("signInHint"),
labels.getString("changeServer"),
labels.getString("errorPageHint")
);
}
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
String welcome = call.getString("welcome");
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
call.resolve(); call.resolve();
restart(); restart();
} }
/** The sign-in hosts the address screen passes along, plain host names only. */
private static Set<String> signInHostsOf(PluginCall call) {
Set<String> hosts = new HashSet<>();
JSArray list = call.getArray("signInHosts");
if (list == null) return hosts;
for (int i = 0; i < list.length(); i++) {
String host = list.optString(i, "");
if (host.matches("[A-Za-z0-9.-]+(:[0-9]+)?")) hosts.add(host);
}
return hosts;
}
@PluginMethod @PluginMethod
public void resetServer(PluginCall call) { public void resetServer(PluginCall call) {
TrekServerStore.setUrl(getContext(), null); TrekServerStore.setUrl(getContext(), null);
@@ -315,7 +395,32 @@ public class TrekShellPlugin extends Plugin {
// A Custom Tab shares the browser's cookies, so a running SSO session at // A Custom Tab shares the browser's cookies, so a running SSO session at
// the identity provider carries over. The redirect back to the app's // the identity provider carries over. The redirect back to the app's
// scheme arrives through the intent-filter in AndroidManifest.xml. // scheme arrives through the intent-filter in AndroidManifest.xml.
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url)); Uri target = Uri.parse(url);
getActivity().runOnUiThread(() -> openSignIn(target));
}
/**
* Opens the sign-in in a Custom Tab, or in the browser itself where no
* Custom Tab can come up. Launched on the UI thread: some devices dropped a
* launch from the bridge's thread without a word, and the sign-in then
* looked cancelled. A browser that cannot open at all is reported as such.
*/
private void openSignIn(Uri target) {
try {
new CustomTabsIntent.Builder().build().launchUrl(getActivity(), target);
return;
} catch (RuntimeException tabFailed) {
Log.w(TAG, "No Custom Tab for the sign-in, trying the browser", tabFailed);
}
try {
getActivity().startActivity(new Intent(Intent.ACTION_VIEW, target).addCategory(Intent.CATEGORY_BROWSABLE));
} catch (RuntimeException browserFailed) {
Log.w(TAG, "No browser for the sign-in", browserFailed);
PluginCall call = pendingAuthCall();
if (call == null) return;
call.reject("No browser could open the sign-in", "NO_BROWSER");
finishAuth(call);
}
} }
@Override @Override
@@ -331,7 +436,11 @@ public class TrekShellPlugin extends Plugin {
} }
Uri data = intent.getData(); Uri data = intent.getData();
PluginCall call = pendingAuthCall(); PluginCall call = pendingAuthCall();
if (call == null || data == null || !authScheme.equals(data.getScheme())) return; if (call == null) {
keepOrphanAuthReturn(intent);
return;
}
if (data == null || !authScheme.equals(data.getScheme())) return;
JSObject result = new JSObject(); JSObject result = new JSObject();
result.put("url", data.toString()); result.put("url", data.toString());
call.resolve(result); call.resolve(result);
@@ -357,6 +466,48 @@ public class TrekShellPlugin extends Plugin {
}, CANCEL_GRACE_MS); }, CANCEL_GRACE_MS);
} }
private static final String AUTH_PREFS = "trek_auth_return";
/** As long as the server keeps the code it carries (NATIVE_HANDOFF_CODE_TTL_MS), with room to spare. */
private static final long ORPHAN_AUTH_TTL_MS = 2 * 60 * 1000;
/**
* A return to the sign-in scheme with no call waiting for it: Android ended
* the app while the browser was in front, and the browser's return started
* it anew (the launch intent comes through here as well). Kept on disk, as
* the process may be new too, until the sign-in page takes it. The return
* is then taken off the activity's intent: Capacitor hands the launch intent
* in again whenever the activity is created, and Android does as well when
* it rebuilds the task from the recent apps.
*/
private void keepOrphanAuthReturn(Intent intent) {
Uri data = intent.getData();
if (data == null || !"auth".equals(data.getHost())) return;
if (!getContext().getString(R.string.custom_url_scheme).equals(data.getScheme())) return;
if (getActivity() != null && getActivity().getIntent() != null && getActivity().getIntent().getData() != null
&& data.equals(getActivity().getIntent().getData())) {
getActivity().setIntent(new Intent(getActivity().getIntent()).setData(null));
}
if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return;
getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE)
.edit()
.putString("url", data.toString())
.putLong("at", System.currentTimeMillis())
.commit();
}
/** The kept return of a sign-in (keepOrphanAuthReturn), once and while fresh; null otherwise. */
@PluginMethod
public void takeAuthCallback(PluginCall call) {
android.content.SharedPreferences prefs = getContext().getSharedPreferences(AUTH_PREFS, Context.MODE_PRIVATE);
String url = prefs.getString("url", null);
long at = prefs.getLong("at", 0);
prefs.edit().clear().commit();
boolean fresh = url != null && System.currentTimeMillis() - at <= ORPHAN_AUTH_TTL_MS;
JSObject result = new JSObject();
result.put("url", fresh ? url : JSONObject.NULL);
call.resolve(result);
}
private PluginCall pendingAuthCall() { private PluginCall pendingAuthCall() {
return authCallId == null ? null : getBridge().getSavedCall(authCallId); return authCallId == null ? null : getBridge().getSavedCall(authCallId);
} }
@@ -582,6 +733,12 @@ public class TrekShellPlugin extends Plugin {
com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call); com.liketrek.trek.billing.TrekBilling.INSTANCE.restore(getContext(), call);
} }
/** Whether this Google account already holds a delivered cloud subscription. */
@PluginMethod
public void storeHeld(PluginCall call) {
com.liketrek.trek.billing.TrekBilling.INSTANCE.held(getContext(), call);
}
/** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */ /** A paid subscription never delivered (it cleared while the app was closed), for the shell to redeem. */
@PluginMethod @PluginMethod
public void storeUnfinished(PluginCall call) { public void storeUnfinished(PluginCall call) {
@@ -630,4 +787,129 @@ public class TrekShellPlugin extends Plugin {
// The last layout WindowManager reported, posture and hinge included; the bare window before the first. // The last layout WindowManager reported, posture and hinge included; the bare window before the first.
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity()))); getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
} }
// ---- Client certificates (mutual TLS) ----
/**
* The address screen's check of a server, made natively so it notices
* whether the server asks for a client certificate and can show the stored
* one (clientcert/ClientCertActions.request).
*/
@PluginMethod
public void requestServer(PluginCall call) {
String url = call.getString("url");
Uri parsed = url == null ? null : Uri.parse(url);
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
call.reject("A http(s) URL is required");
return;
}
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
new Thread(() -> {
try {
call.resolve(ClientCertActions.request(getContext(), url, timeout));
} catch (Exception e) {
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
}
}, "trek-request-server").start();
}
/** The stored certificate's details, or none. */
@PluginMethod
public void clientCertificate(PluginCall call) {
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
JSObject result = new JSObject();
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
if (asked != null) {
JSObject request = new JSObject();
request.put("host", asked.getHost());
request.put("port", asked.getPort());
request.put("served", asked.getServed());
result.put("request", request);
}
call.resolve(result);
}
@PluginMethod
public void removeClientCertificate(PluginCall call) {
TrekClientIdentity.remove(getContext());
forgetCertificateAnswers();
call.resolve();
}
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
private void forgetCertificateAnswers() {
com.liketrek.trek.clientcert.ClientCertWatch.reset();
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
}
/**
* Picks a .p12/.pfx file, asks for its password until it opens or the user
* gives up, and keeps it for the server at host and port. Resolves with
* result "imported" (and the certificate), "cancelled", or "failed" and why.
*/
@PluginMethod
public void importClientCertificate(PluginCall call) {
String host = call.getString("host");
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
call.reject("A host is required");
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
startActivityForResult(call, pick, "clientCertificatePicked");
}
@ActivityCallback
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
if (call == null) return;
Uri uri = picked.getData() == null ? null : picked.getData().getData();
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
resolveImport(call, "cancelled", null);
return;
}
new Thread(() -> {
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
else askPasswordFor(call, bytes, false);
}, "trek-client-cert").start();
}
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
JSObject labels = call.getObject("labels", new JSObject());
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
if (password == null) {
resolveImport(call, "cancelled", null);
return kotlin.Unit.INSTANCE;
}
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
return kotlin.Unit.INSTANCE;
});
}
private void openWith(PluginCall call, byte[] bytes, char[] password) {
try {
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
forgetCertificateAnswers();
resolveImport(call, "imported", null);
} catch (TrekClientIdentity.ImportException e) {
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
else resolveImport(call, "failed", e.getReason().name());
} catch (Exception e) {
Log.w(TAG, "client certificate not kept", e);
resolveImport(call, "failed", "STORE");
} finally {
java.util.Arrays.fill(password, '\0');
}
}
private void resolveImport(PluginCall call, String result, String failure) {
JSObject out = new JSObject();
out.put("result", result);
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
call.resolve(out);
}
} }
@@ -1,11 +1,20 @@
package com.liketrek.trek; package com.liketrek.trek;
import android.net.Uri;
import android.util.Log;
import android.webkit.ClientCertRequest;
import android.webkit.WebResourceError; import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest; import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse; import android.webkit.WebResourceResponse;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient;
import com.getcapacitor.Bridge; import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient; import com.getcapacitor.BridgeWebViewClient;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
/** /**
* Used while the WebView shows the user's server. When that server cannot be * Used while the WebView shows the user's server. When that server cannot be
@@ -18,10 +27,92 @@ import com.getcapacitor.BridgeWebViewClient;
class TrekWebViewClient extends BridgeWebViewClient { class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable; private final Runnable onUnreachable;
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
private final Consumer<Boolean> onSignInPage;
/** Told whether the page now shown is an error page in place of the server's, for the same bar. */
private final Consumer<Boolean> onErrorPage;
/** The main-frame address that last came back as a 4xx page nobody else handled. */
private String errorPageUrl;
private final Set<String> signInHosts;
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
private boolean serverShown;
/** The page itself failed on the network once already; the next failure goes to the address screen. */
private boolean retriedMainFrame;
/** The page loaded again after a network error, until that load starts: its error page counts for nothing. */
private String retryUrl;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable) { TrekWebViewClient(
Bridge bridge,
Runnable onUnreachable,
Consumer<Boolean> onSignInPage,
Consumer<Boolean> onErrorPage,
Set<String> signInHosts
) {
super(bridge); super(bridge);
this.onUnreachable = onUnreachable; this.onUnreachable = onUnreachable;
this.onSignInPage = onSignInPage;
this.onErrorPage = onErrorPage;
this.signInHosts = new HashSet<>(signInHosts);
}
/**
* The sign-in page of a proxy in front of the server loads here rather than
* in the browser, so its session cookie is the web view's and every request
* of the app carries it, as in a browser. Only the hosts the address screen
* saw that proxy send it to (TrekServerStore.getSignInHosts), and only as a
* page: Capacitor injects its bridge into the server's origin alone, so the
* proxy's page gets no access to the app. Everything else goes on to
* Capacitor, which opens a foreign page in the browser.
*/
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false;
String learned = SignInRedirect.learnedHost(
TrekServerStore.getUrl(view.getContext()),
request.getUrl().toString(),
request.isForMainFrame(),
request.isRedirect(),
serverShown
);
if (learned != null) {
signInHosts.add(learned);
TrekServerStore.addSignInHost(view.getContext(), learned);
return false;
}
return super.shouldOverrideUrlLoading(view, request);
}
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
@Override
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
super.doUpdateVisitedHistory(view, url, isReload);
if (retryUrl != null) return;
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
// The 4xx answer arrives before this commit or after it; either way the
// flag is read once here, so the next page clears the bar again.
boolean error = url != null && url.equals(errorPageUrl);
errorPageUrl = null;
onErrorPage.accept(error);
}
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
// The error page of a load that is about to be retried: not the server's
// first page, so no welcome page and no learning of sign-in hosts yet.
if (retryUrl != null) return;
if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true;
String welcome = TrekServerStore.takeWelcomeUrl(view.getContext());
if (welcome != null) view.loadUrl(welcome);
}
private boolean isSignInHost(Uri url) {
String scheme = url.getScheme();
String host = url.getHost();
if (host == null || !("https".equals(scheme) || "http".equals(scheme))) return false;
String key = host.toLowerCase(Locale.ROOT);
return signInHosts.contains(key) || (url.getPort() != -1 && signInHosts.contains(key + ":" + url.getPort()));
} }
/** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */ /** Mapbox's map resources go through the app's own store (tiles/TrekTileStore.kt). */
@@ -34,9 +125,53 @@ class TrekWebViewClient extends BridgeWebViewClient {
return super.shouldInterceptRequest(view, request); return super.shouldInterceptRequest(view, request);
} }
/**
* A proxy in front of the server asks for a client certificate (mutual TLS).
* The stored one goes only to the host and port it was imported for; every
* other site that asks is told there is none, as a browser without a
* matching certificate would. The WebView remembers either answer for the
* host until the app restarts, which a new certificate always brings along.
*/
@Override
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
else request.ignore();
// The page may still come from the service worker's cache while every
// request fails: check natively whether the server lets the app in.
String server = TrekServerStore.getUrl(view.getContext());
Uri serverUri = server == null ? null : Uri.parse(server);
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, () -> {
TrekServerStore.setFailure(view.getContext(), "client certificate requested");
onUnreachable.run();
});
}
}
/**
* A page that failed on the network is loaded once more before the app says
* the server is unreachable. Started from the background, as when the
* browser hands a sign-in back to an app Android had ended, a Samsung phone
* may hold its network back for a moment, and the first load fails with the
* server there all along. The error is logged, as a report has nothing else.
*/
@Override @Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) { if (request.isForMainFrame()) {
Log.w("TrekShell", "Page failed: " + error.getErrorCode() + " " + error.getDescription());
if (!retriedMainFrame && isNetworkError(error.getErrorCode())) {
retriedMainFrame = true;
retryUrl = request.getUrl().toString();
view.postDelayed(() -> {
String url = retryUrl;
retryUrl = null;
view.loadUrl(url);
}, 1500);
return;
}
TrekServerStore.setFailure(view.getContext(), error.getDescription() + " (" + error.getErrorCode() + ")");
onUnreachable.run(); onUnreachable.run();
return; return;
} }
@@ -45,15 +180,37 @@ class TrekWebViewClient extends BridgeWebViewClient {
/** /**
* Only a 5xx on the page itself means the server is down (a proxy's 502 or * Only a 5xx on the page itself means the server is down (a proxy's 502 or
* 503 while TREK restarts). A 4xx page is left alone: the server answered, * 503 while TREK restarts). A 4xx page is not TREK's, which serves its UI for
* and TREK serves its UI for every other path. * every path: a proxy that asked this page for a client certificate and got
* none, as Cloudflare does with a 403, sends the app to the address screen,
* which says so and offers to add one. Any other 4xx page (a proxy's block
* page, a certificate it refused) stays up with the bar and its way out, so
* the app is never stuck on a page without TREK's UI.
*/ */
@Override @Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
int status = errorResponse.getStatusCode();
if (!request.isForMainFrame()) { if (!request.isForMainFrame()) {
super.onReceivedHttpError(view, request, errorResponse); super.onReceivedHttpError(view, request, errorResponse);
} else if (errorResponse.getStatusCode() >= 500) { } else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
TrekServerStore.setFailure(view.getContext(), "HTTP " + status);
onUnreachable.run(); onUnreachable.run();
} else if (status >= 400) {
errorPageUrl = request.getUrl().toString();
onErrorPage.accept(true);
} }
} }
private static boolean isNetworkError(int code) {
return code == WebViewClient.ERROR_HOST_LOOKUP
|| code == WebViewClient.ERROR_CONNECT
|| code == WebViewClient.ERROR_TIMEOUT
|| code == WebViewClient.ERROR_IO
|| code == WebViewClient.ERROR_UNKNOWN;
}
private static boolean wantedCertificate(Uri url) {
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
}
} }
@@ -119,23 +119,38 @@ object TrekBilling {
val id = call.getString("productId") ?: return call.reject("productId is required") val id = call.getString("productId") ?: return call.reject("productId is required")
ready(activity) { ok -> ready(activity) { ok ->
if (!ok) return@ready call.resolve(JSObject().put("kind", "failed")) if (!ok) return@ready call.resolve(JSObject().put("kind", "failed"))
details(activity, listOf(id)) { found -> // One subscription per Google account: a second plan would run beside the first.
val product = found.firstOrNull() // A delivered one stops the purchase here; one that was paid but never set up is
val offer = product?.let(::baseOffer) // handed back as bought, so its instance is set up now.
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed")) val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
pendingPurchase?.resolve(JSObject().put("kind", "cancelled")) client(activity).queryPurchasesAsync(params) { result, purchases ->
pendingPurchase = call val held = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, PREFIX) else null
pendingProduct = id when {
val params = BillingFlowParams.newBuilder() held == null -> launchPurchase(activity, call, id)
.setProductDetailsParamsList( held.isAcknowledged -> call.resolve(JSObject().put("kind", "subscribed"))
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()), else -> call.resolve(receipt(held).put("kind", "bought"))
) }
.build() }
activity.runOnUiThread { }
val launch = client(activity).launchBillingFlow(activity, params) }
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null) private fun launchPurchase(activity: Activity, call: PluginCall, id: String) {
} details(activity, listOf(id)) { found ->
val product = found.firstOrNull()
val offer = product?.let(::baseOffer)
if (product == null || offer == null) return@details call.resolve(JSObject().put("kind", "failed"))
pendingPurchase?.resolve(JSObject().put("kind", "cancelled"))
pendingPurchase = call
pendingProduct = id
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(product).setOfferToken(offer.offerToken).build()),
)
.build()
activity.runOnUiThread {
val launch = client(activity).launchBillingFlow(activity, params)
if (launch.responseCode != BillingClient.BillingResponseCode.OK) {
settle(if (launch.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) "cancelled" else "failed", null)
} }
} }
} }
@@ -208,6 +223,19 @@ object TrekBilling {
} }
} }
/** Whether this Google account holds a delivered subscription under `prefix`; while one runs, the shell offers no purchase. */
fun held(context: Context, call: PluginCall) {
val prefix = call.getString("prefix") ?: PREFIX
ready(context) { ok ->
if (!ok) return@ready call.resolve(JSObject().put("held", false))
val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
client(context).queryPurchasesAsync(params) { result, purchases ->
val found = if (result.responseCode == BillingClient.BillingResponseCode.OK) newest(purchases, prefix) else null
call.resolve(JSObject().put("held", found?.isAcknowledged == true))
}
}
}
/** The newest active subscription under `prefix` on this Google account, if any. */ /** The newest active subscription under `prefix` on this Google account, if any. */
fun restore(context: Context, call: PluginCall) { fun restore(context: Context, call: PluginCall) {
val prefix = call.getString("prefix") ?: "cloud." val prefix = call.getString("prefix") ?: "cloud."
@@ -0,0 +1,151 @@
package com.liketrek.trek.clientcert
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.net.Uri
import android.text.InputType
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import com.getcapacitor.JSObject
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
* check a server while noticing whether it asks for one, read a picked file,
* and ask for its password.
*/
object ClientCertActions {
private const val MAX_BODY_BYTES = 512 * 1024
private const val MAX_FILE_BYTES = 256 * 1024
private const val MAX_REDIRECTS = 5
/**
* A GET against the user's server for the address screen: status, body, final
* URL and headers, plus whether the server asked for a client certificate and
* whether one was shown. A failed connection answers status 0 instead of
* throwing, so a handshake the server broke off for want of a certificate
* still reports that it asked.
*/
@JvmStatic
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
var asked: Triple<String, Int, Boolean>? = null
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
val result = JSObject()
var current = URL(url)
try {
for (hop in 0..MAX_REDIRECTS) {
val connection = current.openConnection() as HttpURLConnection
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
connection.instanceFollowRedirects = false
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
try {
val status = connection.responseCode
val location = connection.getHeaderField("Location")
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
current = URL(current, location)
continue
}
val stream = if (status >= 400) connection.errorStream else connection.inputStream
result.put("status", status)
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
result.put("url", current.toString())
val headers = JSObject()
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
result.put("headers", headers)
} finally {
connection.disconnect()
}
break
}
} catch (e: IOException) {
result.put("status", 0)
result.put("data", "")
result.put("url", current.toString())
result.put("error", e.javaClass.simpleName)
}
val seen = asked
result.put("certRequested", seen != null)
result.put("certSent", seen?.third == true)
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
return result
}
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
@JvmStatic
fun readFile(context: Context, uri: Uri): ByteArray? = try {
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
} catch (e: Exception) {
null
}
/**
* The password prompt, with the UI's own words (`labels`: title, message,
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
* did not open the file. `done` gets the password, or null when cancelled.
*/
@JvmStatic
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
activity.runOnUiThread {
val input = EditText(activity).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
hint = labels.getString("placeholder") ?: ""
}
val frame = FrameLayout(activity).apply {
val pad = (20 * activity.resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
var answered = false
val dialog = AlertDialog.Builder(activity)
.setTitle(labels.getString("title"))
.setMessage(message)
.setView(frame)
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
answered = true
val text = input.text
val password = CharArray(text.length) { text[it] }
done(password)
}
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
answered = true
done(null)
}
.setOnDismissListener { if (!answered) done(null) }
.create()
dialog.show()
input.requestFocus()
}
}
/** The settings' view of the stored certificate. */
@JvmStatic
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
put("host", info.host)
put("port", info.port)
put("subject", info.subject)
put("issuer", info.issuer)
put("notAfter", info.notAfter)
}
private fun readCapped(stream: InputStream, max: Int): ByteArray {
val out = ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (out.size() < max) {
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
if (n < 0) break
out.write(buffer, 0, n)
}
return out.toByteArray()
}
}
@@ -0,0 +1,50 @@
package com.liketrek.trek.clientcert
import android.content.Context
import java.util.concurrent.atomic.AtomicLong
/**
* Notices when the user's server stops letting the app in for want of a
* client certificate: one removed, expired, or no longer trusted by the proxy.
*
* The page alone does not show it. The service worker serves TREK's start page
* from its cache, so the page loads and only its requests fail, and the UI
* reports a server it cannot reach with no way to add a certificate. So when
* the server asks for one, the app checks once natively whether TREK answers
* with what it holds. Only a check that fails at the certificate (the handshake
* broke off, or the proxy answered with its own 4xx) sends the app to the
* address screen, which says so and offers to add one. A server that merely
* asks and lets everyone in answers TREK's 200, and nothing happens.
*/
object ClientCertWatch {
private const val MIN_INTERVAL_MS = 60_000L
private const val FEATURES_PATH = "/api/health/features"
private const val TIMEOUT_MS = 8000
private val lastCheck = AtomicLong(0)
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
@JvmStatic
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
if (serverUrl == null) return
val now = System.currentTimeMillis()
val last = lastCheck.get()
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
val appContext = context.applicationContext
Thread({
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
}, "trek-client-cert-watch").start()
}
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
@JvmStatic
fun reset() {
lastCheck.set(0)
}
/** The server asked for a certificate and did not answer with TREK. */
@JvmStatic
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
}
@@ -0,0 +1,261 @@
package com.liketrek.trek.clientcert
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.io.ByteArrayInputStream
import java.io.IOException
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.UnrecoverableKeyException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509ExtendedKeyManager
/**
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
* devices that show one. The app holds at most one, for one server.
*
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
* the private key is sealed with an AES key of the Android keystore, which
* never leaves it, and stored with the certificate chain in the app's private
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
* the WebView all take, whatever its kind; a key imported into the keystore
* itself would be refused by some of them for its padding or digest rules.
*
* Only the host and port it was imported for ever see it (`identityFor`), so a
* login page, a map tile provider or liketrek.com never learns of it.
*/
object TrekClientIdentity {
private const val PREFS = "trek_client_identity"
private const val WRAP_ALIAS = "trek-client-identity-wrap"
private const val KEYMANAGER_ALIAS = "trek-client-identity"
private const val GCM_TAG_BITS = 128
/** Why a file could not be taken. */
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
class ImportException(val reason: Failure) : Exception(reason.name)
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
@Volatile private var cached: Pair<String, Identity>? = null
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
@Volatile var lastRequest: Request? = null
private set
data class Request(val host: String, val port: Int, val served: Boolean)
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
/**
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
* Throws ImportException naming what is wrong with it.
*/
@JvmStatic
@JvmOverloads
@Throws(ImportException::class)
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
val store = KeyStore.getInstance("PKCS12")
try {
store.load(ByteArrayInputStream(bytes), password)
} catch (e: IOException) {
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
} catch (e: Exception) {
throw ImportException(Failure.FORMAT)
}
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
val key = try {
store.getKey(alias, password) as? PrivateKey
} catch (e: UnrecoverableKeyException) {
throw ImportException(Failure.PASSWORD)
} ?: throw ImportException(Failure.NO_KEY)
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
val leaf = chain[0]
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
return Identity(key, chain)
}
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
private fun isPasswordFailure(e: Throwable): Boolean {
var cause: Throwable? = e
while (cause != null) {
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
cause = cause.cause
}
return false
}
/** Keeps the identity for this host and port, replacing any other. */
@JvmStatic
fun save(context: Context, identity: Identity, host: String, port: Int) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
val sealed = cipher.doFinal(identity.key.encoded)
prefs(context).edit()
.putString("host", host.lowercase(Locale.ROOT))
.putInt("port", port)
.putString("algorithm", identity.key.algorithm)
.putString("key", encode(sealed))
.putString("iv", encode(cipher.iv))
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
.apply()
cached = null
}
@JvmStatic
fun remove(context: Context) {
prefs(context).edit().clear().apply()
cached = null
}
@JvmStatic
fun info(context: Context): Info? {
val stored = stored(context) ?: return null
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
}
/** The identity for a server at this host and port, or null when there is none for it. */
@JvmStatic
fun identityFor(context: Context, host: String?, port: Int): Identity? {
val stored = stored(context) ?: return null
if (!matches(stored.host, stored.port, host, port)) return null
return opened(stored)
}
private fun opened(stored: Stored): Identity? {
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
val identity = runCatching { open(stored) }.getOrNull() ?: return null
cached = stored.sealedText to identity
return identity
}
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
@JvmStatic
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
val effective = if (port <= 0) 443 else port
return effective == storedPort
}
/** Called where a server asked for a certificate, so the address screen can offer one. */
@JvmStatic
fun noteRequest(host: String?, port: Int, served: Boolean) {
if (host == null) return
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
}
/**
* A socket factory for the app's own requests (the server check, map tiles).
* It offers the identity only when the server it is talking to asks for one
* and is the one the identity is for, and tells `onAsked` about every ask.
*/
@JvmStatic
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
val appContext = context.applicationContext
val manager = object : X509ExtendedKeyManager() {
// Decided per handshake in choose(); the key and chain are then read for
// the one alias, so parallel connections never see each other's choice.
private fun choose(host: String?, port: Int): String? {
val identity = identityFor(appContext, host, port)
if (host != null) {
noteRequest(host, port, identity != null)
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
}
return if (identity != null) KEYMANAGER_ALIAS else null
}
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
val session = (socket as? SSLSocket)?.handshakeSession
return choose(session?.peerHost, session?.peerPort ?: -1)
}
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
choose(engine?.peerHost, engine?.peerPort ?: -1)
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
override fun getPrivateKey(alias: String?): PrivateKey? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
}
val tls = SSLContext.getInstance("TLS")
tls.init(arrayOf(manager), null, null)
return tls.socketFactory
}
private fun open(stored: Stored): Identity {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
}
private fun stored(context: Context): Stored? {
val p = prefs(context)
val host = p.getString("host", null) ?: return null
val algorithm = p.getString("algorithm", null) ?: return null
val key = p.getString("key", null) ?: return null
val iv = p.getString("iv", null) ?: return null
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
if (chain.isEmpty()) return null
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
}
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
private fun wrapKey(): SecretKey {
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return generator.generateKey()
}
private fun certificate(der: ByteArray): X509Certificate =
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
/** The CN of a name, or the whole name when it has none. */
@JvmStatic
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
}
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
}
@@ -46,6 +46,7 @@ object ShareHandoff {
fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching { fun store(context: Context, item: ShareItem, tripId: Long?): Boolean = runCatching {
val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType) val meta = JSONObject().put("name", item.name).put("mimeType", item.mimeType)
if (tripId != null) meta.put("tripId", tripId) if (tripId != null) meta.put("tripId", tripId)
if (item.url != null) meta.put("url", item.url).put("title", item.title)
File(dir(context), "pending.data").writeBytes(item.data) File(dir(context), "pending.data").writeBytes(item.data)
File(dir(context), "pending.json").writeText(meta.toString()) File(dir(context), "pending.json").writeText(meta.toString())
true true
@@ -26,6 +26,11 @@ data class ShareItem(
val title: String, val title: String,
/** A host, for a page. */ /** A host, for a page. */
val source: String? = null, val source: String? = null,
/**
* The web address it came from, when it is a page or a link: the app offers to
* keep it among the trip's links as well as to read a booking out of it.
*/
val url: String? = null,
) { ) {
enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE } enum class Kind { PASS, PDF, MAIL, CALENDAR, PAGE, TEXT, IMAGE, FILE }
@@ -90,11 +95,16 @@ object ShareLoader {
return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name })) return checked(ShareItem(kind, withExtension(name, kind), normalized, data, name.substringBeforeLast('.').ifBlank { name }))
} }
/** A link becomes the page it points at; anything else, a text file the import reads. */ /**
* A link becomes the page it points at, or the bare link when the page cannot be
* read (a sign-in wall, an app-only page, no network), which the app can still
* keep among the trip's links. Anything else, a text file the import reads.
*/
internal fun fromText(text: String, subject: String?): ShareItem { internal fun fromText(text: String, subject: String?): ShareItem {
val link = firstLink(text) val link = firstLink(text)
if (link != null) { if (link != null) {
runCatching { return page(link, subject) } runCatching { return page(link, subject) }
return linkOnly(link, subject)
} }
val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim() ?: text
return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80))) return checked(ShareItem(ShareItem.Kind.TEXT, "shared-text.txt", "text/plain", text.toByteArray(), (subject ?: firstLine).take(80)))
@@ -121,12 +131,19 @@ object ShareLoader {
val html = String(data, Charsets.UTF_8) val html = String(data, Charsets.UTF_8)
val host = runCatching { Uri.parse(link).host }.getOrNull() val host = runCatching { Uri.parse(link).host }.getOrNull()
val title = pageTitle(html) ?: subject ?: host ?: "Page" val title = pageTitle(html) ?: subject ?: host ?: "Page"
return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host)) return checked(ShareItem(ShareItem.Kind.PAGE, "${slug(title)}.html", "text/html", data, title, host, link))
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
} }
/** The link alone, as a text/uri-list the app recognises as nothing to read a booking from. */
internal fun linkOnly(link: String, subject: String?): ShareItem {
// java.net.URI rather than android.net.Uri: the same host, and readable in a JVM unit test.
val host = runCatching { java.net.URI(link).host }.getOrNull()
return ShareItem(ShareItem.Kind.PAGE, "link.url", "text/uri-list", link.toByteArray(), subject ?: host ?: link, host, link)
}
/** The kind and the MIME type the import should see, from the name when the type is vague. */ /** The kind and the MIME type the import should see, from the name when the type is vague. */
internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> { internal fun classify(name: String, mime: String): Pair<ShareItem.Kind, String> {
val ext = name.substringAfterLast('.', "").lowercase() val ext = name.substringAfterLast('.', "").lowercase()
@@ -32,6 +32,10 @@ class BookingsWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = BookingsWidget() override val glanceAppWidget: GlanceAppWidget = BookingsWidget()
} }
class FlightWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = FlightWidget()
}
class TodosWidgetReceiver : TrekWidgetReceiver() { class TodosWidgetReceiver : TrekWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodosWidget() override val glanceAppWidget: GlanceAppWidget = TodosWidget()
} }
@@ -56,6 +60,7 @@ object TrekWidgetUpdater {
TripWidgetReceiver::class, TripWidgetReceiver::class,
TodayWidgetReceiver::class, TodayWidgetReceiver::class,
BookingsWidgetReceiver::class, BookingsWidgetReceiver::class,
FlightWidgetReceiver::class,
TodosWidgetReceiver::class, TodosWidgetReceiver::class,
PackingWidgetReceiver::class, PackingWidgetReceiver::class,
AtlasWidgetReceiver::class, AtlasWidgetReceiver::class,
@@ -448,6 +448,173 @@ private fun BookingRow(booking: WidgetSnapshot.Booking, labels: WidgetSnapshot.L
} }
} }
// ---- Flight ----
/**
* Where a flight stands now (as FlightMoment on iOS). What the feed saw wins: a
* flight still at the gate past its planned departure is not flown along the
* arc, and it is only landed once confirmed. Only a flight the feed never heard
* of moves on by its schedule alone; a lost one stops two hours after arrival.
*/
private class FlightMoment(f: WidgetSnapshot.Flight, nowSeconds: Long) {
val start: Long = f.depActual ?: f.depExpected ?: f.dep
val end: Long = maxOf(f.arrExpected ?: f.arr, start + 60)
private val heard = f.seen != null
val landed = f.landed || (!heard && nowSeconds >= end) || nowSeconds >= end + 7200
val airborne = !landed && (f.airborne || f.depActual != null || (!heard && nowSeconds >= start))
val ready = !landed && !airborne && nowSeconds >= start
val progress: Float = when {
landed -> 1f
airborne -> ((nowSeconds - start).toFloat() / (end - start)).coerceIn(0f, 0.98f)
else -> 0f
}
}
/** A duration in the app's words: "2 d 4 h", "1 h 5 min", "12 min". */
private fun durationText(seconds: Long, labels: WidgetSnapshot.Labels): String {
val minutes = ((seconds + 59) / 60).coerceAtLeast(1)
val days = minutes / 1440
val hours = (minutes % 1440) / 60
val rest = minutes % 60
return when {
days > 0 -> labels.durDaysHours.replace("{d}", "$days").replace("{h}", "$hours")
hours > 0 -> labels.durHoursMinutes.replace("{h}", "$hours").replace("{m}", "$rest")
else -> labels.durMinutes.replace("{m}", "$rest")
}
}
/** "Departs in 2 h 10 min", "Lands in 1 h 5 min", "Landed". */
private fun momentText(m: FlightMoment, labels: WidgetSnapshot.Labels, nowSeconds: Long): String = when {
m.landed -> labels.flightLanded
m.ready -> labels.flightReady
m.airborne -> labels.flightLands.replace("{time}", durationText(m.end - nowSeconds, labels))
else -> labels.flightDeparts.replace("{time}", durationText(m.start - nowSeconds, labels))
}
/** The delay as a chip's text and colour; nothing to say without data. */
private fun delayChip(delay: Int?, labels: WidgetSnapshot.Labels): Pair<String, ColorProvider>? = when {
delay == null -> null
delay > 5 -> labels.flightLate.replace("{m}", "$delay") to (if (delay >= 30) TrekStatus.danger else TrekStatus.pending)
delay < -5 -> labels.flightEarly.replace("{m}", "${-delay}") to TrekStatus.confirmed
else -> labels.flightOnTime to TrekStatus.confirmed
}
private const val FLIGHT_BLUE = 0xFF4A7DDB.toInt()
/**
* The trip's next flight, or the one in the air: both airports large, the
* route as an arc with the aircraft on it, the moment in words and the delay.
* The arc and the words follow the clock each time the widget is drawn.
*/
class FlightWidget : TrekWidget() {
@Composable
override fun Content(snapshot: WidgetSnapshot?) {
val context = LocalContext.current
val size = LocalSize.current
val labels = snapshot?.labels ?: return EmptyWidget(WidgetSamples.emptyText(context))
val flight = snapshot.flight
WidgetFrame(onClick = openPath(context, flight?.path ?: snapshot.trip?.path ?: "/")) {
Column(modifier = GlanceModifier.fillMaxSize()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxWidth()) {
Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 14.dp)
Spacer(GlanceModifier.width(5.dp))
Eyebrow(flight?.number ?: labels.flight, GlanceTheme.colors.primary, GlanceModifier.defaultWeight())
flight?.let { f ->
delayChip(f.delay, labels)?.let { (text, color) ->
if (size.isWide || (f.delay ?: 0) > 5) Chip(text, GlanceTheme.colors.surfaceVariant, color)
}
}
}
if (flight == null) {
Spacer(GlanceModifier.defaultWeight())
Line(labels.noFlight, 14.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium, maxLines = 2)
Spacer(GlanceModifier.defaultWeight())
} else {
FlightBody(flight, labels, size.isWide, size.isTall)
}
}
}
}
}
/**
* The origin stands on the right in a right-to-left layout, so the drawn arc is
* mirrored with it. Read from the locale the widget speaks, not from the
* Configuration, which a composition may not read directly.
*/
private fun isRtl(): Boolean =
android.text.TextUtils.getLayoutDirectionFromLocale(java.util.Locale.getDefault()) == android.view.View.LAYOUT_DIRECTION_RTL
@Composable
private fun ColumnScope.FlightBody(f: WidgetSnapshot.Flight, labels: WidgetSnapshot.Labels, wide: Boolean, tall: Boolean) {
val now = System.currentTimeMillis() / 1000
val m = FlightMoment(f, now)
val arrival = f.arrTimeExpected ?: f.arrTime
val momentColor = if (m.airborne) GlanceTheme.colors.primary else GlanceTheme.colors.onSurface
Spacer(GlanceModifier.defaultWeight())
if (!wide) Line(momentText(m, labels, now), 13.sp, momentColor, FontWeight.Bold, maxLines = 2)
Spacer(GlanceModifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = GlanceModifier.fillMaxWidth()) {
Column(modifier = GlanceModifier.defaultWeight()) {
Line(f.from, if (wide) 26.sp else 22.sp, weight = FontWeight.Bold)
if (wide) f.fromCity?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) }
}
if (wide) {
Image(
provider = ImageProvider(WidgetArt.flightArc(m.progress, FLIGHT_BLUE, android.graphics.Color.argb(90, 120, 120, 130), rtl = isRtl())),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = GlanceModifier.width(120.dp).height(26.dp),
)
}
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
Line(f.to, if (wide) 26.sp else 22.sp, weight = FontWeight.Bold)
if (wide) f.toCity?.let { Line(it, 11.sp, GlanceTheme.colors.onSurfaceVariant) }
}
}
if (!wide) {
Image(
provider = ImageProvider(WidgetArt.flightArc(m.progress, FLIGHT_BLUE, android.graphics.Color.argb(90, 120, 120, 130), rtl = isRtl())),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = GlanceModifier.fillMaxWidth().height(22.dp),
)
}
Spacer(GlanceModifier.height(4.dp))
Row(modifier = GlanceModifier.fillMaxWidth()) {
Line(f.depTime, 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold, modifier = GlanceModifier.defaultWeight())
Line(arrival, 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Bold)
}
if (wide) {
Spacer(GlanceModifier.height(8.dp))
Line(momentText(m, labels, now), 13.sp, momentColor, FontWeight.Bold)
if (m.airborne) {
Spacer(GlanceModifier.height(5.dp))
Bar(m.progress, GlanceTheme.colors.primary, GlanceTheme.colors.surfaceVariant, 5.dp)
}
}
if (tall) {
Spacer(GlanceModifier.height(10.dp))
f.aircraft?.let { FlightFact(it) }
f.fromCity?.let { from -> f.toCity?.let { to -> FlightFact("$from \u2192 $to") } }
}
Spacer(GlanceModifier.defaultWeight())
}
@Composable
private fun FlightFact(text: String) {
Row(
modifier = GlanceModifier.fillMaxWidth().background(GlanceTheme.colors.surfaceVariant).cornerRadius(12.dp)
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Flight, GlanceTheme.colors.primary, 13.dp)
Spacer(GlanceModifier.width(7.dp))
Line(text, 12.sp, GlanceTheme.colors.onSurfaceVariant, FontWeight.Medium)
}
Spacer(GlanceModifier.height(5.dp))
}
// ---- To-dos ---- // ---- To-dos ----
/** What is still open for the trip, and how far the packing has come. */ /** What is still open for the trip, and how far the packing has come. */
@@ -149,4 +149,100 @@ object WidgetArt {
} }
return bitmap return bitmap
} }
/**
* A flight's route as an arc: dashed ahead, solid where it has flown, a dot
* at either airport and an airliner riding the curve at its heading. White
* on a translucent track, like the ring; the widget tints nothing, so the
* caller passes the flight colour.
*/
fun flightArc(
progress: Float,
color: Int,
track: Int,
width: Int = 600,
height: Int = 120,
showPlane: Boolean = true,
rtl: Boolean = false,
): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
// Right to left the origin stands on the right: the whole drawing is mirrored.
if (rtl) canvas.scale(-1f, 1f, width / 2f, height / 2f)
val p = progress.coerceIn(0f, 1f)
val inset = height * 0.14f
val sx = inset
val sy = height - inset
val ex = width - inset
val ey = height - inset
val cx = width / 2f
val cy = -height * 0.45f
val path = android.graphics.Path().apply {
moveTo(sx, sy)
quadTo(cx, cy, ex, ey)
}
val line = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = height * 0.035f
this.color = track
pathEffect = android.graphics.DashPathEffect(floatArrayOf(height * 0.02f, height * 0.07f), 0f)
}
canvas.drawPath(path, line)
val measure = android.graphics.PathMeasure(path, false)
if (p > 0f) {
val flown = android.graphics.Path()
measure.getSegment(0f, measure.length * p, flown, true)
line.pathEffect = null
line.strokeWidth = height * 0.045f
line.color = color
canvas.drawPath(flown, line)
}
val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }
canvas.drawCircle(sx, sy, height * 0.05f, dot)
val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = if (p >= 1f) Paint.Style.FILL else Paint.Style.STROKE
strokeWidth = height * 0.03f
this.color = color
}
canvas.drawCircle(ex, ey, height * 0.06f, ring)
if (showPlane) {
val pos = FloatArray(2)
val tan = FloatArray(2)
measure.getPosTan(measure.length * p, pos, tan)
canvas.save()
canvas.translate(pos[0], pos[1])
canvas.rotate(Math.toDegrees(kotlin.math.atan2(tan[1].toDouble(), tan[0].toDouble())).toFloat())
val scale = height * 0.012f
canvas.scale(scale, scale)
canvas.drawCircle(0f, 0f, 13f, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = Color.WHITE })
canvas.drawPath(airliner(), Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color })
canvas.restore()
}
return bitmap
}
/** An airliner seen from above, nose along +x, about 18 units long, centred. */
private fun airliner(): android.graphics.Path = android.graphics.Path().apply {
moveTo(9f, 0f)
cubicTo(9f, -0.9f, 8.3f, -1.3f, 7.4f, -1.3f)
lineTo(3.2f, -1.3f)
lineTo(-1.6f, -7.6f)
lineTo(-3.7f, -7.6f)
lineTo(-1.4f, -1.3f)
lineTo(-5.5f, -1.3f)
lineTo(-7.2f, -3.7f)
lineTo(-8.8f, -3.7f)
lineTo(-7.8f, 0f)
lineTo(-8.8f, 3.7f)
lineTo(-7.2f, 3.7f)
lineTo(-5.5f, 1.3f)
lineTo(-1.4f, 1.3f)
lineTo(-3.7f, 7.6f)
lineTo(-1.6f, 7.6f)
lineTo(3.2f, 1.3f)
lineTo(7.4f, 1.3f)
cubicTo(8.3f, 1.3f, 9f, 0.9f, 9f, 0f)
close()
}
} }
@@ -11,6 +11,19 @@ import org.json.JSONObject
* (WidgetSnapshot.sample in TrekFamily.swift). * (WidgetSnapshot.sample in TrekFamily.swift).
*/ */
object WidgetSamples { object WidgetSamples {
/** In the air on a running trip, two days ahead before one: the same LH 716 as the iOS sample. */
private fun flightJson(running: Boolean): String {
val now = System.currentTimeMillis() / 1000
val dep = if (running) now - 4 * 3600 else now + 2 * 86_400 + 5 * 3600
val live = if (running) {
""""depActual": ${dep + 18 * 60}, "arrExpected": ${dep + 12 * 3600 + 41 * 60}, "arrTimeExpected": "08:46", "delay": 16, "aircraft": "A359 D-AIXA","""
} else {
""
}
return """{"number": "LH 716", "from": "FRA", "to": "HND", "fromCity": "Frankfurt", "toCity": "Tokyo Haneda",
"dep": $dep, "arr": ${dep + 12 * 3600 + 25 * 60}, $live "depTime": "13:05", "arrTime": "08:30", "path": "/trips/1?booking=1"}"""
}
fun emptyText(context: Context): String = context.getString(R.string.widget_empty) fun emptyText(context: Context): String = context.getString(R.string.widget_empty)
fun snapshot(running: Boolean, now: LocalDate = LocalDate.now()): WidgetSnapshot = WidgetSnapshot.fromJson(JSONObject(json(running, now))) fun snapshot(running: Boolean, now: LocalDate = LocalDate.now()): WidgetSnapshot = WidgetSnapshot.fromJson(JSONObject(json(running, now)))
@@ -37,6 +50,7 @@ object WidgetSamples {
"place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3}, "place": "Kyoto, Japan", "dayCount": 9, "placeCount": 34, "memberCount": 3},
$today $today
"weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10}, "weather": {"temp": 18, "high": 21, "low": 11, "main": "${if (running) "Clear" else "Clouds"}", "label": "Mostly sunny", "place": "Kyoto", "rain": 10},
"flight": ${flightJson(running)},
"bookings": [ "bookings": [
{"title": "LH 716", "kind": "flight", "date": "${iso(if (running) 6 else 12)}", "time": "13:05", "detail": "FRA → HND", "confirmed": true, "path": "/trips/1"}, {"title": "LH 716", "kind": "flight", "date": "${iso(if (running) 6 else 12)}", "time": "13:05", "detail": "FRA → HND", "confirmed": true, "path": "/trips/1"},
{"title": "Shinkansen Nozomi 21", "kind": "train", "date": "${iso(if (running) 0 else 13)}", "time": "09:12", "detail": "Tokyo → Kyoto", "confirmed": true, "path": "/trips/1"}, {"title": "Shinkansen Nozomi 21", "kind": "train", "date": "${iso(if (running) 0 else 13)}", "time": "09:12", "detail": "Tokyo → Kyoto", "confirmed": true, "path": "/trips/1"},
@@ -25,6 +25,8 @@ data class WidgetSnapshot(
val atlas: Atlas?, val atlas: Atlas?,
val collab: Collab?, val collab: Collab?,
val labels: Labels, val labels: Labels,
/** The trip's next flight or the one in the air; last and defaulted, so older callers keep compiling. */
val flight: Flight? = null,
) { ) {
data class Trip( data class Trip(
val title: String, val title: String,
@@ -105,6 +107,39 @@ data class WidgetSnapshot(
data class Collab(val members: List<Member>, val messages: List<Message>, val poll: Poll?, val path: String) data class Collab(val members: List<Member>, val messages: List<Message>, val poll: Poll?, val path: String)
/**
* The flight widget's flight. Times are epoch seconds, so the widget moves on
* with the clock between two snapshots; the clock times at either airport
* come formatted, since the widget has no zone data.
*/
data class Flight(
val number: String,
val from: String,
val to: String,
val fromCity: String?,
val toCity: String?,
val dep: Long,
val arr: Long,
/** Observed take-off. */
val depActual: Long?,
/** Before take-off: the planned departure plus the delay seen so far. */
val depExpected: Long?,
/** The feed saw the aircraft in the air when the app last looked. */
val airborne: Boolean,
/** Touchdown once landed, else the estimate while in the air. */
val arrExpected: Long?,
val landed: Boolean,
val depTime: String,
val arrTime: String,
val arrTimeExpected: String?,
/** Minutes late against the schedule, negative when early. */
val delay: Int?,
val aircraft: String?,
/** When the feed last heard the aircraft; null for a flight it never heard of. */
val seen: Long?,
val path: String,
)
/** The labels, translated by the app. The optional ones fall back to English. */ /** The labels, translated by the app. The optional ones fall back to English. */
data class Labels(private val raw: JSONObject) { data class Labels(private val raw: JSONObject) {
val nextTrip get() = text("nextTrip", "Next trip") val nextTrip get() = text("nextTrip", "Next trip")
@@ -137,6 +172,23 @@ data class WidgetSnapshot(
val votes get() = text("votes", "votes") val votes get() = text("votes", "votes")
val noMessages get() = text("noMessages", "No messages yet") val noMessages get() = text("noMessages", "No messages yet")
val departure get() = text("departure", "Departure") val departure get() = text("departure", "Departure")
val flight get() = text("flight", "Flight")
/** Contains "{time}". */
val flightDeparts get() = text("flightDeparts", "Departs in {time}")
/** Contains "{time}". */
val flightLands get() = text("flightLands", "Lands in {time}")
val flightLanded get() = text("flightLanded", "Landed")
val flightReady get() = text("flightReady", "Ready for departure")
val noFlight get() = text("noFlight", "No flight ahead")
val flightOnTime get() = text("flightOnTime", "On time")
/** Contains "{m}". */
val flightLate get() = text("flightLate", "+{m} min")
/** Contains "{m}". */
val flightEarly get() = text("flightEarly", "{m} min early")
/** Contain "{d}" and "{h}", "{h}" and "{m}", and "{m}". */
val durDaysHours get() = text("durDaysHours", "{d} d {h} h")
val durHoursMinutes get() = text("durHoursMinutes", "{h} h {m} min")
val durMinutes get() = text("durMinutes", "{m} min")
private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback } private fun text(key: String, fallback: String): String = raw.optString(key).ifBlank { fallback }
} }
@@ -240,6 +292,29 @@ data class WidgetSnapshot(
) )
}, },
labels = Labels(o.optJSONObject("labels") ?: JSONObject()), labels = Labels(o.optJSONObject("labels") ?: JSONObject()),
flight = o.optJSONObject("flight")?.let { f ->
Flight(
number = f.optString("number"),
from = f.optString("from"),
to = f.optString("to"),
fromCity = f.str("fromCity"),
toCity = f.str("toCity"),
dep = f.optLong("dep"),
arr = f.optLong("arr"),
depActual = f.long("depActual"),
depExpected = f.long("depExpected"),
airborne = f.optBoolean("airborne"),
arrExpected = f.long("arrExpected"),
landed = f.optBoolean("landed"),
depTime = f.optString("depTime"),
arrTime = f.optString("arrTime"),
arrTimeExpected = f.str("arrTimeExpected"),
delay = f.int("delay"),
aircraft = f.str("aircraft"),
seen = f.long("seen"),
path = f.optString("path", "/"),
)
}?.takeIf { it.dep > 0 && it.arr > 0 },
) )
fun day(iso: String?): LocalDate? { fun day(iso: String?): LocalDate? {
@@ -258,6 +333,8 @@ data class WidgetSnapshot(
private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null private fun JSONObject.int(key: String): Int? = if (has(key) && !isNull(key)) optInt(key) else null
private fun JSONObject.long(key: String): Long? = if (has(key) && !isNull(key)) optLong(key) else null
private fun JSONArray?.objects(): List<JSONObject> = private fun JSONArray?.objects(): List<JSONObject> =
if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) } if (this == null) emptyList() else (0 until length()).mapNotNull { optJSONObject(it) }
@@ -8,6 +8,8 @@
<string name="widget_today_description">خطة اليوم كخط زمني، مع الطقس والمحطة التالية.</string> <string name="widget_today_description">خطة اليوم كخط زمني، مع الطقس والمحطة التالية.</string>
<string name="widget_bookings_name">الحجوزات</string> <string name="widget_bookings_name">الحجوزات</string>
<string name="widget_bookings_description">رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.</string> <string name="widget_bookings_description">رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.</string>
<string name="widget_flight_name">الرحلة</string>
<string name="widget_flight_description">رحلتك القادمة مباشرة: عد تنازلي للإقلاع، والطائرة في طريقها، والتأخير والوصول.</string>
<string name="widget_todos_name">المهام</string> <string name="widget_todos_name">المهام</string>
<string name="widget_todos_description">ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.</string> <string name="widget_todos_description">ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.</string>
<string name="widget_packing_name">قائمة الأمتعة</string> <string name="widget_packing_name">قائمة الأمتعة</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.</string> <string name="widget_today_description">Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.</string>
<string name="widget_bookings_name">Bronlar</string> <string name="widget_bookings_name">Bronlar</string>
<string name="widget_bookings_description">Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.</string> <string name="widget_bookings_description">Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.</string>
<string name="widget_flight_name">Uçuş</string>
<string name="widget_flight_description">Növbəti uçuşun canlı: uçuşa qədər geri sayım, yolda olan təyyarə, gecikmə və gəliş.</string>
<string name="widget_todos_name">Tapşırıqlar</string> <string name="widget_todos_name">Tapşırıqlar</string>
<string name="widget_todos_description">Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.</string> <string name="widget_todos_description">Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.</string>
<string name="widget_packing_name">Yük siyahısı</string> <string name="widget_packing_name">Yük siyahısı</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">以时间线显示今天的计划,附带天气和下一站。</string> <string name="widget_today_description">以时间线显示今天的计划,附带天气和下一站。</string>
<string name="widget_bookings_name">预订</string> <string name="widget_bookings_name">预订</string>
<string name="widget_bookings_description">你接下来的航班、火车、酒店和餐厅,以及时间。</string> <string name="widget_bookings_description">你接下来的航班、火车、酒店和餐厅,以及时间。</string>
<string name="widget_flight_name">航班</string>
<string name="widget_flight_description">实时查看下一班航班:起飞倒计时、飞行中的飞机、延误和到达。</string>
<string name="widget_todos_name">待办</string> <string name="widget_todos_name">待办</string>
<string name="widget_todos_description">旅行还剩哪些事没做,以及行李收拾到哪一步。</string> <string name="widget_todos_description">旅行还剩哪些事没做,以及行李收拾到哪一步。</string>
<string name="widget_packing_name">行李</string> <string name="widget_packing_name">行李</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">以時間軸顯示今天的計畫,附上天氣與下一站。</string> <string name="widget_today_description">以時間軸顯示今天的計畫,附上天氣與下一站。</string>
<string name="widget_bookings_name">預訂</string> <string name="widget_bookings_name">預訂</string>
<string name="widget_bookings_description">你接下來的航班、火車、飯店與餐廳,以及時間。</string> <string name="widget_bookings_description">你接下來的航班、火車、飯店與餐廳,以及時間。</string>
<string name="widget_flight_name">航班</string>
<string name="widget_flight_description">即時查看下一班航班:起飛倒數、飛行中的飛機、延誤和抵達。</string>
<string name="widget_todos_name">待辦</string> <string name="widget_todos_name">待辦</string>
<string name="widget_todos_description">旅行還剩哪些事沒做,以及行李整理到哪一步。</string> <string name="widget_todos_description">旅行還剩哪些事沒做,以及行李整理到哪一步。</string>
<string name="widget_packing_name">行李</string> <string name="widget_packing_name">行李</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">El pla del dia com a línia de temps, amb el temps i la propera parada.</string> <string name="widget_today_description">El pla del dia com a línia de temps, amb el temps i la propera parada.</string>
<string name="widget_bookings_name">Reserves</string> <string name="widget_bookings_name">Reserves</string>
<string name="widget_bookings_description">Els teus propers vols, trens, hotels i taules, i quan toquen.</string> <string name="widget_bookings_description">Els teus propers vols, trens, hotels i taules, i quan toquen.</string>
<string name="widget_flight_name">Vol</string>
<string name="widget_flight_description">El teu proper vol en directe: compte enrere de la sortida, l’avió en camí, retard i arribada.</string>
<string name="widget_todos_name">Tasques</string> <string name="widget_todos_name">Tasques</string>
<string name="widget_todos_description">El que encara queda per al viatge i com va l\'equipatge.</string> <string name="widget_todos_description">El que encara queda per al viatge i com va l\'equipatge.</string>
<string name="widget_packing_name">Equipatge</string> <string name="widget_packing_name">Equipatge</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Plán dne jako časová osa, s počasím a další zastávkou.</string> <string name="widget_today_description">Plán dne jako časová osa, s počasím a další zastávkou.</string>
<string name="widget_bookings_name">Rezervace</string> <string name="widget_bookings_name">Rezervace</string>
<string name="widget_bookings_description">Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.</string> <string name="widget_bookings_description">Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.</string>
<string name="widget_flight_name">Let</string>
<string name="widget_flight_description">Tvůj další let živě: odpočet do odletu, letadlo na cestě, zpoždění a přílet.</string>
<string name="widget_todos_name">Úkoly</string> <string name="widget_todos_name">Úkoly</string>
<string name="widget_todos_description">Co ještě zbývá k cestě a jak daleko je balení.</string> <string name="widget_todos_description">Co ještě zbývá k cestě a jak daleko je balení.</string>
<string name="widget_packing_name">Balení</string> <string name="widget_packing_name">Balení</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.</string> <string name="widget_today_description">Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.</string>
<string name="widget_bookings_name">Buchungen</string> <string name="widget_bookings_name">Buchungen</string>
<string name="widget_bookings_description">Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.</string> <string name="widget_bookings_description">Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.</string>
<string name="widget_flight_name">Flug</string>
<string name="widget_flight_description">Dein nächster Flug live: Countdown bis zum Abflug, die Maschine unterwegs, Verspätung und Ankunft.</string>
<string name="widget_todos_name">To-dos</string> <string name="widget_todos_name">To-dos</string>
<string name="widget_todos_description">Was für die Reise noch offen ist und wie weit das Packen ist.</string> <string name="widget_todos_description">Was für die Reise noch offen ist und wie weit das Packen ist.</string>
<string name="widget_packing_name">Packliste</string> <string name="widget_packing_name">Packliste</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.</string> <string name="widget_today_description">Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.</string>
<string name="widget_bookings_name">Κρατήσεις</string> <string name="widget_bookings_name">Κρατήσεις</string>
<string name="widget_bookings_description">Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.</string> <string name="widget_bookings_description">Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.</string>
<string name="widget_flight_name">Πτήση</string>
<string name="widget_flight_description">Η επόμενη πτήση σου ζωντανά: αντίστροφη μέτρηση για την αναχώρηση, το αεροσκάφος καθ’ οδόν, καθυστέρηση και άφιξη.</string>
<string name="widget_todos_name">Εκκρεμότητες</string> <string name="widget_todos_name">Εκκρεμότητες</string>
<string name="widget_todos_description">Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.</string> <string name="widget_todos_description">Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.</string>
<string name="widget_packing_name">Αποσκευές</string> <string name="widget_packing_name">Αποσκευές</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">El plan del día como línea de tiempo, con el tiempo y la próxima parada.</string> <string name="widget_today_description">El plan del día como línea de tiempo, con el tiempo y la próxima parada.</string>
<string name="widget_bookings_name">Reservas</string> <string name="widget_bookings_name">Reservas</string>
<string name="widget_bookings_description">Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.</string> <string name="widget_bookings_description">Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.</string>
<string name="widget_flight_name">Vuelo</string>
<string name="widget_flight_description">Tu próximo vuelo en directo: cuenta atrás de la salida, el avión en camino, retraso y llegada.</string>
<string name="widget_todos_name">Tareas</string> <string name="widget_todos_name">Tareas</string>
<string name="widget_todos_description">Lo que aún queda para el viaje y cómo va la maleta.</string> <string name="widget_todos_description">Lo que aún queda para el viaje y cómo va la maleta.</string>
<string name="widget_packing_name">Equipaje</string> <string name="widget_packing_name">Equipaje</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.</string> <string name="widget_today_description">Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.</string>
<string name="widget_bookings_name">Broneeringud</string> <string name="widget_bookings_name">Broneeringud</string>
<string name="widget_bookings_description">Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.</string> <string name="widget_bookings_description">Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.</string>
<string name="widget_flight_name">Lend</string>
<string name="widget_flight_description">Sinu järgmine lend otse: loendus väljumiseni, lennuk teel, hilinemine ja saabumine.</string>
<string name="widget_todos_name">Ülesanded</string> <string name="widget_todos_name">Ülesanded</string>
<string name="widget_todos_description">Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.</string> <string name="widget_todos_description">Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.</string>
<string name="widget_packing_name">Pakkimine</string> <string name="widget_packing_name">Pakkimine</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Le programme du jour en frise, avec la météo et la prochaine étape.</string> <string name="widget_today_description">Le programme du jour en frise, avec la météo et la prochaine étape.</string>
<string name="widget_bookings_name">Réservations</string> <string name="widget_bookings_name">Réservations</string>
<string name="widget_bookings_description">Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.</string> <string name="widget_bookings_description">Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.</string>
<string name="widget_flight_name">Vol</string>
<string name="widget_flight_description">Votre prochain vol en direct : compte à rebours du départ, l’avion en route, retard et arrivée.</string>
<string name="widget_todos_name">À faire</string> <string name="widget_todos_name">À faire</string>
<string name="widget_todos_description">Ce qui reste à faire pour le voyage et où en sont les bagages.</string> <string name="widget_todos_description">Ce qui reste à faire pour le voyage et où en sont les bagages.</string>
<string name="widget_packing_name">Bagages</string> <string name="widget_packing_name">Bagages</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">A nap terve idővonalon, az időjárással és a következő megállóval.</string> <string name="widget_today_description">A nap terve idővonalon, az időjárással és a következő megállóval.</string>
<string name="widget_bookings_name">Foglalások</string> <string name="widget_bookings_name">Foglalások</string>
<string name="widget_bookings_description">A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.</string> <string name="widget_bookings_description">A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.</string>
<string name="widget_flight_name">Járat</string>
<string name="widget_flight_description">A következő járatod élőben: visszaszámlálás az indulásig, a gép útközben, késés és érkezés.</string>
<string name="widget_todos_name">Teendők</string> <string name="widget_todos_name">Teendők</string>
<string name="widget_todos_description">Ami még hátravan az utazáshoz, és hol tart a csomagolás.</string> <string name="widget_todos_description">Ami még hátravan az utazáshoz, és hol tart a csomagolás.</string>
<string name="widget_packing_name">Csomagolás</string> <string name="widget_packing_name">Csomagolás</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.</string> <string name="widget_today_description">Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.</string>
<string name="widget_bookings_name">Pemesanan</string> <string name="widget_bookings_name">Pemesanan</string>
<string name="widget_bookings_description">Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.</string> <string name="widget_bookings_description">Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.</string>
<string name="widget_flight_name">Penerbangan</string>
<string name="widget_flight_description">Penerbangan berikutnya secara langsung: hitung mundur keberangkatan, pesawat dalam perjalanan, keterlambatan, dan kedatangan.</string>
<string name="widget_todos_name">Tugas</string> <string name="widget_todos_name">Tugas</string>
<string name="widget_todos_description">Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.</string> <string name="widget_todos_description">Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.</string>
<string name="widget_packing_name">Barang bawaan</string> <string name="widget_packing_name">Barang bawaan</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.</string> <string name="widget_today_description">Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.</string>
<string name="widget_bookings_name">Prenotazioni</string> <string name="widget_bookings_name">Prenotazioni</string>
<string name="widget_bookings_description">I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.</string> <string name="widget_bookings_description">I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.</string>
<string name="widget_flight_name">Volo</string>
<string name="widget_flight_description">Il tuo prossimo volo in tempo reale: conto alla rovescia per la partenza, l’aereo in viaggio, ritardo e arrivo.</string>
<string name="widget_todos_name">Da fare</string> <string name="widget_todos_name">Da fare</string>
<string name="widget_todos_description">Cosa manca ancora per il viaggio e a che punto è la valigia.</string> <string name="widget_todos_description">Cosa manca ancora per il viaggio e a che punto è la valigia.</string>
<string name="widget_packing_name">Bagagli</string> <string name="widget_packing_name">Bagagli</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.</string> <string name="widget_today_description">תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.</string>
<string name="widget_bookings_name">הזמנות</string> <string name="widget_bookings_name">הזמנות</string>
<string name="widget_bookings_description">הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.</string> <string name="widget_bookings_description">הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.</string>
<string name="widget_flight_name">טיסה</string>
<string name="widget_flight_description">הטיסה הבאה שלך בזמן אמת: ספירה לאחור עד ההמראה, המטוס בדרך, עיכוב והגעה.</string>
<string name="widget_todos_name">משימות</string> <string name="widget_todos_name">משימות</string>
<string name="widget_todos_description">מה עוד פתוח לטיול וכמה התקדמה האריזה.</string> <string name="widget_todos_description">מה עוד פתוח לטיול וכמה התקדמה האריזה.</string>
<string name="widget_packing_name">אריזה</string> <string name="widget_packing_name">אריזה</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">今日の予定をタイムラインで、天気と次の予定とともに表示します。</string> <string name="widget_today_description">今日の予定をタイムラインで、天気と次の予定とともに表示します。</string>
<string name="widget_bookings_name">予約</string> <string name="widget_bookings_name">予約</string>
<string name="widget_bookings_description">次のフライト、列車、ホテル、レストランとその日時。</string> <string name="widget_bookings_description">次のフライト、列車、ホテル、レストランとその日時。</string>
<string name="widget_flight_name">フライト</string>
<string name="widget_flight_description">次のフライトをライブで:出発までのカウントダウン、飛行中の機体、遅延と到着。</string>
<string name="widget_todos_name">やること</string> <string name="widget_todos_name">やること</string>
<string name="widget_todos_description">旅行のために残っていることと、荷造りの進み具合。</string> <string name="widget_todos_description">旅行のために残っていることと、荷造りの進み具合。</string>
<string name="widget_packing_name">持ち物</string> <string name="widget_packing_name">持ち物</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.</string> <string name="widget_today_description">오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.</string>
<string name="widget_bookings_name">예약</string> <string name="widget_bookings_name">예약</string>
<string name="widget_bookings_description">다가오는 항공편, 기차, 호텔, 식당과 그 시간.</string> <string name="widget_bookings_description">다가오는 항공편, 기차, 호텔, 식당과 그 시간.</string>
<string name="widget_flight_name">항공편</string>
<string name="widget_flight_description">다음 항공편을 실시간으로: 출발 카운트다운, 비행 중인 항공기, 지연과 도착.</string>
<string name="widget_todos_name">할 일</string> <string name="widget_todos_name">할 일</string>
<string name="widget_todos_description">여행을 위해 남은 일과 짐 싸기 진행 상황.</string> <string name="widget_todos_description">여행을 위해 남은 일과 짐 싸기 진행 상황.</string>
<string name="widget_packing_name">짐 싸기</string> <string name="widget_packing_name">짐 싸기</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Het dagplan als tijdlijn, met het weer en de volgende stop.</string> <string name="widget_today_description">Het dagplan als tijdlijn, met het weer en de volgende stop.</string>
<string name="widget_bookings_name">Boekingen</string> <string name="widget_bookings_name">Boekingen</string>
<string name="widget_bookings_description">Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.</string> <string name="widget_bookings_description">Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.</string>
<string name="widget_flight_name">Vlucht</string>
<string name="widget_flight_description">Je volgende vlucht live: aftellen tot vertrek, het toestel onderweg, vertraging en aankomst.</string>
<string name="widget_todos_name">Taken</string> <string name="widget_todos_name">Taken</string>
<string name="widget_todos_description">Wat er nog openstaat voor de reis en hoe ver het inpakken is.</string> <string name="widget_todos_description">Wat er nog openstaat voor de reis en hoe ver het inpakken is.</string>
<string name="widget_packing_name">Bagage</string> <string name="widget_packing_name">Bagage</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.</string> <string name="widget_today_description">Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.</string>
<string name="widget_bookings_name">Rezerwacje</string> <string name="widget_bookings_name">Rezerwacje</string>
<string name="widget_bookings_description">Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.</string> <string name="widget_bookings_description">Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.</string>
<string name="widget_flight_name">Lot</string>
<string name="widget_flight_description">Twój następny lot na żywo: odliczanie do odlotu, samolot w drodze, opóźnienie i przylot.</string>
<string name="widget_todos_name">Zadania</string> <string name="widget_todos_name">Zadania</string>
<string name="widget_todos_description">Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.</string> <string name="widget_todos_description">Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.</string>
<string name="widget_packing_name">Pakowanie</string> <string name="widget_packing_name">Pakowanie</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">O plano do dia como linha do tempo, com o clima e a próxima parada.</string> <string name="widget_today_description">O plano do dia como linha do tempo, com o clima e a próxima parada.</string>
<string name="widget_bookings_name">Reservas</string> <string name="widget_bookings_name">Reservas</string>
<string name="widget_bookings_description">Seus próximos voos, trens, hotéis e mesas, e quando acontecem.</string> <string name="widget_bookings_description">Seus próximos voos, trens, hotéis e mesas, e quando acontecem.</string>
<string name="widget_flight_name">Voo</string>
<string name="widget_flight_description">Seu próximo voo ao vivo: contagem regressiva para a partida, o avião a caminho, atraso e chegada.</string>
<string name="widget_todos_name">Tarefas</string> <string name="widget_todos_name">Tarefas</string>
<string name="widget_todos_description">O que ainda falta para a viagem e quanto da mala já está pronto.</string> <string name="widget_todos_description">O que ainda falta para a viagem e quanto da mala já está pronto.</string>
<string name="widget_packing_name">Bagagem</string> <string name="widget_packing_name">Bagagem</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">План дня в виде ленты времени, с погодой и следующей остановкой.</string> <string name="widget_today_description">План дня в виде ленты времени, с погодой и следующей остановкой.</string>
<string name="widget_bookings_name">Бронирования</string> <string name="widget_bookings_name">Бронирования</string>
<string name="widget_bookings_description">Ближайшие рейсы, поезда, отели и столики и когда они.</string> <string name="widget_bookings_description">Ближайшие рейсы, поезда, отели и столики и когда они.</string>
<string name="widget_flight_name">Рейс</string>
<string name="widget_flight_description">Ваш следующий рейс в реальном времени: отсчёт до вылета, самолёт в пути, задержка и прибытие.</string>
<string name="widget_todos_name">Задачи</string> <string name="widget_todos_name">Задачи</string>
<string name="widget_todos_description">Что ещё осталось сделать к поездке и как идут сборы.</string> <string name="widget_todos_description">Что ещё осталось сделать к поездке и как идут сборы.</string>
<string name="widget_packing_name">Сборы</string> <string name="widget_packing_name">Сборы</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Plán dňa ako časová os, s počasím a ďalšou zastávkou.</string> <string name="widget_today_description">Plán dňa ako časová os, s počasím a ďalšou zastávkou.</string>
<string name="widget_bookings_name">Rezervácie</string> <string name="widget_bookings_name">Rezervácie</string>
<string name="widget_bookings_description">Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.</string> <string name="widget_bookings_description">Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.</string>
<string name="widget_flight_name">Let</string>
<string name="widget_flight_description">Tvoj ďalší let naživo: odpočet do odletu, lietadlo na ceste, meškanie a prílet.</string>
<string name="widget_todos_name">Úlohy</string> <string name="widget_todos_name">Úlohy</string>
<string name="widget_todos_description">Čo ešte zostáva na cestu a ako ďaleko je balenie.</string> <string name="widget_todos_description">Čo ešte zostáva na cestu a ako ďaleko je balenie.</string>
<string name="widget_packing_name">Balenie</string> <string name="widget_packing_name">Balenie</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Dagens plan som tidslinje, med vädret och nästa stopp.</string> <string name="widget_today_description">Dagens plan som tidslinje, med vädret och nästa stopp.</string>
<string name="widget_bookings_name">Bokningar</string> <string name="widget_bookings_name">Bokningar</string>
<string name="widget_bookings_description">Dina nästa flyg, tåg, hotell och bord, och när de är.</string> <string name="widget_bookings_description">Dina nästa flyg, tåg, hotell och bord, och när de är.</string>
<string name="widget_flight_name">Flyg</string>
<string name="widget_flight_description">Ditt nästa flyg live: nedräkning till avgång, planet på väg, försening och ankomst.</string>
<string name="widget_todos_name">Att göra</string> <string name="widget_todos_name">Att göra</string>
<string name="widget_todos_description">Vad som återstår inför resan och hur långt packningen har kommit.</string> <string name="widget_todos_description">Vad som återstår inför resan och hur långt packningen har kommit.</string>
<string name="widget_packing_name">Packning</string> <string name="widget_packing_name">Packning</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป</string> <string name="widget_today_description">แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป</string>
<string name="widget_bookings_name">การจอง</string> <string name="widget_bookings_name">การจอง</string>
<string name="widget_bookings_description">เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ</string> <string name="widget_bookings_description">เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ</string>
<string name="widget_flight_name">เที่ยวบิน</string>
<string name="widget_flight_description">เที่ยวบินถัดไปแบบสด: นับถอยหลังถึงเวลาออกเดินทาง เครื่องบินระหว่างทาง ความล่าช้า และเวลาถึง</string>
<string name="widget_todos_name">สิ่งที่ต้องทำ</string> <string name="widget_todos_name">สิ่งที่ต้องทำ</string>
<string name="widget_todos_description">สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า</string> <string name="widget_todos_description">สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า</string>
<string name="widget_packing_name">จัดกระเป๋า</string> <string name="widget_packing_name">จัดกระเป๋า</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.</string> <string name="widget_today_description">Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.</string>
<string name="widget_bookings_name">Rezervasyonlar</string> <string name="widget_bookings_name">Rezervasyonlar</string>
<string name="widget_bookings_description">Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.</string> <string name="widget_bookings_description">Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.</string>
<string name="widget_flight_name">Uçuş</string>
<string name="widget_flight_description">Bir sonraki uçuşun canlı: kalkışa geri sayım, yoldaki uçak, gecikme ve varış.</string>
<string name="widget_todos_name">Yapılacaklar</string> <string name="widget_todos_name">Yapılacaklar</string>
<string name="widget_todos_description">Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.</string> <string name="widget_todos_description">Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.</string>
<string name="widget_packing_name">Bavul</string> <string name="widget_packing_name">Bavul</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">План дня у вигляді стрічки часу, з погодою і наступною зупинкою.</string> <string name="widget_today_description">План дня у вигляді стрічки часу, з погодою і наступною зупинкою.</string>
<string name="widget_bookings_name">Бронювання</string> <string name="widget_bookings_name">Бронювання</string>
<string name="widget_bookings_description">Найближчі рейси, поїзди, готелі й столики та коли вони.</string> <string name="widget_bookings_description">Найближчі рейси, поїзди, готелі й столики та коли вони.</string>
<string name="widget_flight_name">Рейс</string>
<string name="widget_flight_description">Ваш наступний рейс наживо: відлік до вильоту, літак у дорозі, затримка й прибуття.</string>
<string name="widget_todos_name">Завдання</string> <string name="widget_todos_name">Завдання</string>
<string name="widget_todos_description">Що ще лишилося зробити до подорожі і як просувається пакування.</string> <string name="widget_todos_description">Що ще лишилося зробити до подорожі і як просувається пакування.</string>
<string name="widget_packing_name">Пакування</string> <string name="widget_packing_name">Пакування</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.</string> <string name="widget_today_description">Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.</string>
<string name="widget_bookings_name">Đặt chỗ</string> <string name="widget_bookings_name">Đặt chỗ</string>
<string name="widget_bookings_description">Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.</string> <string name="widget_bookings_description">Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.</string>
<string name="widget_flight_name">Chuyến bay</string>
<string name="widget_flight_description">Chuyến bay tiếp theo trực tiếp: đếm ngược giờ khởi hành, máy bay đang bay, độ trễ và giờ đến.</string>
<string name="widget_todos_name">Việc cần làm</string> <string name="widget_todos_name">Việc cần làm</string>
<string name="widget_todos_description">Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.</string> <string name="widget_todos_description">Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.</string>
<string name="widget_packing_name">Hành lý</string> <string name="widget_packing_name">Hành lý</string>
@@ -8,6 +8,8 @@
<string name="widget_today_description">Today\'s plan as a timeline, with the weather and what is next.</string> <string name="widget_today_description">Today\'s plan as a timeline, with the weather and what is next.</string>
<string name="widget_bookings_name">Bookings</string> <string name="widget_bookings_name">Bookings</string>
<string name="widget_bookings_description">Your next flights, trains, hotels and tables, and when they are due.</string> <string name="widget_bookings_description">Your next flights, trains, hotels and tables, and when they are due.</string>
<string name="widget_flight_name">Flight</string>
<string name="widget_flight_description">Your next flight live: departure countdown, the aircraft on its way, delay and arrival.</string>
<string name="widget_todos_name">To-dos</string> <string name="widget_todos_name">To-dos</string>
<string name="widget_todos_description">What is still open for the trip, and how far the packing has come.</string> <string name="widget_todos_description">What is still open for the trip, and how far the packing has come.</string>
<string name="widget_packing_name">Packing</string> <string name="widget_packing_name">Packing</string>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The flight widget (widget/TrekWidgets.kt). Sizes in cells for Android 12+, dp before.
No previewImage: Glance draws the picker preview from the sample trip (providePreview). -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_flight_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp"
android:minHeight="110dp"
android:minResizeWidth="110dp"
android:minResizeHeight="110dp"
android:maxResizeWidth="530dp"
android:maxResizeHeight="450dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:configure="com.liketrek.trek.widget.WidgetConfigActivity"
android:widgetFeatures="reconfigurable|configuration_optional" />
@@ -0,0 +1,89 @@
package com.liketrek.trek
import com.liketrek.trek.clientcert.ClientCertWatch
import com.liketrek.trek.clientcert.TrekClientIdentity
import com.liketrek.trek.clientcert.TrekClientIdentity.Failure
import java.util.Date
import javax.security.auth.x500.X500Principal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/**
* Reading a client certificate file (TrekClientIdentity.read) and the rule for
* which server sees it. The files in test/resources/clientcert are test-only:
* a throwaway CA, password "trek-test". valid.p12 has OpenSSL 3's default
* encryption (AES-256, PBKDF2), valid-legacy.p12 the older RC2/3DES one that
* many routers and NAS systems still export.
*/
class ClientIdentityTest {
private val password = "trek-test".toCharArray()
private val inside = Date(1_800_000_000_000L) // 2027-01-15, inside every valid test certificate
private fun file(name: String): ByteArray =
requireNotNull(javaClass.classLoader?.getResourceAsStream("clientcert/$name")) { name }.use { it.readBytes() }
private fun failure(name: String, pass: CharArray = password, now: Date = inside): Failure {
try {
TrekClientIdentity.read(file(name), pass, now)
} catch (e: TrekClientIdentity.ImportException) {
return e.reason
}
fail("$name opened")
throw AssertionError()
}
@Test
fun opensModernAndLegacyFiles() {
for (name in listOf("valid.p12", "valid-legacy.p12")) {
val identity = TrekClientIdentity.read(file(name), password, inside)
assertEquals("trek-test-valid", TrekClientIdentity.commonName(identity.chain[0].subjectX500Principal))
assertEquals("TREK mTLS Test CA", TrekClientIdentity.commonName(identity.chain[0].issuerX500Principal))
assertEquals("RSA", identity.key.algorithm)
}
}
@Test
fun namesWhatIsWrongWithAFile() {
assertEquals(Failure.PASSWORD, failure("valid.p12", "wrong".toCharArray()))
assertEquals(Failure.PASSWORD, failure("valid-legacy.p12", "wrong".toCharArray()))
assertEquals(Failure.EXPIRED, failure("expired.p12"))
assertEquals(Failure.NOT_YET_VALID, failure("future.p12"))
assertEquals(Failure.NO_KEY, failure("cert-only.p12"))
try {
TrekClientIdentity.read("not a certificate".toByteArray(), password, inside)
fail("text opened")
} catch (e: TrekClientIdentity.ImportException) {
assertEquals(Failure.FORMAT, e.reason)
}
}
@Test
fun showsTheCertificateOnlyToItsHostAndPort() {
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "Trek.Example.com", -1))
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 443))
assertTrue(TrekClientIdentity.matches("nas.local", 8443, "nas.local", 8443))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "auth.example.com", 443))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 8443))
assertFalse(TrekClientIdentity.matches("nas.local", 8443, "nas.local", -1))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, null, 443))
}
@Test
fun leavesForTheAddressScreenOnlyWhenTheCertificateIsWhatFails() {
assertTrue(ClientCertWatch.refused(true, 0)) // handshake broken off: nginx, Caddy, Traefik
assertTrue(ClientCertWatch.refused(true, 403)) // Cloudflare's block page
assertTrue(ClientCertWatch.refused(true, 400)) // nginx without a valid certificate
assertFalse(ClientCertWatch.refused(true, 200)) // asks, but lets everyone in
assertFalse(ClientCertWatch.refused(true, 502)) // TREK restarting behind the proxy
assertFalse(ClientCertWatch.refused(false, 0)) // simply offline
}
@Test
fun readsTheCommonNameOrFallsBackToTheWholeName() {
assertEquals("Phone, home", TrekClientIdentity.commonName(X500Principal("CN=Phone\\, home,O=Family")))
assertEquals("O=Family", TrekClientIdentity.commonName(X500Principal("O=Family")))
}
}
@@ -13,11 +13,33 @@ import java.time.LocalDate
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/** The native side's own decisions, apart from the views. */ /** The native side's own decisions, apart from the views. */
class NativeLogicTest { class NativeLogicTest {
@Test
fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() {
val server = "https://trek.example.com"
// Authelia in front of the page, its check let through on /api.
assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false))
assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false))
// A link the user follows, a frame, or anything after the server's page has shown: the browser, as before.
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false))
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false))
assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true))
// The server's own host, another scheme, or a step down to plain http.
assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false))
assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false))
assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false))
assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false))
assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false))
assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard"))
assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/"))
}
@Test @Test
fun pushesLandInTheChannelOfTheirEvent() { fun pushesLandInTheChannelOfTheirEvent() {
assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message")) assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message"))
@@ -49,6 +71,16 @@ class NativeLogicTest {
assertEquals("page", ShareLoader.slug("!!!")) assertEquals("page", ShareLoader.slug("!!!"))
} }
@Test
fun aLinkWhosePageCannotBeReadIsHandedOverAsTheLinkItself() {
val item = ShareLoader.linkOnly("https://www.instagram.com/p/abc", null)
assertEquals("text/uri-list", item.mimeType)
assertEquals("https://www.instagram.com/p/abc", item.url)
assertEquals("https://www.instagram.com/p/abc", String(item.data))
assertEquals("www.instagram.com", item.title)
assertEquals("Ramen place", ShareLoader.linkOnly("https://maps.app.goo.gl/x", "Ramen place").title)
}
@Test @Test
fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() { fun theShareSheetOffersComingTripsFirstThenRecentPastOnes() {
val today = LocalDate.of(2026, 11, 7) val today = LocalDate.of(2026, 11, 7)
+6
View File
@@ -2,9 +2,15 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../../node_modules/@capacitor/android/capacitor')
include ':capacitor-action-sheet'
project(':capacitor-action-sheet').projectDir = new File('../../node_modules/@capacitor/action-sheet/android')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android')
include ':capacitor-dialog'
project(':capacitor-dialog').projectDir = new File('../../node_modules/@capacitor/dialog/android')
include ':capacitor-filesystem' include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android') project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android')
+18
View File
@@ -0,0 +1,18 @@
// The chunk group that holds the lucide icons the app loads at startup (vite.config.js).
//
// Every lucide icon is a module of its own, and the plugin icon lookup
// (src/components/shared/PluginIcon.tsx) reaches all of them by name, from the navigation
// every page renders. Left to itself the bundler puts each icon that some other chunk
// also uses into a chunk of its own, so that neither side holds a copy: 187 files of a few
// hundred bytes each, every one a request of its own on a first visit and an entry of its
// own in the service worker's precache. In one group they are one file, whose hash changes
// only when lucide-react does.
//
// `tags: ['$initial']` for the reason the other groups carry it: without it the group would
// also take icons that today sit behind a lazy route and make them part of the first load.
export const ICON_CHUNK = {
name: 'vendor-icons',
priority: 20,
tags: ['$initial'],
test: /[\\/]node_modules[\\/]lucide-react[\\/]/,
};
@@ -37,6 +37,7 @@
512499AF65E091B2BBC9A7BB /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */; }; 512499AF65E091B2BBC9A7BB /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E45050B942C96E1ACCAB7CE9 /* UniformTypeIdentifiers.framework */; };
5480CCC12F42E9605044AB6B /* ShareHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */; }; 5480CCC12F42E9605044AB6B /* ShareHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56050CCA8DE4BEB4680D9E0B /* ShareHandoff.swift */; };
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; }; 5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
F11E0C0A9B7D4E2A8C3F5A01 /* FlightContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11E0C0A9B7D4E2A8C3F5A02 /* FlightContent.swift */; };
5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; }; 5BBE9EE02A47EC67C31F5E21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0A4B2C6C93971E884561CFA /* Localizable.strings */; };
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; }; 612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; }; 634F4E63DC7C2A0027F0D118 /* Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = F406A4A31BD875A880A7CA14 /* Details.swift */; };
@@ -51,6 +52,7 @@
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; }; 81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; }; 8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; }; 896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; }; 8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; }; 8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
@@ -172,6 +174,7 @@
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; }; 10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; }; 114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; }; 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; }; 12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; }; 1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; }; 15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -303,6 +306,7 @@
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; }; E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; }; E6831E849332626BE8D54CEF /* ShareView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; }; E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
F11E0C0A9B7D4E2A8C3F5A02 /* FlightContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FlightContent.swift; sourceTree = "<group>"; };
EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; }; EAFBDD97043B34E4B64FDA86 /* pt-BR */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = "<group>"; };
EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; }; EC8D1292C9FC6430C059960A /* nl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; }; ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -420,6 +424,7 @@
2FAD9762203C412B000D30F8 /* config.xml */, 2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */, 50B271D01FEDC1A000F3C39B /* public */,
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */, 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
); );
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -535,6 +540,7 @@
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */, DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
24B175DE91E1E6E850DB0285 /* TodayContent.swift */, 24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */, E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
F11E0C0A9B7D4E2A8C3F5A02 /* FlightContent.swift */,
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */, AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
); );
name = TREKWidgets; name = TREKWidgets;
@@ -823,6 +829,7 @@
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */, 7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */, 0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */, 8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -845,6 +852,7 @@
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */, 8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */, 9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */, 5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
F11E0C0A9B7D4E2A8C3F5A01 /* FlightContent.swift in Sources */,
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */, F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
+8
View File
@@ -25,6 +25,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
TrekPush.shared.didFail(error) TrekPush.shared.didFail(error)
} }
/// A silent push from the TREK server: a list of a trip changed, so the Apple
/// Watch is brought up to date (TrekWatchRefresher). Nothing else arrives this way.
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
guard userInfo["trek"] as? String == "watch-sync" else { return completionHandler(.noData) }
TrekWatchRefresher.refresh(completion: completionHandler)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .list, .sound]) completionHandler([.banner, .list, .sound])
+5 -1
View File
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>TREK</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@@ -120,6 +120,10 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
+101 -1
View File
@@ -6,6 +6,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return } guard let windowScene = scene as? UIWindowScene else { return }
// Launched by a quick action on the app icon: held until the UI asks for it.
if let item = connectionOptions.shortcutItem { _ = TrekQuickActions.open(item) }
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
window?.rootViewController = TrekBridgeViewController() window?.rootViewController = TrekBridgeViewController()
@@ -15,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
for context in connectionOptions.urlContexts where context.url.isFileURL { for context in connectionOptions.urlContexts where context.url.isFileURL {
TrekSharedFiles.receive(context.url) TrekSharedFiles.receive(context.url)
} }
// Launched by a tap on a widget or a Live Activity: held like a quick action.
// Capacitor still hears of the link, the UI ignores it there (feature appLinks).
for context in connectionOptions.urlContexts {
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
} }
@@ -38,13 +45,101 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// com.liketrek.trek://share: the share sheet left an item for the import. // com.liketrek.trek://share: the share sheet left an item for the import.
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" } let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() } if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
let others = URLContexts.subtracting(files).subtracting(handoffs) // com.liketrek.trek://navigate/<path>: a widget or Live Activity tap. Through
// Capacitor it was an event a page reloading after the system ended its process
// never heard, and that page then took the same link for one already followed.
let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil }
for context in appLinks {
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
}
let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks)
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) } if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
} }
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
SceneDelegateProxy.shared.scene(scene, continue: userActivity) SceneDelegateProxy.shared.scene(scene, continue: userActivity)
} }
// A quick action on the app icon while the app was running in the background.
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem,
completionHandler: @escaping (Bool) -> Void) {
completionHandler(TrekQuickActions.open(shortcutItem))
}
}
/// The quick actions on the app icon (a long press on it): "New trip" and the
/// trip coming up, set by the UI in its language (TrekShell.setQuickActions).
/// Each carries the path it opens; a tap goes to the UI as that path, at once
/// when it listens, or held for it to take when the tap launched the app.
enum TrekQuickActions {
private static let pathKey = "path"
private static var pending: String?
static var onOpen: ((String) -> Void)?
struct Item {
let type: String
let title: String
let subtitle: String?
let icon: String
let path: String
init?(raw: JSObject) {
guard let type = raw["type"] as? String, let title = raw["title"] as? String,
let path = raw["path"] as? String, path.hasPrefix("/") else { return nil }
self.type = type
self.title = title
self.subtitle = raw["subtitle"] as? String
self.icon = raw["icon"] as? String ?? ""
self.path = path
}
}
static func set(_ items: [Item]) {
UIApplication.shared.shortcutItems = items.prefix(4).map { item in
UIApplicationShortcutItem(
type: "com.liketrek.trek.\(item.type)",
localizedTitle: item.title,
localizedSubtitle: item.subtitle,
icon: icon(item.icon),
userInfo: [pathKey: item.path as NSString]
)
}
}
private static func icon(_ name: String) -> UIApplicationShortcutIcon {
switch name {
case "add": return UIApplicationShortcutIcon(type: .add)
case "trip": return UIApplicationShortcutIcon(systemImageName: "suitcase")
default: return UIApplicationShortcutIcon(type: .compose)
}
}
static func open(_ item: UIApplicationShortcutItem) -> Bool {
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
open(path: path)
return true
}
/// A path to open: at once when the UI listens, else held for it to take.
/// Widget and Live Activity taps come this way too, so each is followed once.
static func open(path: String) {
if let onOpen { onOpen(path) } else { pending = path }
}
/// The path in com.liketrek.trek://navigate/<path>, the link widgets and Live Activities open.
static func appLinkPath(_ url: URL) -> String? {
let prefix = "com.liketrek.trek://navigate"
guard url.absoluteString.hasPrefix(prefix) else { return nil }
let path = String(url.absoluteString.dropFirst(prefix.count))
if path.isEmpty { return "/" }
return path.hasPrefix("/") ? path : nil
}
/// The action the app was launched with, once.
static func take() -> String? {
defer { pending = nil }
return pending
}
} }
/// The blur over the app while it sits in the app switcher, when the app lock /// The blur over the app while it sits in the app switcher, when the app lock
@@ -110,6 +205,11 @@ enum TrekSharedFiles {
"data": data.base64EncodedString(), "data": data.base64EncodedString(),
] ]
if let tripId = info["tripId"] as? Int { file["tripId"] = tripId } if let tripId = info["tripId"] as? Int { file["tripId"] = tripId }
// A page or a link: where it came from, so the UI can keep it among the trip's links.
if let url = info["url"] as? String, url.hasPrefix("https://") || url.hasPrefix("http://") {
file["url"] = url
if let title = info["title"] as? String { file["title"] = title }
}
pending = file pending = file
onReceive?(file) onReceive?(file)
} }
@@ -10,6 +10,7 @@ import WebKit
class TrekBridgeViewController: CAPBridgeViewController { class TrekBridgeViewController: CAPBridgeViewController {
override func instanceDescriptor() -> InstanceDescriptor { override func instanceDescriptor() -> InstanceDescriptor {
let descriptor = super.instanceDescriptor() let descriptor = super.instanceDescriptor()
TrekServerStore.recheckAfterUpdate()
if let server = TrekServerStore.url { if let server = TrekServerStore.url {
// The trailing slash matters: Capacitor treats every URL that starts // The trailing slash matters: Capacitor treats every URL that starts
// with this string as the app's own, and without it that would take // with this string as the app's own, and without it that would take
@@ -40,15 +41,289 @@ class TrekBridgeViewController: CAPBridgeViewController {
// reachable" with a server that was there all along. // reachable" with a server that was there all along.
if let webView, let inner = webView.navigationDelegate { if let webView, let inner = webView.navigationDelegate {
let proxy = TrekNavigationProxy(inner: inner) let proxy = TrekNavigationProxy(inner: inner)
proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) }
proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) }
navigationProxy = proxy navigationProxy = proxy
webView.navigationDelegate = proxy webView.navigationDelegate = proxy
} }
guardBridgeMessages()
watchSignInPages()
bridge?.registerPluginInstance(TrekShellPlugin()) bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page, // WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI // closes no sheet and takes every drag near the edge from the UI. The UI
// draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"): // draws its own (mobile/components/MEdgeBack.tsx, feature "edgeBack"):
// the page follows the finger and an open sheet closes first. // the page follows the finger and an open sheet closes first.
webView?.allowsBackForwardNavigationGestures = false webView?.allowsBackForwardNavigationGestures = false
// A long press on a link would lift a preview of the page it points to,
// which no native list does; the press means "drag" or nothing here.
webView?.allowsLinkPreview = false
paintBehindWebView()
}
// MARK: - What shows where the web view does not reach
/// The UI reports its theme through StatusBar.setStyle (native/nativeApp.ts),
/// which lands here; the colour behind the web view follows it.
override func setNeedsStatusBarAppearanceUpdate() {
super.setNeedsStatusBarAppearanceUpdate()
paintBehindWebView()
}
/// Some iOS releases (27.0.x) lay the web view out below the status bar
/// instead of under it, and the strip above the page then showed this
/// view's black. It now carries the top colour of the screen background
/// (mobile.css --m-scr), so the strip reads as part of the page on every
/// release, and the rubber band at the ends shows the same colour.
private func paintBehindWebView() {
let dark: Bool
switch preferredStatusBarStyle {
case .lightContent: dark = true
case .darkContent: dark = false
default: dark = traitCollection.userInterfaceStyle == .dark
}
let color = dark
? UIColor(red: 0x17 / 255, green: 0x17 / 255, blue: 0x1C / 255, alpha: 1)
: UIColor(red: 0xFB / 255, green: 0xFA / 255, blue: 0xF7 / 255, alpha: 1)
view.backgroundColor = color
webView?.scrollView.backgroundColor = color
webView?.underPageBackgroundColor = color
}
// MARK: - The safe area the page keeps clear of
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
publishSafeArea()
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
publishSafeArea()
}
private var publishedSafeArea: UIEdgeInsets?
/// WebKit's env(safe-area-inset-*) is the window's safe area only where the
/// web view overlaps it. Where a release lays the web view out below the
/// status bar it reports 0, yet a camera in a corner can still reach into
/// the page. The part of the window's safe area (and of the status bar) that
/// falls inside the web view goes to the page as --trek-native-safe-*, which
/// mobile.css takes as a floor under env(), never in its place.
func publishSafeArea(force: Bool = false) {
guard let webView, let window = view.window else { return }
let frame = webView.convert(webView.bounds, to: window)
let safe = window.safeAreaInsets
let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
let insets = UIEdgeInsets(
top: max(0, max(safe.top, statusBar) - frame.minY),
left: max(0, safe.left - frame.minX),
bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)),
right: max(0, safe.right - (window.bounds.maxX - frame.maxX))
)
if !force, insets == publishedSafeArea { return }
publishedSafeArea = insets
let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" }
let js = "(function(s){"
+ "s.setProperty('--trek-native-safe-top',\(px(insets.top)));"
+ "s.setProperty('--trek-native-safe-left',\(px(insets.left)));"
+ "s.setProperty('--trek-native-safe-right',\(px(insets.right)));"
+ "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));"
+ "})(document.documentElement.style)"
webView.evaluateJavaScript(js, completionHandler: nil)
}
// MARK: - A proxy's sign-in page
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
/// not there, so nothing else leads back to the address screen, and a passkey
/// cannot work in a web view for a domain the app is not tied to. A bar says
/// so and offers to change the server; the keyboard gets room on that page,
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
private var signInBar: UIView?
private var urlObservation: NSKeyValueObservation?
private var insetObservation: NSKeyValueObservation?
private var onSignInPage = false
/// Whether the main frame shows an error page in place of the server's (a 4xx
/// nobody else handled: a proxy's block page, a client certificate it refused).
private var onErrorPage = false
private var signInHint: UILabel?
private var keyboardUp = false
/// How far the keyboard reaches into the view while it is up.
private var keyboardRoom: CGFloat = 0
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
private var ownsInset = false
private var bodyReset: DispatchWorkItem?
private func watchSignInPages() {
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
let signIn = TrekServerStore.isSignInHost(webView.url)
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
}
// The keyboard plugin zeroes the scroll inset on every frame change it
// makes, the last one a while after the keyboard is up. On a sign-in
// page the inset wanted here is put back each time.
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async { self?.applyInset() }
}
let center = NotificationCenter.default
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
private func signInPageChanged(_ signIn: Bool) {
onSignInPage = signIn
if signIn && signInBar == nil { signInBar = makeSignInBar() }
updateSignInBar()
}
private func errorPageChanged(_ error: Bool) {
onErrorPage = error
if error && signInBar == nil { signInBar = makeSignInBar() }
updateSignInBar()
}
/// Out of the way while the keyboard is up: the form needs the room.
private func updateSignInBar() {
let labels = TrekServerStore.labels
signInHint?.text = onSignInPage
? labels["signInHint"]
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
: labels["errorPageHint"] ?? "This page is not from TREK: the server or a proxy in front of it refused the app. Check your proxy or certificate, or change the server."
signInBar?.isHidden = !((onSignInPage || onErrorPage) && !keyboardUp)
applyInset()
}
/// On a sign-in page the page can scroll past what covers its end: the
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
private func applyInset() {
guard let scrollView = webView?.scrollView else { return }
let wanted: CGFloat
if onSignInPage || onErrorPage {
if keyboardUp {
wanted = keyboardRoom
} else if let bar = signInBar {
view.layoutIfNeeded()
wanted = max(0, view.bounds.height - bar.frame.minY)
} else {
wanted = 0
}
ownsInset = true
} else if ownsInset {
wanted = 0
ownsInset = false
} else {
return
}
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
}
/// The keyboard plugin also sizes the body to the area above the keyboard,
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
/// body gets its height back once that has run, so the button under the
/// keyboard can be scrolled into view through the inset above.
@objc private func keyboardWillShow(_ note: Notification) {
keyboardUp = true
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
}
bodyReset?.cancel()
if onSignInPage {
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
let reset = DispatchWorkItem { [weak self] in
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
self.applyInset()
}
bodyReset = reset
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
}
updateSignInBar()
}
@objc private func keyboardWillHide() {
keyboardUp = false
bodyReset?.cancel()
updateSignInBar()
}
private func makeSignInBar() -> UIView {
let labels = TrekServerStore.labels
// The look of the app's own first screens: a white card with a sand edge,
// warm grey text and a dark button, over whatever page the proxy shows.
let bar = UIView()
bar.backgroundColor = UIColor(white: 1, alpha: 0.97)
bar.layer.cornerRadius = 18
bar.layer.cornerCurve = .continuous
bar.layer.borderWidth = 1
bar.layer.borderColor = UIColor(red: 0xEF / 255, green: 0xE4 / 255, blue: 0xD8 / 255, alpha: 1).cgColor
bar.layer.shadowColor = UIColor(red: 0x1F / 255, green: 0x1A / 255, blue: 0x17 / 255, alpha: 1).cgColor
bar.layer.shadowOpacity = 0.18
bar.layer.shadowRadius = 18
bar.layer.shadowOffset = CGSize(width: 0, height: 10)
bar.translatesAutoresizingMaskIntoConstraints = false
let hint = UILabel()
hint.text = labels["signInHint"]
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
hint.textColor = UIColor(red: 0x6B / 255, green: 0x5F / 255, blue: 0x57 / 255, alpha: 1)
hint.font = .preferredFont(forTextStyle: .footnote)
hint.adjustsFontForContentSizeCategory = true
hint.numberOfLines = 0
signInHint = hint
hint.translatesAutoresizingMaskIntoConstraints = false
var look = UIButton.Configuration.filled()
look.baseBackgroundColor = UIColor(red: 0x11 / 255, green: 0x18 / 255, blue: 0x27 / 255, alpha: 1)
look.baseForegroundColor = .white
look.cornerStyle = .fixed
look.background.cornerRadius = 12
look.contentInsets = NSDirectionalEdgeInsets(top: 9, leading: 14, bottom: 9, trailing: 14)
let change = UIButton(configuration: look)
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
change.translatesAutoresizingMaskIntoConstraints = false
change.setContentHuggingPriority(.required, for: .horizontal)
change.setContentCompressionResistancePriority(.required, for: .horizontal)
bar.addSubview(hint)
bar.addSubview(change)
view.addSubview(bar)
NSLayoutConstraint.activate([
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
])
return bar
}
/// What resetServer does from the UI: forget the server, back to the address screen.
@objc private func changeServerFromSignIn() {
TrekServerStore.url = nil
view.window?.rootViewController = TrekBridgeViewController()
}
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
private var messageGuard: TrekBridgeMessageGuard?
/// Capacitor answers bridge messages from any page in the web view. With a
/// proxy's sign-in page loading there, its messages are dropped before they
/// reach the plugins. Always in place: a sign-in host can also be learned
/// while the app runs (TrekSignInRedirect).
private func guardBridgeMessages() {
guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler,
let controller = webView?.configuration.userContentController else { return }
let guarded = TrekBridgeMessageGuard(inner: handler)
controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name)
controller.add(guarded, name: TrekBridgeMessageGuard.name)
messageGuard = guarded
} }
/// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit /// A key command of the UI's (TrekKeyCommands) was pressed. The commands sit
@@ -137,14 +412,120 @@ enum TrekKeyCommands {
enum TrekServerStore { enum TrekServerStore {
private static let key = "TrekServerURL" private static let key = "TrekServerURL"
private static let failureKey = "TrekLastFailure"
/// Why the server's page did not load ("NSURLErrorDomain -1003"): the
/// address screen shows it small under its message, so a report carries
/// the cause. Taken once.
static func noteFailure(_ error: Error) {
let ns = error as NSError
UserDefaults.standard.set("\(ns.domain) \(ns.code)", forKey: failureKey)
}
static func takeFailure() -> String? {
let failure = UserDefaults.standard.string(forKey: failureKey)
UserDefaults.standard.removeObject(forKey: failureKey)
return failure
}
private static let previousKey = "TrekPreviousServerURL"
private static let checkedKey = "TrekServerCheck"
private static let labelsKey = "TrekShellLabels"
/// The words of the bar over a proxy's sign-in page, in the user's language,
/// as the address screen passed them (setServer's `labels`): the server's UI
/// is not there to say them. Empty keeps the English fallback.
static var labels: [String: String] {
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
}
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
/// A server remembered under a lower value is checked once more: the first
/// launch after an update that raised it puts the address screen back up,
/// filled in with that server (previousURL). Raise both together.
private static let serverCheck = 3
/// Runs before the first bridge is built, so a server picked under an older
/// minimum is not loaded unchecked.
static func recheckAfterUpdate() {
let defaults = UserDefaults.standard
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
defaults.set(serverCheck, forKey: checkedKey)
guard let server = url else { return }
url = nil
defaults.set(server, forKey: previousKey)
}
/// The server an update asked to check again, until a server is set.
static var previousURL: String? {
UserDefaults.standard.string(forKey: previousKey)
}
private static let signInHostsKey = "TrekServerSignInHosts"
/// Setting the server drops the sign-in hosts of the one before it.
static var url: String? { static var url: String? {
get { UserDefaults.standard.string(forKey: key) } get { UserDefaults.standard.string(forKey: key) }
set { set {
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } if let newValue {
UserDefaults.standard.set(newValue, forKey: key)
UserDefaults.standard.removeObject(forKey: previousKey)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
UserDefaults.standard.removeObject(forKey: signInHostsKey)
try? FileManager.default.removeItem(at: offlineCopyURL) try? FileManager.default.removeItem(at: offlineCopyURL)
} }
} }
/// The hosts of a sign-in page in front of the server (a proxy such as
/// Cloudflare Access or Pangolin), as the address screen found them. Only
/// these load in the web view besides the server, so the proxy's session
/// cookie is the web view's (TrekNavigationProxy), and none of them may
/// talk to the app (TrekBridgeMessageGuard).
static var signInHosts: Set<String> {
get { Set((UserDefaults.standard.stringArray(forKey: signInHostsKey) ?? []).map { $0.lowercased() }) }
set {
if newValue.isEmpty {
UserDefaults.standard.removeObject(forKey: signInHostsKey)
} else {
UserDefaults.standard.set(Array(newValue).sorted(), forKey: signInHostsKey)
}
}
}
/// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect).
static func addSignInHost(_ host: String) {
var hosts = signInHosts
guard hosts.insert(host.lowercased()).inserted else { return }
signInHosts = hosts
}
/// Whether `url` is one of the sign-in hosts, matched on host or host:port.
static func isSignInHost(_ url: URL?) -> Bool {
guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false }
let hosts = signInHosts
if hosts.contains(host) { return true }
if let port = url.port { return hosts.contains("\(host):\(port)") }
return false
}
/// The one-time code of a TREK Cloud purchase (setServer's `welcome`). The
/// server's first page opens its welcome page with it once, which signs the
/// buyer in (TrekNavigationProxy). Kept in memory only, never on disk.
static var pendingWelcome: String?
/// The welcome page for the pending code, handed out once.
static func takeWelcomeURL() -> URL? {
guard let code = pendingWelcome, let server = url else { return nil }
pendingWelcome = nil
let base = server.hasSuffix("/") ? server : server + "/"
return URL(string: base + "api/managed/welcome#" + code)
}
static var offlineCopyURL: URL { static var offlineCopyURL: URL {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
return support.appendingPathComponent("offline-start-page.html") return support.appendingPathComponent("offline-start-page.html")
@@ -366,12 +747,48 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
} }
} }
/// A sign-in page in front of the server that the address screen never saw.
/// A proxy can let the app's check through (an Authelia or Authentik rule that
/// bypasses /api) and still send the page itself to its sign-in. Handed to
/// Safari, that sign-in leaves its session there and the app stays blank on
/// every launch. So a redirect away from the server, while the server's first
/// page is still loading, is taken as the proxy's sign-in: it loads in the web
/// view and its host is kept, the way the address screen keeps the ones it saw.
/// A link the user taps is no redirect, and once a page of the server has
/// shown, nothing is learned any more.
enum TrekSignInRedirect {
/// The host (host:port with an explicit port) to keep as a sign-in host, or nil.
static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? {
guard mainFrame, redirect, !serverShown,
let server, let from = URL(string: server), let fromHost = from.host?.lowercased(),
let target, let toHost = target.host?.lowercased(),
let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil }
// Never from https down to plain http: the sign-in would send a password in the clear.
if from.scheme?.lowercased() == "https", toScheme != "https" { return nil }
guard toHost != fromHost else { return nil }
if let port = target.port { return "\(toHost):\(port)" }
return toHost
}
/// Whether `url` is on the server's host, where the app's own pages come from.
static func onServer(server: String?, url: URL?) -> Bool {
guard let server, let host = URL(string: server)?.host?.lowercased() else { return false }
return url?.host?.lowercased() == host
}
}
/// Stands in front of Capacitor's navigation delegate and hands it everything /// Stands in front of Capacitor's navigation delegate and hands it everything
/// but the failures of a navigation that another one replaced: a cancelled /// but the failures of a navigation that another one replaced: a cancelled
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those /// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
/// are no fault, and Capacitor would answer them with its error page. /// are no fault, and Capacitor would answer them with its error page.
final class TrekNavigationProxy: NSObject, WKNavigationDelegate { final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
private let inner: WKNavigationDelegate private let inner: WKNavigationDelegate
/// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect).
private var serverShown = false
/// Told on the main thread whether the main frame now shows an error page in place of the server's.
var onErrorPage: ((Bool) -> Void)?
/// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one.
var onPageShown: (() -> Void)?
init(inner: WKNavigationDelegate) { init(inner: WKNavigationDelegate) {
self.inner = inner self.inner = inner
@@ -391,13 +808,133 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
(inner as AnyObject).responds(to: aSelector) ? inner : nil (inner as AnyObject).responds(to: aSelector) ? inner : nil
} }
/// The sign-in page of a proxy in front of the server loads here instead of
/// in Safari, so its session is the web view's and every request carries it.
/// Only the hosts the address screen saw (TrekServerStore.signInHosts), and
/// only in the main frame; everything else is Capacitor's decision as before.
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.targetFrame?.isMainFrame == true, TrekServerStore.isSignInHost(navigationAction.request.url) {
decisionHandler(.allow)
return
}
// WebKit asks again for every server redirect, as a navigation of type .other.
if let host = TrekSignInRedirect.learnedHost(
server: TrekServerStore.url,
target: navigationAction.request.url,
mainFrame: navigationAction.targetFrame?.isMainFrame == true,
redirect: navigationAction.navigationType == .other,
serverShown: serverShown
) {
TrekServerStore.addSignInHost(host)
decisionHandler(.allow)
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: decisionHandler)
}
/// After the server's first page, a pending welcome code opens the welcome
/// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
inner.webView?(webView, didFinish: navigation)
onPageShown?()
if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) {
serverShown = true
// The server's page is up: an earlier failure is no cause any more.
_ = TrekServerStore.takeFailure()
}
if let welcome = TrekServerStore.takeWelcomeURL() {
webView.load(URLRequest(url: welcome))
}
}
/// A proxy in front of the server asks for a client certificate (mutual TLS):
/// the stored one goes to the host and port it was imported for and nowhere
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if TrekClientIdentity.answer(challenge, completionHandler) {
let space = challenge.protectionSpace
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
guard let self, let webView else { return }
// Capacitor's own answer to a failed load: the address screen (errorPath).
TrekServerStore.noteFailure(URLError(.clientCertificateRejected))
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
}
return
}
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
completionHandler(.performDefaultHandling, nil)
return
}
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
}
/// A proxy that asked the page for a client certificate, got none and
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
/// app to the address screen, which says so and offers to add one, rather
/// than leaving the proxy's error page up. Any other 4xx page is not TREK's,
/// which serves its UI for every path (a proxy's block page, a certificate it
/// refused): it stays up with the bar and its way out to another server.
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
decisionHandler(.cancel)
TrekServerStore.noteFailure(URLError(.clientCertificateRequired))
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
return
}
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse {
let error = http.statusCode >= 400 && http.statusCode < 500
DispatchQueue.main.async { [weak self] in self?.onErrorPage?(error) }
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return } if Self.isReplaced(error) { return }
TrekServerStore.noteFailure(error)
inner.webView?(webView, didFail: navigation, withError: error) inner.webView?(webView, didFail: navigation, withError: error)
} }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return } if Self.isReplaced(error) { return }
TrekServerStore.noteFailure(error)
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error) inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
} }
} }
/// Stands in front of Capacitor's bridge message handler and drops what a
/// proxy's sign-in page posts: that page loads in the web view (see
/// TrekNavigationProxy), and Capacitor itself would hand its messages to the
/// plugins whatever page they come from.
final class TrekBridgeMessageGuard: NSObject, WKScriptMessageHandler {
static let name = "bridge"
private let inner: WKScriptMessageHandler
init(inner: WKScriptMessageHandler) {
self.inner = inner
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let origin = message.frameInfo.securityOrigin
var components = URLComponents()
components.scheme = origin.protocol
components.host = origin.host
if origin.port != 0 { components.port = origin.port }
if TrekServerStore.isSignInHost(components.url) { return }
inner.userContentController(userContentController, didReceive: message)
}
}
+393
View File
@@ -0,0 +1,393 @@
import Foundation
import Security
import UIKit
import UniformTypeIdentifiers
/// The client certificate a user's TREK server asks for (mutual TLS): a proxy
/// such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through
/// only devices that show one. The app holds at most one, for one server.
///
/// It comes in as a PKCS#12 file (.p12, .pfx) and its password. The password is
/// never kept; the identity goes into the keychain, this device only, readable
/// after the first unlock (so a background refresh can show it too). Only the
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
/// sign-in page, a map tile provider or liketrek.com never learns of it.
enum TrekClientIdentity {
private static let label = "trek-client-identity"
private static let hostKey = "TrekClientIdentityHost"
private static let portKey = "TrekClientIdentityPort"
enum Failure: String, Error {
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
}
/// The last server that asked for a certificate while the app ran, and whether it got one.
struct Request {
let host: String
let port: Int
let served: Bool
}
private static let lock = NSLock()
private static var _lastRequest: Request?
static var lastRequest: Request? {
lock.lock(); defer { lock.unlock() }
return _lastRequest
}
static func noteRequest(host: String, port: Int, served: Bool) {
lock.lock(); defer { lock.unlock() }
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
}
// MARK: Reading a file
/// Opens a PKCS#12 file: the identity and the rest of its chain.
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
var items: CFArray?
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
switch status {
case errSecSuccess: break
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
default: return .failure(.format)
}
guard let first = (items as? [[String: Any]])?.first,
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
let identity = value as! SecIdentity
var leaf: SecCertificate?
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
if let validity = validity(of: leaf) {
if now > validity.notAfter { return .failure(.expired) }
if now < validity.notBefore { return .failure(.notYetValid) }
}
let leafData = SecCertificateCopyData(leaf) as Data
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
SecCertificateCopyData($0) as Data != leafData
}
return .success((identity, chain))
}
// MARK: Keeping it
/// Keeps the identity for this host and port, replacing any other.
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
remove()
let query: [String: Any] = [
kSecValueRef as String: identity,
kSecAttrLabel as String: label,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
// The chain's intermediates go along so a proxy that wants them gets them.
let encoded = chain.map { SecCertificateCopyData($0) as Data }
UserDefaults.standard.set(encoded, forKey: chainKey)
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
UserDefaults.standard.set(port, forKey: portKey)
return true
}
private static let chainKey = "TrekClientIdentityChain"
static func remove() {
TrekClientCertWatch.reset()
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
}
UserDefaults.standard.removeObject(forKey: hostKey)
UserDefaults.standard.removeObject(forKey: portKey)
UserDefaults.standard.removeObject(forKey: chainKey)
}
private static func storedIdentity() -> SecIdentity? {
var item: CFTypeRef?
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: label,
kSecReturnRef as String: true,
]
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
return (item as! SecIdentity)
}
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
static func matches(host: String?, port: Int) -> Bool {
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
let storedPort = UserDefaults.standard.integer(forKey: portKey)
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
}
/// The credential for a server at host and port, or nil when the stored one is for another.
static func credential(forHost host: String, port: Int) -> URLCredential? {
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
SecCertificateCreateWithData(nil, $0 as CFData)
}
// Per session: a replaced certificate takes effect on the next connection.
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
}
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
let space = challenge.protectionSpace
let credential = credential(forHost: space.host, port: space.port)
noteRequest(host: space.host, port: space.port, served: credential != nil)
if let credential {
completion(.useCredential, credential)
} else {
// As a browser without a matching certificate: go on without one.
completion(.performDefaultHandling, nil)
}
return true
}
// MARK: What the settings show
static func info() -> [String: Any]? {
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
var leaf: SecCertificate?
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
let port = UserDefaults.standard.integer(forKey: portKey)
var result: [String: Any] = [
"host": host,
"port": port > 0 ? port : 443,
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
"issuer": issuerName(of: leaf) ?? "",
]
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
return result
}
// MARK: Certificate fields
/// notBefore and notAfter from the DER: iOS has no public API for them.
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
DER.validity(SecCertificateCopyData(certificate) as Data)
}
private static func issuerName(of certificate: SecCertificate) -> String? {
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
}
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
enum DER {
private struct Node {
let tag: UInt8
let body: Range<Int>
let end: Int
}
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
guard offset + 2 <= bytes.count else { return nil }
let tag = bytes[offset]
var length = Int(bytes[offset + 1])
var start = offset + 2
if length & 0x80 != 0 {
let count = length & 0x7f
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
start += count
}
guard start + length <= bytes.count else { return nil }
return Node(tag: tag, body: start..<start + length, end: start + length)
}
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
var result: [Node] = []
var offset = range.lowerBound
while offset < range.upperBound, let child = node(bytes, at: offset) {
result.append(child)
offset = child.end
}
return result
}
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
var fields = children(bytes, in: tbs.body)
if fields.first?.tag == 0xa0 { fields.removeFirst() }
return fields.count > 3 ? fields : nil
}
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
let bytes = [UInt8](data)
guard let fields = tbsFields(bytes) else { return nil }
let times = children(bytes, in: fields[3].body)
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
return (from, to)
}
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
return formatter.date(from: text)
}
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
static func issuerCommonName(_ data: Data) -> String? {
let bytes = [UInt8](data)
guard let fields = tbsFields(bytes) else { return nil }
for set in children(bytes, in: fields[2].body) {
for pair in children(bytes, in: set.body) {
let parts = children(bytes, in: pair.body)
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
return String(bytes: bytes[parts[1].body], encoding: .utf8)
}
}
return nil
}
}
}
/// Notices when the user's server stops letting the app in for want of a
/// client certificate (one removed, expired, or no longer trusted). The page can
/// still be the stored offline copy while every request fails, so when the server
/// asks for a certificate the app checks once natively whether TREK answers with
/// what it holds. Only a check that fails at the certificate (the handshake broke
/// off, or the proxy's own 4xx) calls `onRefused`; a server that merely asks and
/// lets everyone in answers TREK's 200. At most once a minute.
enum TrekClientCertWatch {
private static let lock = NSLock()
private static var lastCheck = Date.distantPast
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
static func reset() {
lock.lock(); defer { lock.unlock() }
lastCheck = .distantPast
}
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
lock.lock()
let due = Date().timeIntervalSince(lastCheck) >= 60
if due { lastCheck = Date() }
lock.unlock()
guard due else { return }
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
request.setValue("application/json", forHTTPHeaderField: "Accept")
var task: URLSessionDataTask?
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if asked && (status == 0 || (400..<500).contains(status)) {
DispatchQueue.main.async(execute: onRefused)
}
}
task?.resume()
}
}
/// A URLSession delegate for the app's own requests to the user's server: it
/// shows the client certificate where the server asks for it and notes the ask.
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
static let shared = TrekClientCertSessionDelegate()
/// The app's session for the user's server (offline copy, watch, address check).
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
/// Per task: whether the server asked, and whether it got a certificate (address check only).
private let lock = NSLock()
private var asks: [Int: Bool] = [:]
func ask(for task: URLSessionTask) -> Bool? {
lock.lock(); defer { lock.unlock() }
return asks.removeValue(forKey: task.taskIdentifier)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
self.lock.lock()
self.asks[task.taskIdentifier] = credential != nil
self.lock.unlock()
completionHandler(disposition, credential)
}
if !handled { completionHandler(.performDefaultHandling, nil) }
}
}
/// The file picker and password prompt of an import, in the UI's words.
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
private let presenter: UIViewController
private let labels: [String: String]
private let host: String
private let port: Int
private let done: ([String: Any]) -> Void
private var keepAlive: TrekClientCertImporter?
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
self.presenter = presenter
self.labels = labels
self.host = host
self.port = port
self.done = done
}
func start() {
keepAlive = self
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
picker.delegate = self
presenter.present(picker, animated: true)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finish(["result": "cancelled"])
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first,
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
let data = try? Data(contentsOf: url) else {
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
return
}
try? FileManager.default.removeItem(at: url)
askPassword(for: data, wrong: false)
}
private func askPassword(for data: Data, wrong: Bool) {
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
alert.addTextField { field in
field.isSecureTextEntry = true
field.placeholder = self.labels["placeholder"]
field.textContentType = .password
}
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
self.finish(["result": "cancelled"])
})
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
self.open(data, password: alert.textFields?.first?.text ?? "")
})
presenter.present(alert, animated: true)
}
private func open(_ data: Data, password: String) {
switch TrekClientIdentity.read(data, password: password) {
case .failure(.password):
askPassword(for: data, wrong: true)
case .failure(let failure):
finish(["result": "failed", "failure": failure.rawValue])
case .success(let (identity, chain)):
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
return
}
var result: [String: Any] = ["result": "imported"]
if let info = TrekClientIdentity.info() { result["certificate"] = info }
finish(result)
}
}
private func finish(_ result: [String: Any]) {
done(result)
keepAlive = nil
}
}
+279 -8
View File
@@ -11,6 +11,7 @@ import ActivityKit
#endif #endif
import LocalAuthentication import LocalAuthentication
import PassKit import PassKit
import QuickLook
import UIKit import UIKit
import WebKit import WebKit
@@ -45,10 +46,18 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "storeHeld", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "addToWallet", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setWatchSnapshot", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "takeWatchActions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestServer", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
] ]
/// What this binary offers the server's UI, raised with every native feature /// What this binary offers the server's UI, raised with every native feature
@@ -60,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an /// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing. /// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] { static var features: [String] {
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands"] var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"]
if canAddWalletPasses { names.append("wallet") } if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") } if TrekWatchLink.isAvailable { names.append("watch") }
return names return names
@@ -85,6 +94,32 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
TrekKeyCommands.onPress = { [weak self] id in TrekKeyCommands.onPress = { [weak self] id in
self?.notifyListeners("keyCommand", data: ["id": id]) self?.notifyListeners("keyCommand", data: ["id": id])
} }
// Kept until a listener takes it: a tap while the page reloads reaches the new page.
TrekQuickActions.onOpen = { [weak self] path in
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) }
}
}
// MARK: - Quick actions on the app icon
/// The app icon's quick actions, in the UI's language (TrekQuickActions).
@objc func setQuickActions(_ call: CAPPluginCall) {
let items = (call.getArray("items", JSObject.self) ?? []).compactMap { TrekQuickActions.Item(raw: $0) }
DispatchQueue.main.async {
TrekQuickActions.set(items)
call.resolve()
}
}
/// The quick action the app was launched with, before the UI was there to hear of it.
@objc func takeQuickAction(_ call: CAPPluginCall) {
DispatchQueue.main.async {
if let path = TrekQuickActions.take() {
call.resolve(["path": path])
} else {
call.resolve(["path": NSNull()])
}
}
} }
// MARK: - Hardware keyboard // MARK: - Hardware keyboard
@@ -139,7 +174,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
nextName: nextName, nextName: nextName,
nextTime: call.getString("nextTime"), nextTime: call.getString("nextTime"),
done: call.getInt("done") ?? 0, done: call.getInt("done") ?? 0,
total: call.getInt("total") ?? 0 total: call.getInt("total") ?? 0,
flight: TrekShellPlugin.activityFlight(call.getObject("flight"))
) )
// Stale at the end of the day, so a forgotten one fades out on its own. // Stale at the end of the day, so a forgotten one fades out on its own.
let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400)) let stale = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
@@ -159,6 +195,19 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
call.resolve(["running": false]) call.resolve(["running": false])
} }
/// The day's flight the UI sent along (native/widgets.ts tripDayState), when complete.
@available(iOS 16.1, *)
static func activityFlight(_ raw: JSObject?) -> TripDayAttributes.Flight? {
guard let raw, let number = raw["number"] as? String, let from = raw["from"] as? String, let to = raw["to"] as? String,
let dep = (raw["dep"] as? NSNumber)?.doubleValue, let arr = (raw["arr"] as? NSNumber)?.doubleValue, arr > dep,
let depTime = raw["depTime"] as? String, let arrTime = raw["arrTime"] as? String else { return nil }
return TripDayAttributes.Flight(
number: number, from: from, to: to, dep: dep, arr: arr, depTime: depTime, arrTime: arrTime,
delay: (raw["delay"] as? NSNumber)?.intValue,
airborne: (raw["airborne"] as? Bool) ?? false
)
}
@objc func endTripDay(_ call: CAPPluginCall) { @objc func endTripDay(_ call: CAPPluginCall) {
#if canImport(ActivityKit) #if canImport(ActivityKit)
if #available(iOS 16.2, *) { if #available(iOS 16.2, *) {
@@ -315,7 +364,11 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
private var authSession: ASWebAuthenticationSession? private var authSession: ASWebAuthenticationSession?
@objc func getServer(_ call: CAPPluginCall) { @objc func getServer(_ call: CAPPluginCall) {
call.resolve(["url": TrekServerStore.url ?? NSNull()]) call.resolve([
"url": TrekServerStore.url ?? NSNull(),
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
"failure": TrekServerStore.takeFailure() ?? NSNull(),
])
} }
@objc func setServer(_ call: CAPPluginCall) { @objc func setServer(_ call: CAPPluginCall) {
@@ -324,6 +377,14 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
return return
} }
TrekServerStore.url = raw TrekServerStore.url = raw
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
if !labels.isEmpty { TrekServerStore.labels = labels }
// After the URL: setting it clears the hosts of the server before.
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
let welcome = call.getString("welcome") ?? ""
TrekServerStore.pendingWelcome = welcome.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil ? welcome : nil
call.resolve() call.resolve()
restartBridge() restartBridge()
} }
@@ -396,7 +457,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
} }
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
request.setValue("text/html", forHTTPHeaderField: "Accept") request.setValue("text/html", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { data, response, _ in // The app's session, so a server behind mutual TLS gets the client certificate.
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200, guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true, http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
TrekServerStore.url == raw else { TrekServerStore.url == raw else {
@@ -435,8 +497,123 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
} }
} }
// MARK: - Client certificates (mutual TLS)
extension TrekShellPlugin {
/// The address screen's check of a server, made natively so it notices
/// whether the server asks for a client certificate and can show the stored
/// one. A failed connection resolves with status 0 instead of rejecting.
@objc func requestServer(_ call: CAPPluginCall) {
guard let raw = call.getString("url"), let url = URL(string: raw),
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
call.reject("A http(s) URL is required")
return
}
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
let delegate = TrekClientCertSessionDelegate.shared
var task: URLSessionDataTask?
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
let asked = task.flatMap { delegate.ask(for: $0) }
var result: [String: Any] = [
"certRequested": asked != nil,
"certSent": asked == true,
"url": response?.url?.absoluteString ?? raw,
]
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
if let http = response as? HTTPURLResponse {
result["status"] = http.statusCode
let body = (data ?? Data()).prefix(512 * 1024)
result["data"] = String(data: body, encoding: .utf8) ?? ""
var headers: [String: String] = [:]
for (name, value) in http.allHeaderFields {
if let name = name as? String, let value = value as? String { headers[name] = value }
}
result["headers"] = headers
} else {
result["status"] = 0
result["data"] = ""
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
}
call.resolve(result)
}
task?.resume()
}
/// The stored client certificate's details, and the last server that asked for one.
@objc func clientCertificate(_ call: CAPPluginCall) {
var result: [String: Any] = [:]
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
if let asked = TrekClientIdentity.lastRequest {
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
}
call.resolve(result)
}
@objc func removeClientCertificate(_ call: CAPPluginCall) {
TrekClientIdentity.remove()
call.resolve()
}
/// Picks a .p12/.pfx file, asks for its password until it opens or the user
/// gives up, and keeps it for the server at host and port.
@objc func importClientCertificate(_ call: CAPPluginCall) {
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
call.reject("A host is required")
return
}
let port = call.getInt("port") ?? 443
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
DispatchQueue.main.async { [weak self] in
guard let presenter = self?.bridge?.viewController else {
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
return
}
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
call.resolve(result)
}.start()
}
}
}
/// Positions and errors for the UI, shaped like the Geolocation API's, with the /// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time. /// When-In-Use permission asked the first time.
// MARK: - Quick Look
extension TrekShellPlugin {
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
/// One preview at a time, so a static: an extension cannot store per instance.
private static var previewSource: TrekFilePreviewSource?
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
/// an image, a document, with the system's own share button. "unsupported" for
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
@objc func previewFile(_ call: CAPPluginCall) {
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else {
call.reject("a file in the app's cache is required", "INVALID")
return
}
DispatchQueue.main.async { [weak self] in
guard let self, let presenter = self.bridge?.viewController else {
call.reject("nothing to show the preview from", "UNAVAILABLE")
return
}
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
call.resolve(["result": "unsupported"])
return
}
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
TrekShellPlugin.previewSource = source
let preview = QLPreviewController()
preview.dataSource = source
presenter.present(preview, animated: true)
call.resolve(["result": "shown"])
}
}
}
// MARK: - Apple Wallet // MARK: - Apple Wallet
extension TrekShellPlugin { extension TrekShellPlugin {
@@ -519,6 +696,17 @@ extension TrekShellPlugin {
return return
} }
Task { Task {
// One subscription per Apple ID: another plan would change it, not add one.
// A held one that was delivered stops the purchase here. One that was paid
// but never set up is handed back as bought, so its instance is set up now.
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
if await TrekStore.awaitingSetup(held) {
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
} else {
call.resolve(["kind": "subscribed"])
}
return
}
do { do {
guard let product = try await Product.products(for: [id]).first else { guard let product = try await Product.products(for: [id]).first else {
call.resolve(["kind": "failed"]) call.resolve(["kind": "failed"])
@@ -575,6 +763,21 @@ extension TrekShellPlugin {
call.resolve(["jws": found.jws, "transactionId": String(found.id)]) call.resolve(["jws": found.jws, "transactionId": String(found.id)])
} }
} }
/// Feature storeKit: whether this Apple ID holds a delivered subscription under
/// `prefix`. Read from the entitlements on the device, so no password is asked;
/// while one runs, the shell offers no purchase.
@objc func storeHeld(_ call: CAPPluginCall) {
let prefix = call.getString("prefix") ?? "cloud."
Task {
guard let held = await TrekStore.currentEntitlement(prefix: prefix) else {
call.resolve(["held": false])
return
}
let awaiting = await TrekStore.awaitingSetup(held)
call.resolve(["held": !awaiting])
}
}
} }
enum TrekStore { enum TrekStore {
@@ -605,17 +808,62 @@ enum TrekStore {
} }
} }
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? { typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
var newest: (date: Date, jws: String, id: UInt64)?
/// The subscription this Apple ID holds under `prefix`, newest first. The
/// entitlements on the device come first; on a fresh install they can still
/// be empty while the subscription runs, so the status of the subscription
/// group, which StoreKit asks the App Store for, decides then.
static func currentEntitlement(prefix: String) async -> Held? {
var newest: (date: Date, held: Held)?
for await result in Transaction.currentEntitlements { for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result, guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix), transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue } transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date { if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id) newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
} }
} }
return newest.map { ($0.jws, $0.id) } if let newest { return newest.held }
return await subscriptionGroupHeld(prefix: prefix)
}
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
/// productIdFor in iap.ts); one of them is enough to find their group.
private static let plans = ["start", "basis", "plus", "pro"]
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
guard let products = try? await Product.products(for: ids),
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
for status in statuses where running.contains(status.state) {
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
return held(status.transaction.jwsRepresentation, transaction)
}
}
return nil
}
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
}
/// A first purchase that was paid but whose instance was never set up: handed
/// back as bought so it is set up now. A renewal the device has not finished
/// yet is no such purchase; its subscription is running and counts as held.
static func awaitingSetup(_ held: Held) async -> Bool {
guard held.firstPurchase else { return false }
return await isUnfinished(held.id)
}
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
static func isUnfinished(_ id: UInt64) async -> Bool {
for await result in Transaction.unfinished {
if case .verified(let transaction) = result, transaction.id == id { return true }
}
return false
} }
} }
@@ -839,3 +1087,26 @@ enum TrekSharedKeychain {
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
} }
} }
/// The one file Quick Look shows, under the name the user knows it by.
final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource {
private let item: Item
init(url: URL, title: String?) {
item = Item(url: url, title: title)
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item }
final class Item: NSObject, QLPreviewItem {
let previewItemURL: URL?
let previewItemTitle: String?
init(url: URL, title: String?) {
previewItemURL = url
previewItemTitle = title
}
}
}
+240 -2
View File
@@ -2,6 +2,7 @@ import CryptoKit
import Foundation import Foundation
import UIKit import UIKit
import WatchConnectivity import WatchConnectivity
import WebKit
/// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the /// The phone's end of WatchConnectivity (feature "watch"). The UI hands over the
/// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot); /// watch's snapshot (client/src/native/watch.ts through TrekShell.setWatchSnapshot);
@@ -93,6 +94,11 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
return result return result
} }
/// The snapshot the watch has now, as last published.
func storedSnapshot() -> Data? {
queue.sync { UserDefaults.standard.data(forKey: snapshotKey) }
}
// MARK: Ticks from the watch // MARK: Ticks from the watch
/// The ticks queued so far, oldest first, and the queue emptied. /// The ticks queued so far, oldest first, and the queue emptied.
@@ -110,6 +116,25 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
return list return list
} }
/// While the app is in front, its UI writes the tick, through its offline queue
/// as a tap would. In the background the WebView is asleep, so the tick is
/// written to the server from here at once (TrekWatchWriter), and the trip's
/// other devices hear of it over their WebSocket; only a write that fails is
/// queued for the UI, which then tries again once it runs.
private func receive(_ action: [String: Any], then done: @escaping () -> Void) {
DispatchQueue.main.async {
guard UIApplication.shared.applicationState != .active else {
self.enqueue(action)
done()
return
}
TrekWatchWriter.write(action) { written in
if !written { self.enqueue(action) }
done()
}
}
}
private func enqueue(_ action: [String: Any]) { private func enqueue(_ action: [String: Any]) {
queue.async { queue.async {
// A tick sent twice (the watch resends after a restart) replaces the earlier copy. // A tick sent twice (the watch resends after a restart) replaces the earlier copy.
@@ -145,11 +170,16 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
} }
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if let action = userInfo["action"] as? [String: Any] { enqueue(action) } if let action = userInfo["action"] as? [String: Any] { receive(action) {} }
} }
/// The watch asks for the latest snapshot when it opens while the phone is in reach. /// The watch asks for the latest snapshot when it opens while the phone is in
/// reach, and sends its ticks this way while it is (WatchLink.send).
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
if let action = message["action"] as? [String: Any] {
receive(action) { replyHandler(["queued": true]) }
return
}
queue.async { queue.async {
if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) { if message["request"] as? String == "snapshot", let data = UserDefaults.standard.data(forKey: self.snapshotKey) {
replyHandler(["snapshot": data]) replyHandler(["snapshot": data])
@@ -159,3 +189,211 @@ final class TrekWatchLink: NSObject, WCSessionDelegate {
} }
} }
} }
/// Writes a tick from the watch straight to the TREK server, for when the app is
/// in the background and its UI cannot. The same request the UI makes for a tap
/// (PUT /api/trips/:trip/packing|todo/:item with the box's new state), with the
/// session the WebView holds (its cookies) and the tick's id as the idempotency
/// key, so a tick that also reaches the server another way is taken once.
enum TrekWatchWriter {
private final class BackgroundTask {
var id = UIBackgroundTaskIdentifier.invalid
func end() {
guard id != .invalid else { return }
UIApplication.shared.endBackgroundTask(id)
id = .invalid
}
}
/// Called on the main queue, as is `completion`.
static func write(_ action: [String: Any], completion: @escaping (Bool) -> Void) {
guard let request = Self.request(for: action) else {
completion(false)
return
}
let task = BackgroundTask()
task.id = UIApplication.shared.beginBackgroundTask(withName: "trek.watch.tick") { task.end() }
let finish: (Bool) -> Void = { written in
DispatchQueue.main.async {
completion(written)
task.end()
}
}
perform(request) { _, ok in finish(ok) }
}
/// Sends a request with the WebView's session (its cookies for the server).
/// Called on the main queue; `completion` runs on a background queue.
static func perform(_ request: URLRequest, completion: @escaping (Data?, Bool) -> Void) {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
guard let host = request.url?.host else { return completion(nil, false) }
let session = cookies.filter { cookie in
let domain = cookie.domain.hasPrefix(".") ? String(cookie.domain.dropFirst()) : cookie.domain
return host == domain || host.hasSuffix("." + domain)
}
guard !session.isEmpty else { return completion(nil, false) }
var signed = request
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
signed.setValue(value, forHTTPHeaderField: field)
}
TrekClientCertSessionDelegate.session.dataTask(with: signed) { data, response, error in
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
completion(data, error == nil && (200..<300).contains(status))
}.resume()
}
}
/// The server's address for an API path, or nil without a server.
static func url(_ path: [String]) -> URL? {
guard let server = TrekServerStore.url, let base = URL(string: server),
base.scheme == "https" || base.scheme == "http" else { return nil }
return path.reduce(base.appendingPathComponent("api")) { $0.appendingPathComponent($1) }
}
/// The write for a tick as the watch sends it, or nil for anything else.
static func request(for action: [String: Any]) -> URLRequest? {
guard let server = TrekServerStore.url, let base = URL(string: server),
base.scheme == "https" || base.scheme == "http",
let id = action["id"] as? String, !id.isEmpty, id.count <= 64,
let tripId = action["tripId"] as? Int, let itemId = action["itemId"] as? Int,
let list = action["list"] as? String, list == "packing" || list == "todo",
let done = action["done"] as? Bool,
let body = try? JSONSerialization.data(withJSONObject: ["checked": done]) else { return nil }
let url = base.appendingPathComponent("api").appendingPathComponent("trips")
.appendingPathComponent(String(tripId)).appendingPathComponent(list).appendingPathComponent(String(itemId))
var request = URLRequest(url: url, timeoutInterval: 10)
request.httpMethod = "PUT"
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("watch-\(id)", forHTTPHeaderField: "X-Idempotency-Key")
request.httpBody = body
return request
}
}
/// Brings the watch's packing list and to-dos up to date while the app is in the
/// background, woken by the server's silent push (WatchSyncPushService in TREK):
/// reads both lists of the trip the watch shows from the server and puts them
/// into the snapshot the watch has, built the way the UI builds them
/// (watchPacking and watchTodos in client/src/native/watch.ts). Everything else
/// in the snapshot stays as the UI last made it. In front, the UI does this
/// itself as soon as the lists change, so nothing is done here then.
enum TrekWatchRefresher {
private static let maxName = 80
private static let maxGroup = 40
/// Below what WatchConnectivity carries in one application context.
private static let maxBytes = 55_000
static func refresh(completion: @escaping (UIBackgroundFetchResult) -> Void) {
guard TrekWatchLink.isAvailable, UIApplication.shared.applicationState != .active,
let stored = TrekWatchLink.shared.storedSnapshot(),
var snapshot = (try? JSONSerialization.jsonObject(with: stored)) as? [String: Any],
let trip = snapshot["trip"] as? [String: Any], let tripId = trip["id"] as? Int,
let packingURL = TrekWatchWriter.url(["trips", String(tripId), "packing"]),
let todoURL = TrekWatchWriter.url(["trips", String(tripId), "todo"]) else {
return completion(.noData)
}
let group = DispatchGroup()
var packing: [[String: Any]]?
var todos: [[String: Any]]?
group.enter()
items(at: packingURL) { list in
packing = list
group.leave()
}
group.enter()
items(at: todoURL) { list in
todos = list
group.leave()
}
group.notify(queue: .main) {
guard let packing, let todos else { return completion(.failed) }
snapshot["packing"] = packingList(packing)
snapshot["todos"] = todoList(todos)
snapshot["generatedAt"] = ISO8601DateFormatter().string(from: Date())
guard let data = fitted(snapshot) else { return completion(.failed) }
TrekWatchLink.shared.publish(data)
completion(.newData)
}
}
/// The `items` of a list endpoint, on the main queue; nil when it could not be read.
private static func items(at url: URL, then completion: @escaping ([[String: Any]]?) -> Void) {
var request = URLRequest(url: url, timeoutInterval: 10)
request.httpShouldHandleCookies = false
request.setValue("application/json", forHTTPHeaderField: "Accept")
TrekWatchWriter.perform(request) { data, ok in
var list: [[String: Any]]?
if ok, let data, let body = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
list = body["items"] as? [[String: Any]]
}
DispatchQueue.main.async { completion(list) }
}
}
private static func done(_ item: [String: Any]) -> Bool {
(item["checked"] as? Bool) ?? ((item["checked"] as? Int).map { $0 != 0 } ?? false)
}
private static func trimmed(_ value: Any?, _ max: Int) -> String? {
guard let text = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
return String(text.prefix(max))
}
private static func entry(_ item: [String: Any]) -> [String: Any]? {
guard let id = item["id"] as? Int, let name = item["name"] as? String else { return nil }
var out: [String: Any] = ["id": id, "name": String(name.prefix(maxName)), "done": done(item)]
if let group = trimmed(item["category"], maxGroup) { out["group"] = group }
return out
}
/// Unpacked first, then packed, each in the list's own order.
static func packingList(_ items: [[String: Any]]) -> Any {
guard !items.isEmpty else { return NSNull() }
let sorted = items.sorted {
let (a, b) = (done($0) ? 1 : 0, done($1) ? 1 : 0)
return a != b ? a < b : (($0["sort_order"] as? Int) ?? 0) < (($1["sort_order"] as? Int) ?? 0)
}
let packed = items.filter { done($0) }.count
return ["items": sorted.compactMap(entry), "packed": packed, "total": items.count]
}
/// Open ones first by due date (undated last) and priority, then the done ones.
static func todoList(_ items: [[String: Any]]) -> Any {
guard !items.isEmpty else { return NSNull() }
let due = { (item: [String: Any]) -> String? in trimmed(item["due_date"], 10) }
let sorted = items.sorted { a, b in
if done(a) != done(b) { return !done(a) }
if (due(a) == nil) != (due(b) == nil) { return due(a) != nil }
if let da = due(a), let db = due(b), da != db { return da < db }
let (pa, pb) = ((a["priority"] as? Int) ?? 0, (b["priority"] as? Int) ?? 0)
if pa != pb { return pa > pb }
return ((a["sort_order"] as? Int) ?? 0) < ((b["sort_order"] as? Int) ?? 0)
}
let rows = sorted.compactMap { item -> [String: Any]? in
guard var row = entry(item) else { return nil }
if let date = due(item) { row["due"] = date }
if let priority = item["priority"] as? Int, priority > 0 { row["priority"] = min(3, priority) }
return row
}
return ["items": rows, "open": items.filter { !done($0) }.count, "total": items.count]
}
/// The snapshot as JSON within the size the watch takes: done items go first, as on the UI's side.
private static func fitted(_ snapshot: [String: Any]) -> Data? {
var object = snapshot
// A list that is now empty leaves the snapshot, as the UI leaves it out.
for key in ["packing", "todos"] where object[key] is NSNull { object.removeValue(forKey: key) }
guard var data = try? JSONSerialization.data(withJSONObject: object) else { return nil }
if data.count > maxBytes {
for key in ["packing", "todos"] {
guard var list = object[key] as? [String: Any], let items = list["items"] as? [[String: Any]] else { continue }
list["items"] = items.filter { ($0["done"] as? Bool) != true }
object[key] = list
}
data = (try? JSONSerialization.data(withJSONObject: object)) ?? data
}
return data
}
}
+4
View File
@@ -12,7 +12,9 @@ let package = Package(
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"), .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"),
.package(name: "CapacitorActionSheet", path: "../../../../node_modules/@capacitor/action-sheet"),
.package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"), .package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"),
.package(name: "CapacitorDialog", path: "../../../../node_modules/@capacitor/dialog"),
.package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"), .package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"),
.package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"), .package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"), .package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"),
@@ -26,7 +28,9 @@ let package = Package(
dependencies: [ dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorActionSheet", package: "CapacitorActionSheet"),
.product(name: "CapacitorApp", package: "CapacitorApp"), .product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorDialog", package: "CapacitorDialog"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"), .product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
@@ -54,6 +54,10 @@ enum ShareHandoff {
static func store(_ item: ShareItem, tripId: Int?) -> Bool { static func store(_ item: ShareItem, tripId: Int?) -> Bool {
var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType] var meta: [String: Any] = ["name": item.name, "mimeType": item.mimeType]
if let tripId { meta["tripId"] = tripId } if let tripId { meta["tripId"] = tripId }
if let url = item.url {
meta["url"] = url
meta["title"] = item.title
}
guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false } guard let metaData = try? JSONSerialization.data(withJSONObject: meta) else { return false }
return write(dataAccount, item.data) && write(metaAccount, metaData) return write(dataAccount, item.data) && write(metaAccount, metaData)
} }
+35 -10
View File
@@ -32,6 +32,9 @@ struct ShareItem {
var title: String var title: String
/// A host, for a page. /// A host, for a page.
var source: String? var source: String?
/// The web address it came from, when it is a page or a link: the app offers to
/// keep it among the trip's links as well as to read a booking out of it.
var url: String? = nil
var sizeLabel: String { var sizeLabel: String {
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file) ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
@@ -88,6 +91,10 @@ enum ShareLoader {
let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? "" let text = (item as? String) ?? (item as? Data).flatMap { String(data: $0, encoding: .utf8) } ?? ""
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw ShareLoadError.unsupported } guard !trimmed.isEmpty else { throw ShareLoadError.unsupported }
// A text that is just a link (what many apps share) is that link.
if !trimmed.contains(where: \.isWhitespace), let url = URL(string: trimmed), webAddress(url) != nil {
return try await page(at: url)
}
let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed let firstLine = trimmed.split(whereSeparator: \.isNewline).first.map(String.init) ?? trimmed
return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain", return try checked(ShareItem(kind: .text, name: "shared-text.txt", mimeType: "text/plain",
data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil)) data: Data(trimmed.utf8), title: String(firstLine.prefix(80)), source: nil))
@@ -133,21 +140,39 @@ enum ShareLoader {
let url = (results["url"] as? String).flatMap(URL.init(string:)) let url = (results["url"] as? String).flatMap(URL.init(string:))
let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page" let title = (results["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? url?.host ?? "Page"
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html", return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: Data(html.utf8), title: title, source: url?.host)) data: Data(html.utf8), title: title, source: url?.host,
url: url.flatMap(webAddress)))
} }
/// A link shared from an app other than Safari: the page as the server sends it. /// A link shared from an app other than Safari: the page as the server sends it,
/// or the bare link when the page cannot be read (a sign-in wall, an app-only
/// page, no network), which the app can still keep among the trip's links.
private static func page(at url: URL) async throws -> ShareItem { private static func page(at url: URL) async throws -> ShareItem {
guard url.scheme == "https" || url.scheme == "http" else { throw ShareLoadError.unsupported } guard let address = webAddress(url) else { throw ShareLoadError.unsupported }
var request = URLRequest(url: url, timeoutInterval: 10) var request = URLRequest(url: url, timeoutInterval: 10)
request.setValue("text/html", forHTTPHeaderField: "Accept") request.setValue("text/html", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request) if let fetched = try? await URLSession.shared.data(for: request),
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let http = fetched.1 as? HTTPURLResponse, (200..<300).contains(http.statusCode),
(http.mimeType ?? "").contains("html") else { throw ShareLoadError.unsupported } (http.mimeType ?? "").contains("html"), !fetched.0.isEmpty, fetched.0.count <= maxBytes {
let html = String(decoding: data, as: UTF8.self) let data = fetched.0
let title = pageTitle(html) ?? url.host ?? "Page" let html = String(decoding: data, as: UTF8.self)
return try checked(ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html", let title = pageTitle(html) ?? url.host ?? "Page"
data: data, title: title, source: url.host)) return ShareItem(kind: .page, name: "\(slug(title)).html", mimeType: "text/html",
data: data, title: title, source: url.host, url: address)
}
return link(url, address)
}
/// The link alone, as a text/uri-list the app recognises as nothing to read a booking from.
private static func link(_ url: URL, _ address: String) -> ShareItem {
ShareItem(kind: .page, name: "link.url", mimeType: "text/uri-list", data: Data(address.utf8),
title: url.host ?? address, source: url.host, url: address)
}
/// An http(s) address, the only kind a trip's links take.
private static func webAddress(_ url: URL) -> String? {
guard url.scheme == "https" || url.scheme == "http", url.host != nil else { return nil }
return url.absoluteString
} }
private static func fromFile(_ url: URL) throws -> ShareItem { private static func fromFile(_ url: URL) throws -> ShareItem {
+20 -2
View File
@@ -100,9 +100,27 @@ final class WatchLink: NSObject, WCSessionDelegate {
try? await Task.sleep(nanoseconds: 300_000_000) try? await Task.sleep(nanoseconds: 300_000_000)
} }
/// A tick to the phone. In reach it goes as a message, which arrives at once and
/// wakes the phone app for it, so the trip's other devices see it within seconds.
/// Out of reach, or when the message fails or a phone app too old to take it
/// answers without "queued", it goes as user info, which the system delivers later.
func send(_ action: PendingAction) { func send(_ action: PendingAction) {
guard WCSession.default.activationState == .activated else { return } let session = WCSession.default
WCSession.default.transferUserInfo(["action": action.message]) guard session.activationState == .activated else { return }
let message: [String: Any] = ["action": action.message]
guard session.isReachable else {
session.transferUserInfo(message)
return
}
session.sendMessage(message, replyHandler: { reply in
if reply["queued"] as? Bool == true {
Task { @MainActor in WatchModel.shared.markSent(action.id) }
} else {
session.transferUserInfo(message)
}
}, errorHandler: { _ in
session.transferUserInfo(message)
})
} }
/// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored). /// Asks the phone for a fresh snapshot while it is in reach (the app answers from what it stored).
@@ -0,0 +1,266 @@
import SwiftUI
/// The trip's next flight, or the one in the air: both airports large, the
/// route as an arc with the aircraft riding it, the moment in words with a
/// countdown the system keeps running, and the delay. Between two snapshots
/// the widget moves on with the clock: it takes off at the observed (or
/// planned) time, flies along the arc and lands at the expected (or planned)
/// arrival.
struct FlightContent: View {
let snapshot: WidgetSnapshot?
let family: TrekFamily
let now: Date
@Environment(\.colorScheme) private var scheme
private let accent = Trek.booking("flight").color
var body: some View {
if let flight = snapshot?.flight, let labels = snapshot?.labels {
let moment = FlightMoment(flight, now: now)
switch family {
case .circular: circular(flight, moment)
case .rectangular: rectangular(flight, moment, labels)
case .inline: inline(flight, moment, labels)
case .small: small(flight, moment, labels)
case .medium: medium(flight, moment, labels)
default: large(flight, moment, labels)
}
} else {
empty(snapshot?.labels)
}
}
// MARK: Small: the moment above, the route below
private func small(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(accent)
Text(f.number).font(.system(size: 12, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
Spacer(minLength: 0)
delayChip(f, labels, compact: true)
}
Spacer(minLength: 0)
momentText(f, m, labels)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(m.stage == .airborne ? accent : Trek.ink(scheme))
.lineLimit(2)
.minimumScaleFactor(0.85)
Spacer(minLength: 6)
HStack(alignment: .firstTextBaseline) {
Text(f.from).font(Trek.number(24, .heavy)).foregroundStyle(Trek.ink(scheme))
Spacer(minLength: 2)
Text(f.to).font(Trek.number(24, .heavy)).foregroundStyle(Trek.ink(scheme))
}
RouteArc(progress: m.progress, color: accent).flipsForRightToLeftLayoutDirection(true).frame(height: 20).padding(.vertical, 3)
HStack {
Text(f.depTime).font(Trek.number(12, .bold)).foregroundStyle(Trek.muted(scheme))
Spacer(minLength: 2)
Text(f.arrTimeExpected ?? f.arrTime).font(Trek.number(12, .bold)).foregroundStyle(Trek.muted(scheme))
}
}
}
// MARK: Medium: a boarding pass
private func medium(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 7) {
Text(labels.flight ?? "").trekEyebrow(scheme)
Text(f.number).font(.system(size: 11, weight: .heavy)).foregroundStyle(accent)
Spacer(minLength: 4)
delayChip(f, labels, compact: false)
}
Spacer(minLength: 4)
HStack(alignment: .center, spacing: 10) {
airport(f.from, f.fromCity, time: f.depTime, planned: nil, align: .leading)
VStack(spacing: 4) {
RouteArc(progress: m.progress, color: accent).flipsForRightToLeftLayoutDirection(true).frame(height: 30)
progressBar(m)
}
.frame(maxWidth: .infinity)
airport(f.to, f.toCity, time: f.arrTimeExpected ?? f.arrTime, planned: f.arrTimeExpected.flatMap { $0 == f.arrTime ? nil : f.arrTime }, align: .trailing)
}
Spacer(minLength: 6)
momentText(f, m, labels)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(m.stage == .airborne ? accent : Trek.ink(scheme))
.lineLimit(1)
.minimumScaleFactor(0.85)
}
}
// MARK: Large and extra large: the pass with the details under it
private func large(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
VStack(alignment: .leading, spacing: 12) {
medium(f, m, labels).frame(maxHeight: 150)
Rectangle().fill(Trek.track(scheme)).frame(height: 0.8)
LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], spacing: 8) {
if let aircraft = f.aircraft { detail(systemImage: "airplane.circle", value: aircraft) }
detail(systemImage: "clock", value: "\(f.depTime) → \(f.arrTimeExpected ?? f.arrTime)")
if let fromCity = f.fromCity { detail(systemImage: "airplane.departure", value: fromCity) }
if let toCity = f.toCity { detail(systemImage: "airplane.arrival", value: toCity) }
}
Spacer(minLength: 0)
}
}
// MARK: Lock screen
private func circular(_ f: WidgetSnapshot.Flight, _ m: FlightMoment) -> some View {
ProgressRing(value: m.progress, lineWidth: 5, colors: [.white], trackColor: Color.white.opacity(0.25)) {
Image(systemName: m.stage == .landed ? "airplane.arrival" : m.stage == .airborne ? "airplane" : "airplane.departure")
.font(.system(size: 17, weight: .bold))
}
.padding(4)
}
private func rectangular(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "airplane").font(.system(size: 11, weight: .bold))
Text("\(f.number) \(f.from) → \(f.to)").font(.system(size: 12, weight: .heavy)).lineLimit(1)
}
momentText(f, m, labels).font(.system(size: 13, weight: .semibold)).lineLimit(1)
if m.stage == .airborne {
ProgressView(timerInterval: m.start...m.end, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
.progressViewStyle(.linear)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func inline(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> some View {
Text("\(Image(systemName: "airplane")) \(f.number) ") + momentText(f, m, labels)
}
// MARK: Parts
private func airport(_ code: String, _ city: String?, time: String, planned: String?, align: HorizontalAlignment) -> some View {
VStack(alignment: align, spacing: 1) {
Text(code).font(Trek.number(28, .heavy)).foregroundStyle(Trek.ink(scheme))
if let city { Text(city).font(.system(size: 10.5, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
if let planned { Text(planned).font(Trek.number(10.5, .bold)).strikethrough().foregroundStyle(Trek.faint(scheme)) }
Text(time).font(Trek.number(14, .heavy)).foregroundStyle(Trek.ink(scheme))
}
.frame(maxWidth: 96, alignment: align == .leading ? .leading : .trailing)
}
@ViewBuilder
private func progressBar(_ m: FlightMoment) -> some View {
if m.stage == .airborne {
// The system runs this bar on its own, so it fills between two snapshots too.
ProgressView(timerInterval: m.start...m.end, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
.progressViewStyle(.linear)
.tint(accent)
} else {
Capsule().fill(m.stage == .landed ? accent : Trek.track(scheme)).frame(height: 4)
}
}
private func detail(systemImage: String, value: String) -> some View {
HStack(spacing: 6) {
Image(systemName: systemImage).font(.system(size: 12, weight: .semibold)).foregroundStyle(accent)
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Trek.card(scheme)))
}
/// "Departs in 2 hr, 10 min", "Lands in 1 hr", "Landed": the countdowns are the system's own running text.
private func momentText(_ f: WidgetSnapshot.Flight, _ m: FlightMoment, _ labels: WidgetSnapshot.Labels) -> Text {
switch m.stage {
case .upcoming:
return Countdown.text(labels.flightDeparts ?? "{time}", until: m.start)
case .ready:
return Text(labels.flightReady ?? "")
case .airborne:
// "In the air" is what the blue line and the moving aircraft say; the words count down to landing.
return Countdown.text(labels.flightLands ?? "{time}", until: m.end)
case .landed:
return Text(labels.flightLanded ?? "")
}
}
@ViewBuilder
private func delayChip(_ f: WidgetSnapshot.Flight, _ labels: WidgetSnapshot.Labels, compact: Bool) -> some View {
if let delay = f.delay {
let late = delay > 5
let early = delay < -5
let color = late ? (delay >= 30 ? Trek.danger : Trek.pending) : Trek.confirmed
let text = late
? (labels.flightLate ?? "+{m}").replacingOccurrences(of: "{m}", with: "\(delay)")
: early ? (labels.flightEarly ?? "{m}").replacingOccurrences(of: "{m}", with: "\(-delay)") : (labels.flightOnTime ?? "")
if !(compact && !late) {
Text(text)
.font(.system(size: 10.5, weight: .heavy))
.foregroundStyle(color)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(color.opacity(scheme == .dark ? 0.22 : 0.14)))
.lineLimit(1)
}
}
}
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(labels?.flight ?? "").trekEyebrow(scheme)
Spacer(minLength: 0)
Image(systemName: "airplane").font(.system(size: 30, weight: .bold)).foregroundStyle(accent.opacity(0.35))
Text(labels?.noFlight ?? labels?.noBookings ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
}
}
/// Where a flight stands at a given moment, from the snapshot's times. What the
/// feed saw wins: a flight still at the gate past its planned departure is not
/// flown along the arc, and a flight is only landed once confirmed. Only a
/// flight the feed never heard of moves on by its schedule alone.
struct FlightMoment {
enum Stage { case upcoming, ready, airborne, landed }
let stage: Stage
/// 0 at the gate, 1 at the destination.
let progress: Double
/// Take-off and touchdown the moment is measured between: observed or expected where known, planned otherwise.
let start: Date
let end: Date
init(_ f: WidgetSnapshot.Flight, now: Date) {
let start = Date(timeIntervalSince1970: f.depActual ?? f.depExpected ?? f.dep)
let end = max(Date(timeIntervalSince1970: f.arrExpected ?? f.arr), start.addingTimeInterval(60))
self.start = start
self.end = end
let heard = f.seen != nil
let flying = f.airborne == true || f.depActual != nil || (!heard && now >= start)
// A flight the feed lost stops counting two hours after its arrival.
if f.landed == true || (!heard && now >= end) || now >= end.addingTimeInterval(7200) {
stage = .landed
progress = 1
} else if flying {
stage = .airborne
// Unconfirmed, it never quite arrives on the arc.
progress = min(0.98, max(0, now.timeIntervalSince(start) / end.timeIntervalSince(start)))
} else if now >= start {
stage = .ready
progress = 0
} else {
stage = .upcoming
progress = 0
}
}
}
/// A sentence with "{time}" in it, the placeholder replaced by a countdown the system keeps running.
enum Countdown {
static func text(_ sentence: String, until date: Date) -> Text {
let parts = sentence.components(separatedBy: "{time}")
guard parts.count == 2 else { return Text(sentence) }
return Text(parts[0]) + Text(date, style: .relative) + Text(parts[1])
}
}
@@ -158,6 +158,30 @@ private struct BookingsFamilyView: View {
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) } var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
} }
// MARK: - Flight
struct FlightWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: "TripFlight", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
TrekContainer(link: entry.snapshot?.flight.flatMap { WidgetSnapshot.link($0.path) }) {
FlightFamilyView(entry: entry)
} background: {
TrekPaper()
}
}
.configurationDisplayName("widget.flight.name")
.description("widget.flight.description")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
private struct FlightFamilyView: View {
@Environment(\.widgetFamily) private var family
let entry: TrekEntry
var body: some View { FlightContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
}
// MARK: - To-dos // MARK: - To-dos
struct TodosWidget: Widget { struct TodosWidget: Widget {
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// The TREK widgets: the trip, today's plan, the bookings ahead, the to-dos, /// The TREK widgets: the trip, today's plan, the bookings ahead, the next flight, the to-dos,
/// the packing list, the Atlas and the travel group on the home and lock /// the packing list, the Atlas and the travel group on the home and lock
/// screen, and the travel day as a Live Activity. /// screen, and the travel day as a Live Activity.
@main @main
@@ -10,6 +10,7 @@ struct TREKWidgetsBundle: WidgetBundle {
NextTripWidget() NextTripWidget()
TodayWidget() TodayWidget()
BookingsWidget() BookingsWidget()
FlightWidget()
TodosWidget() TodosWidget()
PackingWidget() PackingWidget()
AtlasWidget() AtlasWidget()
@@ -115,6 +115,29 @@ extension WidgetSnapshot {
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure" poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
) )
) )
// In the air on a running trip, two days ahead before one.
let departure = running ? now.addingTimeInterval(-4 * 3600) : now.addingTimeInterval(2 * 86_400 + 5 * 3600)
snapshot.flight = Flight(
number: "LH 716", from: "FRA", to: "HND", fromCity: "Frankfurt", toCity: "Tokyo Haneda",
dep: departure.timeIntervalSince1970, arr: departure.addingTimeInterval(12 * 3600 + 25 * 60).timeIntervalSince1970,
depActual: running ? departure.addingTimeInterval(18 * 60).timeIntervalSince1970 : nil,
depExpected: nil, airborne: running ? true : nil,
arrExpected: running ? departure.addingTimeInterval(12 * 3600 + 41 * 60).timeIntervalSince1970 : nil,
landed: nil, depTime: "13:05", arrTime: "08:30", arrTimeExpected: running ? "08:46" : nil,
delay: running ? 16 : nil, aircraft: running ? "A359 D-AIXA" : nil, seen: running ? now.timeIntervalSince1970 : nil,
path: "/trips/1?booking=1"
)
snapshot.labels.flight = "Flight"
snapshot.labels.flightDeparts = "Departs in {time}"
snapshot.labels.flightInAir = "In the air"
snapshot.labels.flightLands = "Lands in {time}"
snapshot.labels.flightLanded = "Landed"
snapshot.labels.flightOnTime = "On time"
snapshot.labels.flightLate = "+{m} min"
snapshot.labels.flightEarly = "{m} min early"
snapshot.labels.flightEstimated = "estimated"
snapshot.labels.flightReady = "Ready for departure"
snapshot.labels.noFlight = "No flight ahead"
snapshot.packing?.bags = [ snapshot.packing?.bags = [
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19), .init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12), .init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
@@ -174,6 +197,7 @@ extension WidgetSnapshot {
snapshot.packing = nil snapshot.packing = nil
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: []) snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
snapshot.collab = nil snapshot.collab = nil
snapshot.flight = nil
return snapshot return snapshot
} }
} }
@@ -16,6 +16,27 @@ struct TripDayAttributes: ActivityAttributes {
/// Stops behind, out of the day's stops. /// Stops behind, out of the day's stops.
var done: Int var done: Int
var total: Int var total: Int
/// The day's flight while it is near or in the air, so the activity turns into
/// a boarding pass. Optional: a state written by an older app decodes without it.
var flight: Flight?
}
/// A flight the activity follows on its own between two updates: the system
/// runs the bar and the countdown from the two times.
struct Flight: Codable, Hashable {
var number: String
var from: String
var to: String
/// Take-off and touchdown, epoch seconds: observed or expected where known.
var dep: Double
var arr: Double
/// The clock at either airport, formatted by the app.
var depTime: String
var arrTime: String
/// Minutes late, for the colour of the arrival time.
var delay: Int?
/// In the air when the app last looked: the countdown runs to touchdown.
var airborne: Bool?
} }
var tripTitle: String var tripTitle: String
@@ -7,11 +7,17 @@ import WidgetKit
struct TripDayLiveActivity: Widget { struct TripDayLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: TripDayAttributes.self) { context in ActivityConfiguration(for: TripDayAttributes.self) { context in
TripDayBanner( Group {
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel, if let flight = context.state.flight {
nextName: context.state.nextName, nextTime: context.state.nextTime, FlightBanner(flight: flight, tripTitle: context.attributes.tripTitle)
done: context.state.done, total: context.state.total } else {
) TripDayBanner(
tripTitle: context.attributes.tripTitle, dayLabel: context.state.dayLabel,
nextName: context.state.nextName, nextTime: context.state.nextTime,
done: context.state.done, total: context.state.total
)
}
}
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92)) .activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
.widgetURL(WidgetSnapshot.link(context.attributes.path)) .widgetURL(WidgetSnapshot.link(context.attributes.path))
@@ -33,17 +39,32 @@ struct TripDayLiveActivity: Widget {
} }
} }
DynamicIslandExpandedRegion(.bottom) { DynamicIslandExpandedRegion(.bottom) {
VStack(alignment: .leading, spacing: 8) { if let flight = state.flight {
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1) FlightPass(flight: flight).padding(.horizontal, 4)
DayStopProgress(done: state.done, total: state.total) } else {
VStack(alignment: .leading, spacing: 8) {
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
DayStopProgress(done: state.done, total: state.total)
}
.padding(.horizontal, 4)
} }
.padding(.horizontal, 4)
} }
} compactLeading: { } compactLeading: {
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed) if let flight = state.flight {
HStack(spacing: 3) {
Image(systemName: "airplane").foregroundStyle(Trek.info)
Text(flight.to).font(Trek.number(13, .heavy))
}
} else {
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed)
}
} compactTrailing: { } compactTrailing: {
Text(state.nextTime ?? "\(state.done)/\(state.total)") if let flight = state.flight {
.font(Trek.number(13, .heavy)) FlightCountdown(flight: flight).font(Trek.number(13, .heavy)).frame(maxWidth: 64)
} else {
Text(state.nextTime ?? "\(state.done)/\(state.total)")
.font(Trek.number(13, .heavy))
}
} minimal: { } minimal: {
ZStack { ZStack {
Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5) Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5)
@@ -59,3 +80,76 @@ struct TripDayLiveActivity: Widget {
} }
} }
} }
/// The day's flight on the lock screen: both airports, the bar the system runs
/// from take-off to touchdown, and a countdown to whichever comes next.
struct FlightBanner: View {
let flight: TripDayAttributes.Flight
let tripTitle: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(Trek.info)
Text(flight.number).font(.system(size: 12, weight: .heavy)).foregroundStyle(.white)
Text(tripTitle)
.font(.system(size: 11, weight: .bold))
.tracking(0.5)
.textCase(.uppercase)
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
Spacer(minLength: 4)
FlightCountdown(flight: flight).font(Trek.number(15, .heavy)).foregroundStyle(.white)
}
FlightPass(flight: flight)
}
.padding(16)
}
}
/// The two airports with their times and the bar between them.
struct FlightPass: View {
let flight: TripDayAttributes.Flight
var body: some View {
let start = Date(timeIntervalSince1970: flight.dep)
let end = Date(timeIntervalSince1970: flight.arr)
let late = (flight.delay ?? 0) > 5
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 0) {
Text(flight.from).font(Trek.number(26, .heavy)).foregroundStyle(.white)
Text(flight.depTime).font(Trek.number(13, .bold)).foregroundStyle(.white.opacity(0.7))
}
VStack(spacing: 4) {
Image(systemName: "airplane").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.info)
ProgressView(timerInterval: start...end, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
.progressViewStyle(.linear)
.tint(Trek.info)
}
.frame(maxWidth: .infinity)
VStack(alignment: .trailing, spacing: 0) {
Text(flight.to).font(Trek.number(26, .heavy)).foregroundStyle(.white)
Text(flight.arrTime).font(Trek.number(13, .bold)).foregroundStyle(late ? Trek.pending : .white.opacity(0.7))
}
}
}
}
/// A running countdown, in the system's own format: to touchdown once the app saw
/// the aircraft in the air, to take-off before. The system runs it between two
/// updates; the stage changes with the app's next update after take-off.
struct FlightCountdown: View {
let flight: TripDayAttributes.Flight
var body: some View {
let start = Date(timeIntervalSince1970: flight.dep)
let end = Date(timeIntervalSince1970: flight.arr)
let flying = flight.airborne == true
let target = flying ? end : start
HStack(spacing: 3) {
Image(systemName: flying ? "airplane.arrival" : "airplane.departure")
Text(timerInterval: min(Date(), target)...target, countsDown: true)
.multilineTextAlignment(.trailing)
}
}
}
@@ -160,6 +160,38 @@ struct WidgetSnapshot: Codable {
} }
} }
/// The trip's next flight or the one in the air, for the flight widget.
/// Times are epoch seconds, so the widget moves its countdown and its
/// aircraft on with the clock between two snapshots; the clock times at
/// either airport come formatted, the widget has no zone data.
struct Flight: Codable {
var number: String
var from: String
var to: String
var fromCity: String?
var toCity: String?
var dep: Double
var arr: Double
/// Observed take-off.
var depActual: Double?
/// Before take-off: the planned departure plus the delay seen so far.
var depExpected: Double?
/// The feed saw the aircraft in the air when the app last looked.
var airborne: Bool?
/// Touchdown once landed, else the estimate while in the air.
var arrExpected: Double?
var landed: Bool?
var depTime: String
var arrTime: String
var arrTimeExpected: String?
/// Minutes late against the schedule, negative when early.
var delay: Int?
var aircraft: String?
/// When the feed last heard the aircraft.
var seen: Double?
var path: String
}
struct Labels: Codable { struct Labels: Codable {
var nextTrip: String var nextTrip: String
var today: String var today: String
@@ -193,6 +225,22 @@ struct WidgetSnapshot: Codable {
var votes: String? var votes: String?
var noMessages: String? var noMessages: String?
var departure: String? var departure: String?
var flight: String?
/// Contains "{time}", where the widget puts its running countdown.
var flightDeparts: String?
var flightInAir: String?
/// Contains "{time}".
var flightLands: String?
var flightLanded: String?
var flightOnTime: String?
/// Contains "{m}".
var flightLate: String?
/// Contains "{m}".
var flightEarly: String?
var flightEstimated: String?
/// Past the departure time with the aircraft not yet in the air.
var flightReady: String?
var noFlight: String?
} }
var trip: Trip? var trip: Trip?
@@ -203,6 +251,7 @@ struct WidgetSnapshot: Codable {
var packing: Packing? var packing: Packing?
var atlas: Atlas? var atlas: Atlas?
var collab: Collab? var collab: Collab?
var flight: Flight?
var labels: Labels var labels: Labels
/// The dashboard's snapshot. /// The dashboard's snapshot.
@@ -5,6 +5,8 @@
"widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية."; "widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية.";
"widget.bookings.name" = "الحجوزات"; "widget.bookings.name" = "الحجوزات";
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها."; "widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
"widget.flight.name" = "الرحلة";
"widget.flight.description" = "رحلتك القادمة مباشرة: عد تنازلي للإقلاع، والطائرة في طريقها، والتأخير والوصول.";
"widget.todos.name" = "المهام"; "widget.todos.name" = "المهام";
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة."; "widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
"widget.packing.name" = "قائمة الأمتعة"; "widget.packing.name" = "قائمة الأمتعة";
@@ -5,6 +5,8 @@
"widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla."; "widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.";
"widget.bookings.name" = "Bronlar"; "widget.bookings.name" = "Bronlar";
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə."; "widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
"widget.flight.name" = "Uçuş";
"widget.flight.description" = "Növbəti uçuşun canlı: uçuşa qədər geri sayım, yolda olan təyyarə, gecikmə və gəliş.";
"widget.todos.name" = "Tapşırıqlar"; "widget.todos.name" = "Tapşırıqlar";
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu."; "widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
"widget.packing.name" = "Yük siyahısı"; "widget.packing.name" = "Yük siyahısı";
@@ -5,6 +5,8 @@
"widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada."; "widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada.";
"widget.bookings.name" = "Reserves"; "widget.bookings.name" = "Reserves";
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen."; "widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
"widget.flight.name" = "Vol";
"widget.flight.description" = "El teu proper vol en directe: compte enrere de la sortida, l’avió en camí, retard i arribada.";
"widget.todos.name" = "Tasques"; "widget.todos.name" = "Tasques";
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge."; "widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
"widget.packing.name" = "Equipatge"; "widget.packing.name" = "Equipatge";
@@ -5,6 +5,8 @@
"widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou."; "widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou.";
"widget.bookings.name" = "Rezervace"; "widget.bookings.name" = "Rezervace";
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají."; "widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
"widget.flight.name" = "Let";
"widget.flight.description" = "Tvůj další let živě: odpočet do odletu, letadlo na cestě, zpoždění a přílet.";
"widget.todos.name" = "Úkoly"; "widget.todos.name" = "Úkoly";
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení."; "widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
"widget.packing.name" = "Balení"; "widget.packing.name" = "Balení";
@@ -5,6 +5,8 @@
"widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp."; "widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.";
"widget.bookings.name" = "Buchungen"; "widget.bookings.name" = "Buchungen";
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen."; "widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
"widget.flight.name" = "Flug";
"widget.flight.description" = "Dein nächster Flug live: Countdown bis zum Abflug, die Maschine unterwegs, Verspätung und Ankunft.";
"widget.todos.name" = "To-dos"; "widget.todos.name" = "To-dos";
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist."; "widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
"widget.packing.name" = "Packliste"; "widget.packing.name" = "Packliste";
@@ -5,6 +5,8 @@
"widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση."; "widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.";
"widget.bookings.name" = "Κρατήσεις"; "widget.bookings.name" = "Κρατήσεις";
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι."; "widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
"widget.flight.name" = "Πτήση";
"widget.flight.description" = "Η επόμενη πτήση σου ζωντανά: αντίστροφη μέτρηση για την αναχώρηση, το αεροσκάφος καθ’ οδόν, καθυστέρηση και άφιξη.";
"widget.todos.name" = "Εκκρεμότητες"; "widget.todos.name" = "Εκκρεμότητες";
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα."; "widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
"widget.packing.name" = "Αποσκευές"; "widget.packing.name" = "Αποσκευές";
@@ -5,6 +5,8 @@
"widget.today.description" = "Today's plan as a timeline, with the weather and what is next."; "widget.today.description" = "Today's plan as a timeline, with the weather and what is next.";
"widget.bookings.name" = "Bookings"; "widget.bookings.name" = "Bookings";
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due."; "widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
"widget.flight.name" = "Flight";
"widget.flight.description" = "Your next flight live: departure countdown, the aircraft on its way, delay and arrival.";
"widget.todos.name" = "To-dos"; "widget.todos.name" = "To-dos";
"widget.todos.description" = "What is still open for the trip, and how far the packing has come."; "widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
"widget.packing.name" = "Packing"; "widget.packing.name" = "Packing";
@@ -5,6 +5,8 @@
"widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada."; "widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada.";
"widget.bookings.name" = "Reservas"; "widget.bookings.name" = "Reservas";
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan."; "widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
"widget.flight.name" = "Vuelo";
"widget.flight.description" = "Tu próximo vuelo en directo: cuenta atrás de la salida, el avión en camino, retraso y llegada.";
"widget.todos.name" = "Tareas"; "widget.todos.name" = "Tareas";
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta."; "widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
"widget.packing.name" = "Equipaje"; "widget.packing.name" = "Equipaje";
@@ -5,6 +5,8 @@
"widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega."; "widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.";
"widget.bookings.name" = "Broneeringud"; "widget.bookings.name" = "Broneeringud";
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on."; "widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
"widget.flight.name" = "Lend";
"widget.flight.description" = "Sinu järgmine lend otse: loendus väljumiseni, lennuk teel, hilinemine ja saabumine.";
"widget.todos.name" = "Ülesanded"; "widget.todos.name" = "Ülesanded";
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on."; "widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
"widget.packing.name" = "Pakkimine"; "widget.packing.name" = "Pakkimine";

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