The plan for this release was a user interface migration. Move the screens to Compose, keep the behaviour identical, ship it. The alert rework was not on the list.
It got onto the list about a week in, when moving the alert screens meant reading the alert code, and the alert code turned out to be one feature with three jobs stuffed into it.
Three features wearing one name
BusanBus had a concept called an alert. You could set one from a stop or from a vehicle, and it would notify you about something. What it actually did depended on where you set it from, and the three paths had almost nothing in common:
"Tell me when the next bus is close." You are at home or in a café. You want a nudge a few minutes before the bus reaches your stop so you can leave. This needs to poll a stop's arrival feed on a modest interval and fire once. It does not need your location. It should survive the screen being off. It should expire on its own if the bus never comes.
"Follow this specific vehicle." You are watching a particular bus approach, usually because the route is infrequent or the arrival estimate is unstable. This needs to track one vehicle's position and update continuously. It is short-lived and it is fine for it to require the app to be open.
"Tell me when to get off." You are on the bus, reading or carrying something, and you want a reminder as you approach your stop. This one is completely different: it needs *your* location, continuously, while the app is in the background, for the length of a bus ride.
These had been sharing a code path, a data model, and a notification channel. The consequences were exactly what you would predict. Setting an alighting reminder requested location permission even when the user had picked a departure nudge. A departure nudge held resources appropriate to continuous tracking. And all three produced notifications that looked the same, so users could not tell which of their alerts had fired.
The Compose migration did not cause any of this. It just meant somebody finally read the file.
Splitting them, and what Android 14 required
Separating the three made the resource question honest, and the alighting reminder is where honesty is expensive.
Continuous background location on modern Android is not something you get quietly. It requires the background location permission, which on Android 10 and later is a separate grant from foreground location and which the system deliberately makes a two-step decision. It requires a foreground service so the work is visible while it runs. And since Android 14, that service must declare a location foreground service type in the manifest, hold the matching runtime permission, and the system enforces this at start time — a service that starts without the right type throws rather than silently misbehaving.
We could have kept all of that behind the general alert feature. Every user setting any kind of alert would have been asked for background location, most of them would have declined, and a good number would have declined and then found that their departure nudge did not work either, because the permission refusal took down the shared code path.
Instead:
Arrival alerts use scheduled work against the stop feed. No location permission. They run with the app closed and expire on their own.
Vehicle tracking runs while you are looking at it. No location permission, no background service, no persistent notification.
Alighting reminders ask for location, explain why at the moment you set one rather than at install, run in a foreground service with the location type for the duration of the ride, and stop themselves when they fire or when the ride clearly ended.
Each one now has its own notification channel, so you can silence departure nudges without silencing the reminder that stops you missing your stop.
The foreground notification is not a formality
A foreground service must show a notification. It is easy to treat that as a tax and post something minimal.
For an alighting reminder it is the opposite: it is the most useful surface in the feature. While the service runs, that notification shows how many stops remain and updates as the bus moves. It means the common case — a glance to check progress — does not require unlocking the phone or opening the app.
It is also the honest disclosure. A notification that says "tracking your location to remind you at *your stop*, with a stop button" is a much better statement about what the app is doing than a generic "BusanBus is running." If someone forgets they set a reminder, we want the notification to be the thing that reminds them, including that location is in use.
What the Compose migration actually bought
Migrating a working interface is hard to justify to users, so it is worth being specific about what changed rather than calling it modernisation.
Loading and error states became consistent. In the previous codebase every screen had grown its own approach to "no data yet", "request failed", and "showing cached data". Some showed a spinner, some showed nothing, one showed a stale value with no indication it was stale. Rebuilding the screens against one set of state types meant every screen now handles the same four cases the same way. Given how often a transit app is used on a poor connection, this is a real user-facing improvement and not housekeeping.
Data age is visible everywhere. It used to be shown on the stop detail screen and nowhere else. Every arrival number in the app now carries how old it is, which matters because the app increasingly shows cached values when the network is bad.
Similar stops are presented consistently. Search results, favourites, and map callouts all show direction and location for stops with colliding names, using the same component. Previously each surface made its own choice about how much context to show, and the map callout showed the least.
One-handed use got better in the specific places it matters. Route detail and favourites put the primary actions within reach of a thumb. This sounds like a design nicety until you remember the app is mostly used outdoors, often in the rain, usually with one hand full.
Where we still depend on things we do not control
The alighting reminder fires based on your position relative to a stop, and position on a moving bus in a dense area is not always precise. Underground sections, tall buildings, and tunnels degrade the fix. We fire early rather than late, and the reminder tells you how many stops remain rather than only saying "get off now", so a nudge that arrives a little early is still actionable.
Arrival alerts inherit everything the upstream feed does. If a vehicle stops reporting, the alert cannot fire on an arrival nobody knows about. When we detect that the vehicle we were waiting for has gone stale, we say so rather than leaving a pending alert that will never resolve.
What we would do differently
The alert code was a single feature for two years because the first version of it genuinely was one thing, and each new case was added as a flag. No individual commit was unreasonable. The result was a function with three modes, one permission requirement covering all of them, and no test that exercised the departure nudge without a location grant.
The signal we missed was in the support mail: people kept asking why a bus app needed to track their location. We read that as a communication problem and wrote better permission copy. It was an architecture problem, and the copy could not fix it.