No description
  • Kotlin 99.2%
  • Python 0.8%
Find a file
Nicolas Perraut 0b2b73c2cf Tell an invalid roster apart from an unreachable server (#3)
A 200 that is not a roster — a login page, a web front at the wrong address, a payload whose shape drifted — used to escape the decoder as a generic failure and read exactly like being offline. It is now `ShiftServiceError.Invalid`, carrying the decoder's reason for the logs.

- Day banner: "Le serveur répond, mais pas avec une feuille de service." with the settings offered.
- Connection test decodes the answer rather than merely fetching it, so a wrong address is caught where it is typed.
- Tests run a local server serving HTML, a drifted shape and a real roster.

Independent of #2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #3
2026-09-09 12:43:34 +00:00
app Tell an invalid roster apart from an unreachable server (#3) 2026-09-09 12:43:34 +00:00
core Tell an invalid roster apart from an unreachable server (#3) 2026-09-09 12:43:34 +00:00
docs Say what this is, how it looks, and who may copy it 2026-08-15 15:31:42 +02:00
gradle Draw the day the way the iPhone draws it 2026-08-15 20:07:26 +02:00
licenses Read the same day on an Android phone 2026-08-15 11:10:04 +02:00
tools Make sure a reprise is never missed on Android 2026-08-15 13:08:21 +02:00
wear Reach feature parity with the iPhone app (#1) 2026-09-09 12:13:37 +00:00
.gitignore Read the same day on an Android phone 2026-08-15 11:10:04 +02:00
build.gradle.kts Read the same day on an Android phone 2026-08-15 11:10:04 +02:00
DESIGN.md Say what this is, how it looks, and who may copy it 2026-08-15 15:31:42 +02:00
gradle.properties Read the same day on an Android phone 2026-08-15 11:10:04 +02:00
gradlew Read the same day on an Android phone 2026-08-15 11:10:04 +02:00
gradlew.bat Read the same day on an Android phone 2026-08-15 11:10:04 +02:00
LICENSE Say what this is, how it looks, and who may copy it 2026-08-15 15:31:42 +02:00
README.md Reach feature parity with the iPhone app (#1) 2026-09-09 12:13:37 +00:00
settings.gradle.kts Put the day on the home screen, the chip and the wrist 2026-08-15 15:29:38 +02:00

ABC+ for Android

An Android and Wear OS app that shows a bus driver their feuille de service: the run under way, the break and how much of it is left, the next departure and its girouette code. It reads a roster from a server you run yourself, and it buzzes before each reprise so a break can be taken without watching the clock.

This is the Android half of ABC+, which was written first for iPhone and Apple Watch. The two apps share a contract, not code: the server does the roster parsing, so each client is thin enough that a rewrite beats any cross-platform bridge. What crossed over is the product (PRODUCT.md in the iOS repository), the visual system (DESIGN.md here), and the roster rules, which are ported function for function with their tests.

The interface is in French.

What it does

  • The day, as it stands. The current segment with a live countdown, what comes next, and the whole duty as a timeline. Past, current and next are decided by the clock, never by a tap.
  • Departure alerts. Three warnings before each reprise, at 3 minutes, 30 seconds and the departure itself, each configurable. This is the one thing the app will interrupt you for.
  • A home screen widget reading the same cached roster the app paints from.
  • An ongoing notification for the duty, which Android 16 promotes to the status bar chip. It changes at each segment boundary rather than when you open the app.
  • The watch app, showing the same run in the same words, and arming its own alerts so the wrist buzzes even when the phone is in a bag on silent.
  • Other days. A day switcher marks rest days and unpublished ones differently, because they are not the same thing.
  • Stale is said out loud. A failed refresh keeps the last roster and admits it is old. An unconfigured install says so rather than drawing an empty day.
  • Sideways during a duty: the run under way on one half, the next steps on the other, countdown at arm's-length size. No day switcher, no chrome.
  • Sending the day on: the day, or what is left of it mid-duty, drawn as an image for a colleague who does not have the app. A run's stops too, where the roster carries them.
  • Sharing your shift with colleagues over the Glong protocol, if the server implements it: your code as a QR, a friend list with what each of them sees, and three levels from every run down to "occupé ou libre". Where the server answers 404, none of it appears.

About the alerts

On iOS a scheduled notification fires and that is the end of it. On Android the platform has to be argued with, and this app is honest about it:

  • It asks for exact alarms (USE_EXACT_ALARM), because "roughly three minutes before" is not an alert. A reprise is a fixed clock time a person has to meet, which is what that permission is for. If a build or an OEM refuses it, settings offers the way to grant it instead of downgrading silently.
  • It runs a foreground service for the length of a duty, so Doze and the battery managers that kill idle apps cannot take the alarm chain with them. Its notification is the current run, which is worth having on screen anyway.
  • It re-arms after a reboot from the cached roster.
  • On phones from manufacturers known to kill background work — Xiaomi, Huawei, Samsung, Oppo and friends — settings names the problem and links to the screen that fixes it.

Even so: on an aggressive OEM build, an alarm can still be dropped. That is a property of those phones, not something an app can promise its way out of, and a driver should know it before trusting this with a shift.

You will need a server

The app has no backend in this repository and will not do anything useful without one. It expects the roster API documented in docs/api-push-spec.md:

GET https://your-server/api/v1/shift
GET https://your-server/api/v1/shift?date=2026-08-15

An ordered, contiguous array of segments, breaks included as explicit pause segments. Basic auth is optional and per-deployment. That file is a copy; the original lives in the iOS repository and wins when the two disagree.

No push credentials are needed. Where iOS drives its Live Activity over APNs, this app updates its ongoing notification locally at each segment boundary, so there is no Firebase project to create and nothing to expire. If push ever becomes worth it (a roster that changes while the phone sleeps), FCM is the place it would go.

Building it

JDK 21, Android SDK with platform 36, and the Gradle wrapper in this repository.

./gradlew :core:test          # the roster rules, no emulator needed
./gradlew :app:assembleDebug :wear:assembleDebug

Two things worth knowing before the first build:

  • AGP 9 carries Kotlin support itself. Adding org.jetbrains.kotlin.android on top is now an error, not a belt-and-braces.
  • The androidx versions are pinned to the API 36 generation. The current wave (Compose 1.12, core-ktx 1.19, lifecycle 2.11) requires compileSdk 37, and no public platform 37 exists yet. Bump gradle/libs.versions.toml when it ships.

The screenshot fixtures are debug-only and reachable by intent extra, so any state can be captured without a server:

adb shell am start -n fr.phormian.abcplus/.MainActivity --es fixture driving
# driving | break | before | rest | unplanned | mixed

core is plain JVM with no Android imports, which is what keeps its tests runnable in a second. They are ported one for one from the iOS suite because they are the specification: what "next" means from a break, how a free day normalizes, and that repos never reads like an unpublished day.

First run

The app opens on a welcome screen asking for one thing: the address of your server. The path is fixed by the contract, so there is one field to mistype instead of four, and pasting a whole URL out of an email works as well as typing the host. A missing scheme becomes https, since basic auth over plain HTTP would put the password on the wire in clear. The password goes to a Keystore-backed store rather than sitting in clear beside the host.

Status

Everything the iOS app does is ported. The watch app is the only surface that has never run.

What has actually been run, as opposed to merely compiled:

  • The day view, in every state, on an API 35 emulator and on a Pixel 3a (Android 12): the paged days and their marks, the hero with its comment, the folded journey, the long-press share and the gallery save, the help and settings sheets, the connection probe, and landscape focus.
  • The whole alert chain with the screen off: three warnings armed as exact alarms and fired at end180 s, end30 s and end0 s.
  • The release build, signed and launched, decoding a cached roster with R8 on.
  • Landscape focus, and sharing a day: the rendered image and the system sheet.

Not verified yet, and worth knowing before trusting any of it with a shift:

  • An aggressive OEM handset. The Pixel 3a is the friendliest Android there is; the battery managers named above are the interesting case, and no alert has yet been watched fire on one.
  • The Wear app at runtime. It builds, debug and release. Running it is another matter: the Wear OS emulator wants 7.4 GB free before it will start, and then its own system processes spend the first minutes not responding on this hardware. A paired watch and phone is the honest way to test this, and that is where it should be checked before anyone relies on the wrist buzzing.
  • The widget on a home screen. Placing one cannot be automated, so its code path has never drawn on a real launcher.
  • The tones. The emulator gives no audio capture; the channels carry the right sounds, but nobody has heard them in a cab.

License

Copyright © 2026 Nicolas Perraut. Licensed under the GNU AGPL v3 or later, like the iOS app.

Fork it, run it for your own depot, change whatever you like. If you hand a modified version to anyone else, or let people use one over a network, the source of your version has to be available to them under the same terms. A server of your own that merely speaks the documented API is a separate program, not a derivative of this app.

Nunito is bundled under the SIL Open Font License; see licenses/.