← all work

Taxiarab

A ride-hailing platform running in Jordan — two Android apps over one six-module Clean Architecture core, Arabic-first and RTL by default. I joined in Dec 2024 and own the layer where the product is most likely to fail silently: foreground services, the real-time transports, and the ride-invitation window.

10K+ downloadsrider app, 4.2 from 111 reviews

Role
Android systems developer — joined Dec 2024
Span
Dec ’24 — now
Kind
Client work · product
Status
ongoing
Stack
Kotlin · Jetpack Compose · Material 3 · Hilt · Coroutines · Pusher · WebSocket · Retrofit · Clean Architecture · RTL
play.google.com

Two apps, one core

The Gradle root is called Arab Booking and ships six modules, not the five an earlier draft of this page claimed: :client (rider, com.taxialarab.rider), :driver (captain, com.taxialarab.captain), and four shared libraries — :core:domain, :core:data, :core:ui, :design_system. Both apps depend on all four; :core:data depends on :core:domain; nothing depends upward. Clean layering with MVVM on top of it, and one route through every feature: Screen → Route → ViewModel → UseCase → Repository.

Six modules, not five. Core data carries the accent because that is where this whole page's claims actually live.

min SDK 24, compile and target 35. Compose with Material 3, Hilt for injection, Retrofit and OkHttp underneath. supportsRtl="true" on both manifests and a values-ar/strings.xml beside every values/strings.xml — Arabic is the default product locale, not a translation pass that happened later.

The split that matters is not rider-versus-driver. It is that the two apps have completely different failure modes and the shared core has to serve both without pretending otherwise.

What I actually touched

Of 312 commits in the repository, 80 are mine, from 2024-12-28 to 2026-07-26. A commit count flatters nobody though — it says nothing about where the work landed. Counting file-change events instead (every file touched, summed across my commits) gives the real shape:

ModuleFile-change events
core/ui1297
driver686
client608
core/data334
core/domain90
design_system71

core/ui leads by an order of magnitude and that is not a compliment to it — it is nine Kotlin files, and the count is dominated by a package rename that rewrote every path in the module. The honest reading of this table is the middle three rows: the driver app, the rider app, and the data layer between them, which is where the rest of this page lives.

Three foreground services, one owner

The driver app runs LocationForegroundService, WebSocketForegroundService and RideTrackingService. Every ride-hailing driver app on Android runs something like them; the way they rot is that each one manages its own lifecycle and nothing knows the aggregate state.

LocationServiceManager is declared the only class permitted to talk to a service directly, and it is a @Singleton. It loops on a 30-second health check and restarts anything that died, with at most three recovery attempts on a 5-second base delay. Location intervals move with context rather than running one timer flat out: 5 seconds and 10 metres in the foreground, 30 seconds and 50 metres in the background.

A restart request is not a restart

Centralising the lifecycle removes a class of bug — two services disagreeing about whether the driver is online — but it does not make the services survivable. The OS still kills them, OEM task-killers still kill them harder, and the recovery loop is a request that can fail three times and stop. The architecture is honest about its intent; it is not a guarantee, and the invitation bug below is what that looks like in production.

Two transports, because the two sides fail differently

The rider app talks Pusher (RidePusherClient, auth bridged through an OkHttp-backed PusherModule). The driver app holds a raw WebSocket (DriverWebsocketClient) inside a foreground service. Both live in :core:data on pusher-java-client:2.2.1, and both fan raw event names plus JSON into typed sealed hierarchies — UserTaxiArabWebSocketResponse and DriverTaxiArabWebSocketResponse.

The asymmetry is deliberate. A rider has the app open and is looking at it; a dropped Pusher connection is recoverable because the user is present. A driver has the phone in a windscreen cradle, screen off, on battery, and the single most valuable event in the system arrives while nobody is watching.

The invitation window is the whole product

A ride invitation reaches a driver with a countdown. Accept inside the window and the ride is theirs; miss it and it goes to someone else. Nearly every production bug I fixed on this codebase lives in that one window, and they form a chain worth reading in order.

The invitation never arrived. WebSocketForegroundService cancelled its coroutine scope on any STOP intent, and the START handler never rebuilt it — so collectRideInvitationEvents never restarted and the event was never posted. Two fixes: the ViewModel now posts invitations directly from webSocketMessageFlow because it is always the active collector, and serviceScope became a var so a START after a cancel can recreate it.

The countdown lied. It ticked down with a local decrement every second, which drifts whenever the app is busy, and it was seeded from receipt time — so a slow screen start ate real seconds off a real deadline. Replaced with an absolute deadline recomputed at display time from the wall clock, so the number shown is the number remaining and the button hard-disables at the true expiry.

The deadline was never parsed at all. The backend spec said expired_at arrives as a naive Y-m-d H:i:s in Asia/Amman. Checked against the live wire, it is ISO-8601 UTC with microseconds. The parser matched neither format, returned null on every real payload, and the server’s expiry silently never applied — the timer just ran its full relative window. Fixed by parsing the real format on a SimpleDateFormat safe at minSdk 24, stripping sub-second digits first because more than three fractional digits get misread as milliseconds. The skew guard stayed: expired_at may only ever shorten the countdown, so a fast device clock cannot make a live invitation look already expired and drop it.

Losing looked like winning. BaseResponse carries a business success flag, but wrapApiResponse only checked the HTTP status. A late claim on an already-taken ride came back as HTTP 200 with success=false, sailed into onSuccess, and showed the driver “accepted” for a ride that was never theirs — while the server’s honest rejection message was swallowed. Guarded at the accept call site only: null data or success=false is a terminal rejection. A genuine win always carries ride data, so the guard cannot regress a real accept, and the shared wrapper stays untouched.

