Skip to content

Android app

The APX Android app is a native shell around the daemon’s phone surface. It keeps the existing React mobile UI as the single interface implementation, while Android owns the floating mascot, system notifications, and app launch.

The app opens /mobile, which the panel redirects to /m/chat — the phone surface’s current address, where it sits alongside /m/tasks and /m/commitments behind a bottom tab bar.

  1. Make the daemon reachable from the phone, over LAN or an ADB reverse port.
  2. Run apx pair web on the daemon machine.
  3. Enter the daemon URL and printed pairing code in APX Android.
  4. Grant the notification permission when Android asks.
  5. Grant Display over other apps only when the floating mascot is wanted.

The app receives its own kind: android client token. It does not reuse the token stored by Chrome or an installed PWA because Android isolates each app’s storage. Existing web pairing remains valid; both clients appear in apx pair list and can be revoked independently.

Android does not let an app approve notification access or Do Not Disturb access for itself. A new installation therefore needs two explicit system switches for the complete car experience:

CapabilityOne-time actionRequired for
APX notificationsAccept Android’s notification promptEvery APX notification
Driving alertsAPX menu → Allow driving alerts, then enable APX in the system screenHeads-up cards while Android Auto enables Do Not Disturb
Trip detectionAPX menu → Enable trip detection, then enable APX notification accessReading Google Maps navigation state, and detecting an Android Auto session
LocationChoose While using the appSupplying the trip origin and the live position during a trip
OverlayEnable Display over other appsFloating mascot only; not needed for car cards
BatteryAPX menu → Remove battery restriction, then confirm Android’s dialogTrip start and end arriving on time while the phone is in a pocket

