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.

Aug 2026 React Native · Expo · SQLite Android 15+ · Kotlin · AIDL llama.cpp → LiteRT-LM built with Claude Code private repo privacy policy
On the name: this was built under the working name Activity Hub, which is still what the repo and the rest of these write-ups call it. It ships on Android as Tick-Tock. Its privacy policy lives at shivam1410.github.io/tick-tock/privacy. There is a macOS app of the same name as well — Tick-Tock for Mac — which shares this one's premise and none of its code.

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

SignalSourceWhat it becomes
NotificationsAndroid NotificationListenerServiceCall start/end, message bursts
Call logCallLog.CallsCellular calls with exact duration
App usageUsageStatsManagerFocus blocks, "what was I actually doing"
LocationGeofences around saved placesOutings — left home, arrived, came back
HealthHealth ConnectSleep windows, workouts, steps
CalendarGoogle Calendar APIScheduled 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 capturesWhere it lands
Notes and journal entriesNotion
To-dos parsed out of capturesTodoist
Confirmed activities and time blocksToggl
Scheduled eventsGoogle 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:

TargetWhat happens
calendarBecomes an activity on the local timeline
todoBecomes a Todoist task
noteAppended to today's note, and to Notion
toggl_startStarts a running Toggl timer
toggl_stopStops the running timer

The routing is a four-tier fallback, and the ordering is the whole point:

  1. An explicit target, if you picked one from the widget's landing selector.
  2. A text prefixtodo:, cal:, start timer. Free, instant, deterministic.
  3. On-device AI classification, for plain unprefixed text.
  4. 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.

The same shape, a third time: ranked tiers of evidence, cheapest and most certain first, the model somewhere in the middle, and a deterministic floor underneath so the feature has no single point of failure. It showed up in call-end detection, in the Ask Anything planner, and here. Small models are only dependable when something duller is standing behind them.

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.

One detail I'm glad I got right: only the API tokens in a backup are encrypted, under a passphrase — AES-256-GCM with a scrypt-derived key. Everything else stays plaintext. Encrypting the whole bundle would mean a forgotten passphrase costs you your entire history; scoping it to the credentials means it costs you five minutes re-entering tokens. A wrong passphrase fails the GCM tag check and surfaces as a clear error rather than quietly returning garbage.

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:

RNAndroidNotificationListener.java — upstream
@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.

The general shape: a ranked chain of evidence, each level explicitly worse than the one above it, and a UI that tells the truth about which level it landed on. Users forgive a wrong guess. They do not forgive a wrong guess presented as a measurement.

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:

ConstantTestingProductionWhat it buys
GEOFENCE_RADIUS_METERS350Radius around a saved place
MIN_OUTING_MINUTES010Floor before a draft is worth creating
GEOFENCE_EXIT_DEBOUNCE_SECS60300–600How 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:

  1. Planner. The model receives the question plus a catalogue of available tools and returns JSON: which tool, which date range, which grouping.
  2. 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.
  3. 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:

architecture
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.

Not open source (yet). The repo carries an uncomfortable amount of my actual life in its fixtures and test data. If you are building something adjacent and want the notification-patch details or the AIDL contract, email me — I am happy to share those pieces.