Running on a 2013 phone: the low-end tech stack behind ProFocus
ProFocus turns a spare phone into a desk clock, focus timer, and WiFi photo frame. Supporting that means running on Android 4.3 and iOS 12, with no Firebase and no Play Services. Here is how.
Most apps quietly drop support for old phones because it is easier. ProFocus does the opposite, because supporting old phones is the product. It takes the phone in your drawer, the one two upgrades ago, and turns it into a desk clock, a focus timer, and a WiFi photo frame. That only works if the app actually installs and runs on hardware everyone else has given up on. This is the stack that keeps that promise.
How low is low
- Android:
minSdk 18, which is Android 4.3 Jelly Bean, from 2013. We test on a real 4.3 device, not just an emulator. - iOS: deployment target 12.2, so a 2015 iPhone 6s is fully supported.
- TV: an Android TV build and a tvOS 12 build, plus a Wear OS companion.
Hitting those floors is not free. It shapes almost every technical decision below.
No Firebase, no Play Services, on purpose
The single biggest choice: ProFocus ships no Firebase SDK and no Google Play Services dependency. Those libraries are heavy, they are not guaranteed to run on ancient or de-Googled devices, and they pull in a lot of surface area for an app that just needs to show a clock.
Instead, analytics go out as plain GA4 Measurement Protocol calls to our own server. That server, tracking-server, is a stateless Fastify (Node 20, TypeScript) proxy with no database: it takes a batch of events, resolves the API key to a project, and forwards them to the right GA4 stream. One small server is the only thing that ever talks to Google, so the app stays lean and works even where Play Services does not.
The iOS 12 obstacle course
Swift on iOS 12 is a minefield of things that compile fine and then crash on launch:
- Swift Concurrency (
async/await) crashes on iOS 12 because the concurrency runtime is not present. So the whole app is written against completion-handler APIs only; even the RevenueCat payment layer uses its callback API, neverasync. - SwiftProtobuf also crashes on iOS 12 launch. We share a
remote.protoschema across platforms, but on iOS we do not use the generated protobuf types at all. Instead there is a hand-writtenRemoteProtocol.swiftstruct that mirrors the schema, and everything travels as plain JSON on the wire. - A linker gap for
___chkstk_darwinon-device is resolved by force-loadinglibclang_rt.ios.a.
The Android side has its own version of this dance: features that genuinely need a newer API, like CameraX for QR scanning or Google Play Billing, are guarded behind runtime API-level checks. The app still declares minSdk 18, and those features simply light up on devices new enough to have them, while a 4.3 phone runs everything else.
A shared SDK, not per-app glue
The payment and analytics pieces are not rebuilt for each app. They live in a config-driven shared SDK used across FightTech products:
| Module | Platform | Job |
|---|---|---|
fight-payment-* | iOS 12+, Android 18+ | RevenueCat in-app purchase + paywall |
fight-tracking-ga-* | iOS 12+, Android 18+ | GA4 Measurement Protocol client |
proto | both | shared remote-control schema (JSON on the wire) |
Everything is injected at init: API keys, the entitlement id, the theme, analytics hooks. Nothing is hardcoded per product, and only public SDK keys are committed to the repo. Wiring a new app to payments and analytics is configuration, not code.
What the user gets
All of that plumbing adds up to something simple from the outside: an app with no account and no third-party tracking that shares photos device-to-device over your local WiFi (the phone hosts a tiny HTTP server on port 8080), shows a Flip, Circle, or Analog clock, and runs a focus timer, on a phone that modern apps refuse to install on.
The engineering lesson is that “support old devices” is not a checkbox you tick at the end. It is a constraint you carry from the first line: no async, no heavy SDKs, runtime guards everywhere, and your own small server standing in for the platform services you chose not to ship.
You can try it from the App Store — ideally on that spare phone in the drawer.