In Android Auto settings, keep Show message notifications enabled. Enable Show first line of messages when the card should expose APX text instead of only announcing a new message.

  • Normal launch opens <daemon>/mobile inside the app, not Chrome.
  • The mascot floats above the launcher and other apps, can be dragged, and remembers its position.
  • Its avatar follows Settings → Super-agent → Avatar. The authenticated WebSocket sends the current value on connect and every later change; Android stores it locally and repaints the overlay immediately.
  • It hides while the APX app itself is open and returns when APX goes to the background.
  • Inbound APX events trigger the same short message bubble as the desktop mascot, the bundled message sound, and an Android notification.
  • Tapping the mascot or notification opens the native app at /mobile.
  • The persistent mascot notification includes a Hide action.
  • An app-only ellipsis beside Preferences opens native options. Chrome and installed PWAs do not show it.
  • Native options can mute/unmute message sounds without disabling the mascot.
  • Preferences → Notifications reports Android’s real permission state. Tap it to open the app’s system notification settings.
  • The native menu can enable APX trip detection. After notification-listener access is granted, the app detects Google Maps navigation start and end from notification metadata, then shows a native car banner and a shortcut back to Maps. A new trip posts an authenticated trip.started event to /api/mobility/events, with the latest available location as origin. When Maps omits destination, an Android Auto card asks where the user is going; Reply accepts voice dictation and completes the same trip. Changing the destination starts a new evaluation. It uploads no route, location history, or complete notification content.
  • The trip banner opens the trip’s errands, not Maps: tapping it shows one card per pending errand, nearest first, carrying the same four chips the Telegram card offers — Navegar, Sumar a la ruta, Voy, En la siguiente. A “voy” pressed here reaches the same record a “voy” pressed in Telegram does, and earns the same follow-up after the trip; a card already answered shows the answer instead of re-offering the question. When an errand still has several possible shops the card says so, rather than presenting the nearest one as a decision APX has made. Its right-hand label carries the live count (“8 cerca”). The list comes from GET /api/mobility/trip, which is read-only — asking never fires a reminder — and answering goes to POST /api/mobility/errands/answer. Capped at eight errands.
  • Trip messages are intentionally rate-limited. A known destination must remain stable for 45 seconds; navigation without destination waits 10 minutes. Reopening the same destination stays silent for 30 minutes, and changing to a different destination cannot trigger another Roby evaluation for 5 minutes. Rapid cancel/restart tests update local state but produce no message burst. APX sends trip context to the daemon as soon as it detects it; these delays gate evaluation and alerts, not Roby’s awareness.
  • As an explicit fallback, choose Share trip progress → More → Share trip with APX in Maps on the phone. APX accepts Google Maps links only, attaches the permitted current location, and updates the active trip. Current Maps live-progress shares commonly contain only an opaque maps.app.goo.gl link, not destination text; APX must label those as shared progress and must not invent a destination. Android Auto may expose contacts only and hide app share targets; use the phone or APX voice reply in that case.
  • The daemon asks Roby to inspect pending tasks and commitments with read-only tools. User-configured trip events run immediately outside proactive rounds and interruption budgets, but they update Roby’s mobility context instead of forcing a Telegram notice. Roby sees current trip, last mobility question, and the user’s last button response in later chats and secretary rounds. It sends only a new concrete opportunity; SILENT is the default when nothing useful changed. Roby must not claim proximity or a detour without enough data.
  • When origin and destination are available, the daemon geocodes with Nominatim, computes the route with Valhalla/OSRM, and finds OpenStreetMap shops within 700 m. It sends at most three matches and Google Maps links; a verified route with no matches stays silent.
  • Telegram offers Yes, now, I cannot, Remind me later, and No more today. Remind later records a pending delivery for a later round. Closing Maps before delivery cancels the pending notice.
  • Android Auto is a second trip source, independent of Maps. The same notification listener recognises Android Auto’s own session notification (“Android Auto · Connected to the car”) and opens a trip the moment the phone projects to a head unit, with or without a route loaded. The banner then reads You are in the car. Announcements (“tap to set up Android Auto”) and the developer head-unit-server notice are never treated as a session. A trip ends only when BOTH sources are gone: a route that finishes with the phone still plugged in keeps the same trip and the same trip_id.
  • While a trip is active, APX reports position to /api/mobility/positions every 30 s / 150 m from a foreground service typed location. That service is why APX does not ask for ACCESS_BACKGROUND_LOCATION: the ongoing “Trip in progress” notification is the permission model, and tracking stops with the trip. Each sample is one point plus accuracy; no route geometry and no location history is uploaded or stored.
  • The daemon matches each position against pending tasks that name a physical errand and alerts once when a matching place is within 2 km. One alert per errand per trip — a “buy ibuprofen” task that matches thirteen pharmacies gets the nearest one, not thirteen cards — and at most two cards per sample. It never repeats, even after a daemon restart.
  • A proximity card carries four chips: Navigate and Add stop (Google Maps deep links; “Add stop” keeps the current destination and inserts the place as a waypoint), plus I’ll go and Tell me at the next one. Answering “I’ll go” is a promise, not an action: when the trip ends APX asks Did you make it to that place? with Done — which closes the task — and Not yet, which leaves it open.
  • “Tell me at the next one” declines the shop, not the errand. Waving past the branch you happen to be driving by leaves the task owed another reminder, so the next shop that satisfies it can still speak up later in the same trip — it just never asks about the branch you already declined. When there is no next shop it goes quiet for the rest of the trip, which is what the old “Not today” did to the whole errand on the first press.
  • While a trip is active, every automatic Telegram reply goes out twice: a voice note first, then the same words as text under a quiet [Transcript] line — a label, not a banner: the audio is the message and the text is there to be skimmed. The turn itself runs in voice mode, so the reply is one or two spoken sentences rather than a paragraph read aloud. TTS or ffmpeg failing costs the audio, never the message. Turn it off with voice.mobility_replies: false. The end-of-trip follow-up is plain text — it is read while parked.
  • Mobility deliveries also create an APX Android card and play the local sound when native message sounds are enabled.
  • Direct APX messages use Android’s messaging notification format. Android Auto can render them as heads-up notices and read them aloud without opening Telegram. The native menu includes Test Android Auto notice for DHU or vehicle testing. Android Auto enables Do Not Disturb while connected; grant Driving alerts once if APX should bypass it.

APX is a WebView shell, and idle it is meant to cost nothing. A trip is the one state that is allowed to be expensive — that is when GPS runs and when a dropped socket has to be repaired in seconds.

  • The mascot animates while it is dragged, hopping, or showing a message. At rest it holds its pose and only blinks, instead of redrawing thirty times a second over whatever app is actually on screen.
  • Leaving APX pauses the embedded /mobile and stops its JavaScript timers. Coming back resumes it. Native notifications are unaffected — they arrive through the foreground service, not the WebView.
  • The socket’s reconnect ladder is capped at 15 seconds during a trip and at 5 minutes outside one, and it stops entirely when the phone has no network, waking on Android’s connectivity callback instead of on a timer. A phone away from the daemon used to redial it every 15 seconds all day. Opening a trip reconnects immediately.
  • APX menu → Remove battery restriction opens Android’s own dialog. It is worth granting for trip detection: without it Android defers the start and end of a trip while the phone is in a pocket. The menu row shows the current state, and once APX is exempt the same row opens the system list, which is where the exemption is taken back.
  • It also decides whether GPS tracking can start on its own. Location is a while-in-use permission, so granting it is not enough — Android additionally requires the app to be in an “eligible state” to start a location-typed foreground service, and the notification listener that detects a trip runs in the background. Without the exemption that start is refused, APX logs it and carries on: the daemon still learns the trip began, but there is no position stream and therefore no proximity reminders until you open APX, which makes the app eligible and retries. With the exemption, tracking starts by itself.

