Mobile app / backend / AI surface
Ember
An Android app for rehab adherence, with an AI surface that is read-only by construction and a shared streak between friends that is derived rather than stored.
- Kotlin
- Jetpack Compose
- WorkManager
- Ktor
- Exposed ORM
- PostgreSQL
- Flyway
- Model Context Protocol
- Android App Links
- JWT
- Google OAuth
- Docker
Overview
An app built for one specific job: keeping up a physiotherapy routine once the clinic visits stop, because the clinic was mostly supplying consistency and that is the part software can replace. It has since grown two more faces. It exposes a Model Context Protocol server, so an assistant can be asked what today looks like or how the streak stands. And it has friends, where two people share a streak that only advances on days they both satisfied.
Problem
A streak is trivially cheatable and a reminder is easy to get wrong, and both failures are quiet. If the client decides when a day is complete, any device clock can fabricate a run. If the reminder depends on a server or a push service being healthy, it fails on exactly the evening it was needed. Opening the app to an assistant adds a third: an assistant that can mark a workout done can quietly dismantle the only thing the app is for. And a shared streak between two people invites a fourth, because the obvious design is to store it, at which point it can disagree with the two personal streaks it is supposedly made of.
Solution
The server decides. Streaks are computed from recorded completions whose timestamps are validated rather than trusted, rejected if they are in the future or older than a grace window that exists so finishing offline near midnight still counts. Rest days need no stored row and bridge a run rather than breaking it. The shared streak is derived from the same two calendars with an and, which needs no table, no start step, no cap and no background job, and can never disagree with its parts. It is floored at the moment the friendship was accepted, because otherwise two people who each already train daily would meet on a Monday and be shown a streak stretching back through history they never spent together.
Architecture
A Kotlin and Ktor API over PostgreSQL with Flyway migrations, an Android client in Compose, and a separate protocol server for the assistant surface, all containerised. Reminders are local notifications driven by WorkManager rather than push, because the phone already knows the time. Friendships are stored on an ordered pair rather than as requester and addressee, since a friendship is undirected and without ordering the same two people can hold two rows, each waiting on the other. Invites are Android App Links to a public landing page that never looks the username up, because answering differently for real accounts would turn it into an account enumeration endpoint.
Key decisions
Put the limit in the API, not the prompt: an assistant is told it cannot record a workout, and separately the credential simply does not work on that endpoint, so the guarantee survives a model that ignores its instructions. Derive rather than store, and accept what that costs, which is a frozen record: unfriending ends the shared streak rather than preserving it. Make a tapped invite link prefill and stop rather than sending the request, because the link arrives in a chat message and the person holding the phone should decide what goes out in their name. Return a not-found rather than a forbidden when a stranger touches someone else’s friendship row, since whether a request exists between two other people is not theirs to learn. And carry state in the week strip as a glyph rather than a colour, because missed and rest were both a muted circle and colour is the one cue a colour-blind reader does not get.
Challenges
The sharpest bug in the project was code that was correct and had never run. The main activity used the default launch mode, so every tapped invite link started a second copy of it on top of the running one, which meant the handler written for incoming links was unreachable. The cold start path worked perfectly, and that is the path anyone would test. Earlier, the reminders had never fired for anyone: scheduling on app start replaced pending work under the same name, and since the system starts the process to run a reminder, startup cancelled the very job that woke it. And a timer ran one countdown for exercises that are done once per side, so anyone following it did half the movement; per-side was recorded only as prose inside a description, which a person reads and an app cannot. All three were found by looking at a running app or a log, not by reading code.
Outcome
On an internal distribution track, with a suite of over five hundred tests across the app, the API and the protocol server. Two things that had been open for the app’s entire existence are now closed: reminders were confirmed arriving, and the first session was actually recorded. Adoption is the honest weak point and worth stating plainly. There is one real outside tester, and he has not built a schedule yet, so he cannot complete a day even if he wants to. That is the finding that matters more than any feature: there are two separate drop-offs, from signing up to building a schedule, and from having a schedule to finishing one session. Asking him where he stopped is worth more than anything else on the list.