Every one of these was invisible

None of the four threw, logged an error, or failed a build. A cancelled scope looks like a quiet night. A drifting timer looks like a driver who was slow. A null date parse looks like the server not sending expiry. A false accept looks like a dispatch problem two systems away. This is why the work is in the service and transport layer rather than the screens: a Compose bug is visible the moment you look at it, and none of these were.

It runs on my own API

Every user-facing road distance in both apps — trip estimate, driver-to-pickup, the route drawn on the map — is computed by Geolink, the geolocation platform I built and run. This is not an association drawn between two entries in a portfolio: core/data/.../di/LocationModule.kt builds its Retrofit client against https://geolink-eg.com/api/v1/, :core:domain exposes it as ManageDirectionUseCase, and docs/DISTANCE_FLOW.md in that repository traces the field end to end. Straight-line maths survives only inside the GPS layer, for speed and significant-change thresholds.

Being on both sides of that boundary means the integration is instrumented rather than assumed. A full getRouteDirection() chained three requests — directions plus two reverse-geocodes — each paying a www-to-apex redirect, so six round trips at p50 2.48s and max 4.90s measured from a wired connection against a 5-second coroutine timeout, a margin mobile RTT consumes entirely. A driver accepting a ride could sit up to forty seconds with no pickup marker while that resolved, even though the invitation payload already carried the pickup coordinate.

The fix was mostly subtraction: draw the marker from the payload immediately and keep the route call as a fallback; drop the two reverse-geocodes, whose address strings no consumer ever read; point the client at the apex host to remove the redirect hop. Widening the timeout was considered and rejected — it does not address a call that was never needed, and a wider timeout only postpones the same failure.

The client-side guards around that integration are documented in the same file: a 12-second throttle between route calls during live ride tracking, one retry after a one-second delay, an exponential 1s/2s/4s backoff on the pickup-to-destination route, and a hard guard that skips the call entirely when a coordinate is (0,0). A timestamp bug hid inside that throttle for a while — it was stamped before the call rather than after success, so a single timeout bought twelve seconds of silence before a retry was even permitted.

Shipped

Both apps are live on Google Play for Jordan, both last updated 26 July 2026, which is also the date of my most recent commit on this codebase:

The two rating averages are the more interesting number. The driver app is rated markedly higher than the rider app by an audience that uses it for eight hours a day rather than four minutes a week — and the driver app is where the invitation window lives.

Versioning in the repository runs on dates: 26.6.14 is 14 June 2026, with a YYMMDD0N version code beneath it. A short run of semantic-looking names (4.6.26, 4.6.27) broke that convention and was reverted to the date scheme rather than left to look like a version history it never was.

The identity, from its own design system

An earlier version of this page said Taxiarab had no design system this project could read, and sampled the maroon out of the Play Store icon instead. That is no longer true, and the correction is worth stating precisely, because the old value was not a bad guess — it was an accurate measurement of the wrong artefact.

Decoding client/src/main/ic_launcher-playstore.png byte by byte gives rgb(148 28 68) for the icon field. The sample was exactly right. But the icon is a marketing raster, and the token that every one of the app’s 32 screens renders lives in design_system/.../theme/Color.kt, where it is named and declared:

  • Rose Garnet — Color(0xFF92023F), rgb(146 2 63) — the brand colour, carried at full strength plus four alpha steps at 70, 46 and 39 percent
  • Raisin Black — 0xFF262626 — the only text colour in the system, likewise with an alpha ladder
  • Cairo — bold, semi-bold, medium and regular, shipped as four .ttf files in design_system/src/main/res/font/ and bound to a ten-style scale from headlineLarge at 36sp down to body2, every size multiplied by a live scaleFactor()

Two points of hue separate the raster from the token. The quoted Rose Garnet value now lives at --accent-dark in this site’s own token file, not --accent — at its native lightness (HSL L29, correct for a full-bleed app screen) it read muddy at the small swatch/button/link sizes a portfolio accent actually renders at, next to every other brand here running its accent at L48-60. --accent is the same 334.6° hue and near-identical saturation lifted to L42, checked at 5.19:1 contrast against this page’s canvas — a relit tier of the real colour, not a different one.

The mark itself is unchanged and reads the same in source as it did on the listing: a solid white location pin with a road running through it in the same maroon as the field behind, the road being negative space rather than a second colour, over the bilingual wordmark TAXI ARAB / تاكسي عرب.

The dark mode is not a dark mode

Color.kt declares LightColors and DarkColors as two separate 23-value objects that are byte-for-byte identical. AbTheme branches on isSystemInDarkTheme() and returns the same palette either way. The app has the complete structure of a theme system and exactly one theme in it, and the adaptive-icon background is declared plain #FFFFFF in both apps. The dark block in this site’s own brands/taxiarab/tokens.css is therefore derived, not quoted — there is nothing upstream to quote.

What this page is not

Taxiarab is a client engagement. The codebase this is written from is its Android repository, which I have commit access to and which was rebranded to the com.taxialarab.* namespace during my time on it — the iOS bundle id above, com.app.TaxiArabCustomer, is one of that repository’s own former package names, which is how the App Store listing is tied to this work rather than assumed into it.

Everything above is read out of that source or measured against the live listings. What is not here: the backend, which I did not write; the design decisions behind Rose Garnet and the pin, which predate me; and any claim to speak for the brand, whose owners define it, not this page.