The trip plan: how many searches a drive costs

Section titled “The trip plan: how many searches a drive costs”

An errand is not always a place. “Buy bread” can be satisfied by the bakery on the corner or by either supermarket, and which one is right depends on where the car is at the time. Answering that question by searching again on every GPS sample would be a place-provider call every few seconds; answering it once at the start would send the driver past three better options.

So the daemon keeps a trip plan: one entry per errand, holding every place that could satisfy it and the one it is currently pointed at.

  • The plan is built when you get in the car, from the origin the phone reports — not lazily on the first sample taken 800 m down the road.
  • Re-ranking is free. Picking the nearest candidate as the car moves is arithmetic on coordinates already held. No call, no model.
  • Re-searching is rare. It happens only when the car leaves the searched area by 8 km, or when the open-errand list itself changes — a local file read, checked on every sample, so a task added at a red light is live on the next one.
  • A settled errand stops looking. One candidate is the decision, and so is a “voy”: both lock the errand, and a locked errand is never re-searched or re-pointed. Once every errand is locked the plan is finished and the drive costs nothing more.

A normal drive with a settled shopping list therefore costs one place search. A drive where every task carries its own pin costs zero — and zero model calls either way, because a trip task states what it is and where, instead of asking a model to read the title and guess.

The free stack (Nominatim for places, Valhalla/OSRM for routes) is what ships, and the plan above is what makes swapping it in cheap. For reference, at 2026 prices the Google alternatives bill per call, so the number of calls is the whole game:

  • Places API Nearby/Text Search — 5,000 free calls a month, then USD 32 per 1,000. One search per drive is affordable; one per GPS sample is not.
  • Routes API computeRoutes — from USD 5 per 1,000, and it can optimise the order of up to 25 intermediate stops. This is the piece that would build a real multi-errand route rather than a nearest-first list.
  • Search Along RoutecomputeRoutes returns an encoded polyline, and Text Search accepts it as searchAlongRouteParameters, returning only places actually on the way. Two calls per trip, and strictly better than a radius search around a point when a destination is known.
  • Android’s own geofencing (GeofencingClient, part of Play Services and not billed) is the other half: it hands up to 100 fences to the OS, which wakes the app when one is crossed using batched hardware geofencing, instead of the app sampling GPS on a timer. The plan above already produces exactly the small, stable set of places such an API wants.

APX does not receive Google Maps’ exact polyline. Android sends the latest available GPS point as the origin and destination text exposed by Maps, explicitly shared, or confirmed by voice. The daemon geocodes that destination and builds its own driving route with Valhalla, falling back to OSRM.

That reconstructed route is enough for corridor and nearby-place matching, but it can differ from Google Maps because it has no Google traffic, avoidance, or private-road decisions. Without a destination, Roby receives current position only; APX does not claim a route or run proximity matching.

Android Auto also sends semantic navigation messages to the connected head unit. This is a private projection-protocol channel, not an Android app API. During a real USB/DHU test, APX captured:

  • 0x8003: navigation active/inactive state;
  • 0x8006: current maneuver text, road names, and destination address;
  • 0x8007: remaining distance and arrival information.

The verified 0x8006 payload contained guidance such as Bosque Petrificado and destination address R8400, Río Negro. This proves an APX-controlled head unit can detect route context automatically, without notification scraping, voice confirmation, Google Maps sharing, or a Google Maps API key.

Target production flow:

Android phone → APX head unit/proxy → navigation decoder
→ authenticated /api/mobility/events → Roby

Head-unit connection itself proves driving context. Emit trip.started only after navigation state becomes active and a useful destination appears. Emit trip.ended when navigation becomes inactive or projection disconnects. Keep last semantic state in memory, deduplicate repeated 0x8006 frames, and never store video/audio projection data.

Installing it, and choosing which address it connects to, is its own page: Install on Android. The short version:

Terminal window
apx android install # over USB: installs, tunnels and pairs in one step

or open the published APK on the phone.

Terminal window
cd src/interfaces/android
./gradlew testDebugUnitTest assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

A release build needs a keystore in the environment (APX_KEYSTORE, APX_KEYSTORE_PASSWORD, APX_KEY_ALIAS, APX_KEY_PASSWORD); without one, assembleRelease produces an unsigned APK that Android refuses to install. CI does this on every change under src/interfaces/android/ and republishes the release — see .github/workflows/android.yml.