How the two group features work, as built. Code across the backend and both apps
cites this file by section number — docs/CIRCLES_AND_CONVOYS.md section 6
and so on — so sections are stable; add at the end rather than renumbering.
Companion documents: the service’s own rules are in
BACKEND_SPEC.md (spec §11 in backend comments is its live
relay section, not §11 here), and the wire vocabulary is mirrored in
backend/Detour/Detour.Api/Live/LiveFrames.cs. Where this file and that file
disagree, the code wins and this file is the bug.
A convoy and a circle are the same thing wearing different policy.
Both are a named group with a membership table, gated on friendship, with a live position feed. What separates them is not structure but policy: how long the group lives, whether anything is written down, how often a phone speaks, and whether voice is allowed at all.
Building them as one entity was a decision, not an accident, and it comes with a rule that keeps it honest: if shared code has to ask “is this a convoy?” in more than about three places, the merge has gone one layer too deep. Today the count is two, both in the relay — the voice gate and the destination-vote gate — with every other difference expressed as data.
| Convoy | Circle | |
|---|---|---|
| For | A ride together | Family, housemates — who is where |
| Lifetime | Dies when the last member leaves | Persists, including while you are alone in it |
| Cadence | Seconds | Minutes |
| Position history | Nothing stored, ever | Latest fix per member, overwritten in place |
| Voice | Allowed by the rules; not carried today (§6.4) | Never |
| Shared destination vote | Yes | No |
| Sharing switch | Connected means sharing | Opt-in per member, pausable |
| Shared places, arrivals | — | Yes |
Both are invite-only, and an invite requires an accepted friendship. That is what makes membership mean “granted access” rather than “found the room”. It is also why the friends screen and the circles screen are two views of one relationship: you cannot be in a group with someone you are not friends with, so un-friending someone is not a partial withdrawal.
Neither ever exposes a member’s trips, traces or map. A group shares live position, and for circles a last-known position and arrival events. That is all.
One entity, discriminated by kind, with policy carried as columns rather than as
if kind == branches in the handlers:
| Table | Holds |
|---|---|
groups |
id, kind (convoy / circle), name, owner, created |
group_members |
group, member, status (invited / accepted), joined, is_sharing |
member_fixes |
one row per (group, member): lat, lon, accuracy, instant. Circles only. |
circle_places |
a place shared into a circle by one member: identifier, name, radius |
place_events |
arrival/departure records, newest-N per circle |
Two things carry the whole product distinction, and neither is an if in a
handler:
GroupKind.DropWhenEmpty — the kind is a SmartEnum stored by name, and
it answers whether an empty group should go, rather than the leave path
asking what kind it holds. Empty-group deletion is the single most likely place
for a shared handler to silently break circles: someone leaves your family
circle, you are alone in it, and it evaporates. Asking the kind means the leave
path has one behaviour and cannot forget a case.is_sharing on the membership, not on the group. Pausing is per person per
circle. On the group it would make one member’s pause everybody’s.member_fixes is one row per member, overwritten in place. No history table, no
trail — see §8.
Because kinds are stored by name, reordering the enum members must never
silently remap existing rows — the same rule as every other SmartEnum in the
backend.
Two endpoint namespaces over one implementation, so the API reads by intent and per-kind rules have an obvious home.
| Path | Notes |
|---|---|
POST/GET /api/convoys · /api/circles |
Create, list. Kind comes from the path. |
POST /api/groups/{id}/invitations |
Membership and friendship required |
POST /api/groups/{id}/invitations/respond |
Accept or decline |
DELETE /api/groups/{id}/membership |
Leave; drops live sockets and the leaver’s places and last fix |
PUT /api/circles/{id}/sharing |
Circles only — a convoy id answers 404, not “not applicable” |
POST/GET /api/circles/{id}/positions |
The low-cadence path (§10) |
POST/GET /api/circles/{id}/places · DELETE /api/circle-places/{id} |
Owner-only delete |
POST/GET /api/circles/{id}/events |
Arrivals and departures |
POST /api/me/fix |
One fix, fanned out to every group the caller shares with |
GET /api/live |
The WebSocket upgrade (§6) |
A group id that does not exist and a group you are not in produce the same answer, so ids cannot be enumerated.
Path naming, learned the hard way. Do not name anything under
/route…. A public hostname sharing a tunnel with GraphHopper matches/routeas a prefix, so/routes/*never reaches this service — it answers 404 from GraphHopper while the identical path returns 401 correctly on localhost. That is why route sharing lives at/shared-routes/*. Verify every new endpoint through the public hostname, not just the box.
| File | Role |
|---|---|
shared/…/data/Groups.kt |
Membership calls for both kinds, taking the kind as a parameter |
shared/…/data/CircleFixes.kt |
The low-cadence position path |
shared/…/data/CircleEvents.kt |
Arrival/departure feed and the on-device geofence evaluator |
shared/…/data/CirclePresence.kt |
The presence tick both platforms run: the guards, the sharing filter, the evaluator lifecycle, the trust check and the cadence. Takes its three clocks as parameters — the file’s own KDoc, “The three clocks”, says why they must never collapse into fewer. |
shared/…/data/CircleNotifyPolicy.kt |
Which circles want delivery, and which caught-up arrivals are worth raising (the cap, the stale window, and newest-first selection, which both platforms then deliver in reverse — see its KDoc). Decisions only; the wording and the delivery are elsewhere. |
shared/…/drive/RelayProtocol.kt |
The wire codec — decodes the nine inbound frame types, builds the seven outbound ones. Pure, no socket, no state. |
shared/…/drive/ConvoyRelay.kt |
The relay’s state machine — peers, push-to-talk membership, the spin vote, connect/backoff/reconnect — behind a RelaySocket seam. One implementation both platforms run, not two hand-rolled copies. |
shared/…/drive/RelaySocket.kt |
The seam: open/receive/send/close, with URL and bearer resolution left to whoever implements it |
app/…/net/OkHttpRelaySocket.kt |
Android’s RelaySocket, over OkHttp’s WebSocket |
app/…/net/ConvoyLiveClient.kt |
Android glue around ConvoyRelay: the Features.liveRelay/no-server guards, run-loop wiring, location forwarding |
app/…/convoy/ConvoyLiveService.kt |
Foreground service holding the socket while the screen is off |
app/…/tracking/TripTrackingService.kt |
Drives the circle tick — one collector, two sinks (§10). Owns the loop and the monotonic fix age; the decisions are CirclePresence’s. |
iosApp/Detour/UrlSessionRelaySocket.swift |
iOS’s RelaySocket, over URLSessionWebSocketTask |
iosApp/Detour/ConvoyLiveClient.swift |
iOS glue around ConvoyRelay, the same shape as the Android object above, ObservableObject-published for SwiftUI |
iosApp/Detour/CircleSync.swift |
The same, on iOS. Its fix age is monotonic: CLLocation carries no uptime-stamped time, so LocationBroadcast stamps each fix against ProcessInfo.systemUptime on receipt, back-dated by the delivery lag. One residual gap versus Android — systemUptime does not advance across device sleep, so a fix held over a suspend reads younger than it is. |
app/…/notif/CircleNotifyService.kt, PlaceNotifications.kt |
Android’s notification delivery: the foreground service, the channel, the PendingIntent. Policy comes from CircleNotifyPolicy. |
iosApp/Detour/CircleNotifications.swift |
iOS’s delivery: UNUserNotificationCenter, authorization, the foreground catch-up sweep. Same policy source. |
The two UIs stay entirely separate. A circle screen and a convoy screen have almost nothing visually in common, and merging them would be the one merge with no payoff.
One WebSocket at /api/live, authenticated by the same rider token as every
REST call — an ordinary endpoint on the ordinary port, not a second listener.
A rider in a circle all day who also starts a convoy for a ride needs both at once, so joining adds a membership rather than replacing it:
socket ──joined = {circle 2, convoy 7}──┬──► convoy 7 seconds
rider 3 │
└──► circle 2 minutes
Consequences worth knowing before touching it:
Every frame is one JSON object with a type. A malformed frame is dropped
rather than closing an otherwise fine connection, and a client that floods has
frames dropped silently — telling it which ones it lost would be a second
channel to flood.
type |
Keys | Rules |
|---|---|---|
join |
groupId |
Must be an accepted membership. Refusal comes back as error, worded the same whether the group is missing or you are not in it. |
location |
lat, lon, optional accuracyM, headingDeg, speedKmh, ts |
No groupId. One fix is one fix; fanning it out to every group the sender shares with is the relay’s job, not the phone’s. Dropped entirely if the sender has paused sharing. In a circle it also overwrites the sender’s stored last fix. |
spin_offer |
groupId, candidates[] of {lat, lon, distanceM?, durationS?, name?} |
Convoy only. 1–3 candidates. One invalid candidate voids the whole frame — a rider must never vote on a sheet missing an option their peers can see. |
spin_vote |
groupId, index |
Convoy only. Index 0–2; anything else is dropped rather than relayed as a vote for a candidate nobody offered. |
Voice frames (ptt_start, ptt_audio, ptt_end) are accepted off the wire and
dropped, exactly as an unknown type is — see §6.4.
None of these frames name the sender at all — the socket is already authenticated to one rider, so there is nothing to carry. The account id only shows up going the other way, in §6.3.
type |
Keys |
|---|---|
joined |
groupId |
error |
message — the client closes on this rather than sitting connected but never joined |
positions |
peers[] of {u, lat, lon, h?, s?, ts, ttl} |
left |
user |
spin_offer / spin_vote |
the client’s frame plus groupId and user |
place_event |
groupId, user, placeId, placeName, kind, ts |
Position keys are abbreviated and nothing else is: a position goes out several times a minute per peer, multiplied by peers × riders, while every other frame is rare enough that clarity is free.
u on a peer inside positions, and user on left, spin_offer,
spin_vote and place_event, all carry an account id — not a handle. The
keys did not change when this landed; only the type behind them did (u was a
username string, and is now a Guid). positions in particular carries no
handle anywhere in the frame: a peer’s display label comes from the group’s
membership, which the client already holds from having joined it, so it is not
worth repeating on the one frame that goes out several times a minute per
peer.
ttl is per peer, not a client-side constant. A convoy rider and a circle
member arrive on the same stream at wildly different cadences — 20 seconds for a
fix that came over a socket, 300 for one posted over HTTP — and a single
hardcoded staleness window either flickers circle members off the map between
updates or leaves a dropped convoy rider frozen on it.
place_event is server-originated only. A client cannot cause one by
sending it, which is why there is no inbound counterpart. It is also why there is
no push notification anywhere in this project: arrivals reach an open app through
this frame and a closed one by polling the feed, and adding real push would mean
device tokens, a vendor service and, for iOS, a paid developer account.
Push-to-talk is not carried today. The rules for it stand — convoy only, rejected for circles, one chunk bounded in size — but the relay drops the frames.
What comes back will be Opus over binary frames. Raw 16 kHz PCM base64’d into JSON cost roughly 40 KB/s per talker per listener, which is what made it worth deferring rather than porting. Both apps read this from one shared feature flag, so neither can quietly disagree with the other about what works.
Voice and destination votes are gated on kind, server-side, per frame.
Hiding a button in the UI is not the fix: if ptt_* or spin_* were relayed for
any group a socket has joined, every circle would silently gain always-on voice
between people who signed up for a dot on a map. The check is re-run per frame
rather than cached from join time — stricter, and it costs nothing.
| Dimension | Convoy | Circle | Expressed as |
|---|---|---|---|
| Lifetime | Dies when empty | Survives | GroupKind.DropWhenEmpty |
| Position retention | Nothing written | Latest fix only | member_fixes, written for circles only |
| Cadence | ~2 s | ~2 min | Client policy, no server code |
| Voice | Convoy-only rule | Never | Server-side kind check in the relay |
| Destination vote | Yes | Never | Server-side kind check in the relay |
| Sharing default | Connected = sharing | Opt-in, pausable | group_members.is_sharing |
| Size cap | None | 15 | Enforced on invite |
| Place events | — | Arrival / departure | Separate tables, circle-only |
Two of those rows are code that asks about kind — both in the relay, both the gate in §6.5. Everything else is data.
A circle whose positions live only in open sockets shows you nothing until the other person opens the app — which is not the product. So a circle stores one row per member: the latest fix, overwritten in place. No history, no trail. It exists only for circles you joined, only while that circle’s sharing switch is on, and leaving deletes it.
That is a real change in what this service is: for circles, it stops being purely a relay and becomes an observer. Defensible — opt-in, pausable, latest-fix-only, deleted on leave — but it is the one place the server keeps somebody’s position, and it should stay the only one.
Geofences are decided on the device — and, on Android, backed by the OS as
well (issue #91). A circle place has a radius; whether you crossed it is
GeofenceEvaluator’s dwell/hysteresis arithmetic either way. The fix stream
itself still never leaves the device, and only the resulting arrival or
departure is posted — that part is unchanged. What changed: Android also
registers an OS geofence (two, actually — one per guard) for any place the
rider is presently within a few km of (CirclePresence.PROXIMITY_GATE_RADIUS_M),
so the transition fires the moment it happens instead of waiting for the next
poll tick. Only places currently nearby are ever handed to Play services, not
the rider’s complete set — the poll tick stays as the correction path for
whatever the OS geofence misses (a dropped fence, a place added while
offline, a rider already inside the gate radius at cold start). This is a
deliberate, reasoned reversal of the original on-device-only design.
Two detectors, one announcement (issue #273). Both paths post through
CircleEvents.record, and it — not either caller — decides whether a
transition is the first announcement of a real change. Arrive and depart
alternate per place against a durable confirmed-inside set, so a fence and a
tick detecting the same crossing produce one event rather than two, and a cold
start whose in-memory dwell state is gone no longer re-announces an arrival
that is hours old. The two paths used to hold disjoint state and neither could
see what the other had posted; both halves of the rule now live in one place.
Because that memory is durable it outlives the process, so it can also drift: force-stopped for hours, a rider can leave a place with no departure ever announced. Every tick therefore reconciles the memory against the rider’s actual position before detecting anything new, announces any departure that was missed while nothing was watching, and drops claims for places that no longer exist. Without that pass a stale claim would swallow the rider’s next real arrival and the circle would show them parked somewhere permanently — a worse failure than the duplicate the gate removes. Suppressed announcements are kept, bounded, in Settings → Diagnostics, because a gate that drops events silently would make a genuinely missed transition invisible.
Pausing is enforced server-side as well as on the device. Trusting the client would mean a stale build keeps broadcasting after the user believes they stopped.
One collector, two sinks. The failure mode to design against is two
independent location subscriptions — the convoy’s and the circle’s — running
together during a ride and doubling the cost of the app’s most expensive feature.
There is one location stream; convoys and circles are both sinks on it, and the
highest active cadence wins. Neither platform opens a second subscription.
Circle arrive/depart is decided on-device by GeofenceEvaluator arithmetic
either way; on Android it’s also backed by an OS geofence for whichever
places the rider is presently near (issue #91) — no longer purely off the OS’s
per-app region-monitoring budget, though the proximity gate keeps the
registered set to a handful of nearby places rather than the rider’s full
set. (Android separately registers one further, unrelated geofence for
parked-state service dormancy, issue #90; it plays no part in circle
presence.)
Cadences, and why:
| Tick | Interval | Why |
|---|---|---|
| Convoy position | ~2 s | It is a live ride feed; anything slower reads as a frozen map |
| Circle position + geofence check, tracker up | 2 min | Presence, not a trail. “Last seen” stays current without a cost anyone notices, and the tick is a second sink on a location stream a trip, a joined convoy or a foregrounded map already pays for |
| Circle position + geofence check, parked | 15 min | Not a battery choice — WorkManager’s minimum period. Since #90 TripTrackingService stops while parked, so CircleSyncWorker carries the tick, and 15 min is the shortest period the platform allows for periodic work |
| Circle tick with no circle to share with | 30 min, tracker up | What a rider who never touches the feature pays, and the delay before their first circle starts working. Parked, they get the 15-minute floor instead: tick() returns this longer interval but CircleSyncWorker’s period is fixed, so the backoff has nothing to act on |
Transport follows from that: a circle at minute cadence does not justify holding
a socket open all day, so the low-cadence path is a plain POST of the latest
fix (§4), and the socket is for when a screen is actually watching. It also
degrades gracefully when the phone has no connectivity.