project · android · on-device inference
Tick-Tock (Activity Hub) — reconstructing a day from the signals a phone already leaks
An offline-first tracker that builds your timeline out of notifications, call logs, app usage, geofences and health data — then lets you interrogate it with a language model that never leaves the device.
Every time-tracking tool I have tried makes the same bet: that I will remember to press start. I never do. By Friday the log is a work of fiction, and the honest answer to "where did this week go?" is that I have no idea.
So Tick-Tock takes the opposite bet. It assumes I will never press anything, and instead reconstructs the day afterwards from signals the phone is already producing — then asks me to confirm.
The core idea: nothing is a fact until you say so
The app captures aggressively and commits nothing. Every inferred block of time lands in the timeline as a draft activity: a proposal with a start, an end, a guessed label, and a note about which signal produced it. Reviewing a day means swiping through a handful of drafts and correcting the two that are wrong, which takes about forty seconds. Confirmed activities can then sync onward — to Toggl, to Google Calendar, to a local backup.
That distinction turns out to be the whole design. Passive capture is a guessing game, and a guessing game that writes directly into your record of truth is worse than no tool at all. Keeping inference in a staging area means the system is allowed to be wrong, which in turn means it is allowed to be aggressive.
What it listens to
| Signal | Source | What it becomes |
|---|---|---|
| Notifications | Android NotificationListenerService | Call start/end, message bursts |
| Call log | CallLog.Calls | Cellular calls with exact duration |
| App usage | UsageStatsManager | Focus blocks, "what was I actually doing" |
| Location | Geofences around saved places | Outings — left home, arrived, came back |
| Health | Health Connect | Sleep windows, workouts, steps |
| Calendar | Google Calendar API | Scheduled meetings, written back after review |
All of it lands in a local SQLite database on the phone. There is no server, no account, and no sync service — the app works fully with the network off. Every outbound call is one you explicitly wired up yourself.
Your data goes where you already keep things
The app deliberately does not try to become your notes app, your task manager, or your timesheet. It has all three kinds of data, so it pushes each to the tool that already owns that job:
| What it captures | Where it lands |
|---|---|
| Notes and journal entries | Notion |
| To-dos parsed out of captures | Todoist |
| Confirmed activities and time blocks | Toggl |
| Scheduled events | Google Calendar |
Every one of these is optional and off until you supply a token. The local database stays the source of truth; the integrations are mirrors, not storage. If Notion is down, or you delete the integration tomorrow, nothing is lost — the timeline never depended on it.
The widget: type anything, it works out where it goes
Passive capture handles what the phone already knows. The other half is the thought you have while walking to the kitchen — and a tool you have to unlock, open and navigate loses that thought every time.
So there is a home-screen widget with one text field. You type anything, and a router decides which of five destinations it belongs to:
| Target | What happens |
|---|---|
calendar | Becomes an activity on the local timeline |
todo | Becomes a Todoist task |
note | Appended to today's note, and to Notion |
toggl_start | Starts a running Toggl timer |
toggl_stop | Stops the running timer |
The routing is a four-tier fallback, and the ordering is the whole point:
- An explicit target, if you picked one from the widget's landing selector.
- A text prefix —
todo:,cal:,start timer. Free, instant, deterministic. - On-device AI classification, for plain unprefixed text.
- Keyword matching, as the floor.
Only tier 3 needs the model. Every other tier is pure and offline-safe, which means a capture can never silently fail because AI was unavailable — the model missing, cold, or unloaded degrades the routing quality, not the capture. Whatever you typed always lands somewhere.
The resolved destination is shown back to you rather than applied silently, and it stays overridable — the classification is a proposal, exactly like every inferred activity in the timeline. There are two widget variants for this: one that always asks, and an auto one that just routes and tells you where it went.
Three ways out, and none of them are mandatory
An offline-first app with no account has to take backup seriously, because there is no support team to restore your data from. So there are three independent recovery paths, and you can use any combination — including none.
1 — Cloud, if you want it
Backups go to Google Drive's appDataFolder, a hidden app-private area that never appears in your Drive UI and is readable only by this app. The bundle is gzipped, and integrity metadata — SHA-256, timestamp, app version, record counts — rides along in the file's private properties so a restore can verify the payload before it decodes anything.
2 — Entirely local, if you don't
Two on-device paths that never touch the network. A rolling ring of the most recent bundles sits in the app's document directory as an automatic safety net — it survives restarts, but not an uninstall, since that wipes the sandbox. So there is also manual export and import through the Storage Access Framework, writing the bundle to a folder you pick. That one is uninstall-proof, because the file lives wherever you put it.
3 — Straight to your next phone
Device-to-device transfer over Google Nearby Connections, no cloud in the middle. The transport only exposes a text channel, so the bundle is gzipped, base64-encoded, chunked and reassembled with acknowledgement backpressure. Pairing is out-of-band: the receiving phone shows a QR containing a random session name and a secret the sender must present after connecting — proving it physically saw that screen. Nearby encrypts the channel, but encryption is not authorisation, and this is the piece that stops a stranger across the room from pulling your timeline.
The WhatsApp call problem
The most instructive bug in the project is also the smallest. WhatsApp voice calls do not appear in Android's call log at all — they use the self-managed connection API, so CallLog.Calls never sees them. The only reliable evidence that a WhatsApp call happened is the ongoing-call notification, and the only reliable evidence that it ended is that notification being dismissed.
The library I was using, react-native-notification-listener, ships onNotificationRemoved as an empty stub:
@Override
public void onNotificationRemoved(StatusBarNotification sbn) {}
So I patched it. A committed patch-package diff fills in the method to fire the same HeadlessJS task as an incoming notification, with one extra field in the intent — removed: "true". The JS handler sees that flag, and for WhatsApp notifications writes a CALL_ENDED row into a buffer table carrying the exact dismissal timestamp.
When the draft builder later assembles a call activity, it looks for that marker first. If it finds one, the call gets a real end time. If it does not, it falls back down a chain of progressively worse guesses: a timer pattern parsed out of the notification text, then overlapping app-usage data, then a timestamp difference, then a flat five-minute default. Only in the fallback cases does the UI show the "duration estimated — please adjust" banner.
Geofences, and the tyranny of GPS drift
Outing detection sounds trivial — draw a circle around home, log when you leave it. In practice a stationary phone wanders tens of metres, so a naive implementation invents a dozen phantom trips a day, most of them lasting eleven seconds.
Three constants absorb almost all of that:
| Constant | Testing | Production | What it buys |
|---|---|---|---|
GEOFENCE_RADIUS_METERS | 3 | 50 | Radius around a saved place |
MIN_OUTING_MINUTES | 0 | 10 | Floor before a draft is worth creating |
GEOFENCE_EXIT_DEBOUNCE_SECS | 60 | 300–600 | How long you must stay outside before it counts |
The testing column exists so I can walk five steps down the hallway and trigger the whole pipeline on a real device. The production column exists so that walking to the front door is not an outing. Shipping the testing values by accident is a rite of passage I have now performed twice.
Ask Anything: a language model that lives on the phone
Once the database is full of activities, the obvious next want is to ask questions of it in English. How much did I sleep last week? What eats my mornings? Which day did I actually leave the house?
Shipping that to a hosted model was never an option — the whole point of the app is that a very intimate record of my life stays on one device. So the query pipeline runs locally, in three passes:
- Planner. The model receives the question plus a catalogue of available tools and returns JSON: which tool, which date range, which grouping.
- SQL. Plain TypeScript turns that JSON into a parameterised query against the local database. The model never writes SQL, and never sees the whole schema.
- Synthesis. The rows come back and the model turns them into a sentence.
That split matters more than the model choice. A 1.5B-parameter model asked to emit SQL directly is a liability; the same model asked to fill in four fields of a JSON object is reliable enough to ship. Keeping the model in the role of classifier and narrator rather than query author is what makes small-model inference usable.
One runtime, many apps
The first version loaded model weights inside Activity Hub. That works exactly until you want a second app to do the same thing — and then you are holding two copies of a gigabyte-plus model in RAM on a phone.
So the runtime moved out into its own APK, gi-hub, which hosts a single engine and lends it to consumers over a bound AIDL service:
activity-hub ──┐
expense app ──┼──► gi-hub (APK)
other client ──┘ └─ InferenceService (foreground, bound)
├─ IInferenceService streaming + grammar
├─ ISimpleInference init / generate / shutdown
└─ engines: LiteRT-LM · llama.cpp
one model on disk
one ~1.3 GB RAM footprint
one KV cache
Activity Hub itself now contains no model code at all. It binds to the service, streams tokens back across the Binder boundary, and is otherwise ignorant of what is running on the other side. The AIDL surface and the Kotlin bridge are generated at build time by an Expo config plugin, which rewrites them into the android/ tree on every prebuild — because expo prebuild --clean regenerates that directory wholesale and anything hand-edited there is already gone.
That runtime has since become its own project, with a second consumer app bound to it. The gi-hub write-up covers the AIDL contract, the engine-swapping design, and the four ways Android actively resists sharing a model between processes.
What it costs to ask
A question runs two passes — planner, then synthesis — so the round trip is roughly double a single inference: about twelve seconds end to end on a Pixel 7a. Slow for a search box. Perfectly fine for a question you ask your own diary.
That it is twelve seconds and not two minutes is entirely the runtime's doing. The first working version took about seventy seconds for the planner pass alone, and getting it to seven took three phases and one instructive dead end — a quantisation format that was slower despite being "better", a build flag whose absence silently disables the accelerator you just enabled, and a GPU backend that looked like progress and wasn't. That work belongs to gi-hub rather than to this app, so it lives there: 70 seconds to 7 →.
Where it stands
It runs on my phone every day. Calls, outings, sleep and app-usage capture are stable; notes, journalling, to-dos and home-screen widgets grew out of it because once you have a reliable local timeline, everything else wants to hang off it. The shared-inference APK is the piece still moving fastest.
The design constraint that paid off most was refusing to have a backend. It forced every feature to answer "what happens with no network?" up front, which is why the integrations are mirrors rather than storage, why there are three independent ways to get your data out, and why the language model runs on the phone. None of that was principle — it was just the only way to build it.