DEV Community

Turn an old Android phone into a screen-off security camera (no cloud, LAN-only)

Super Funicular on August 05, 2026

Old Android phones pile up in drawers. Instead of letting one die there, you can point it at a doorway, a driveway, or a pet's favorite corner and ...
Collapse
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

Love this approach! The "no-cloud, LAN-only" philosophy is so refreshing in a world where almost every smart camera demands a subscription and sends your private video feed outside your home.

Wrestling with Android's Doze Mode and background lifecycle management to keep a camera capture session alive with the screen off is no small feat. Solving it cleanly with a proper Foreground Service rather than hacky workarounds shows real craftsmanship.

As an indie developer building a local-first macOS network security tool (RoamSwitch), I have huge respect for projects that prove you don't need heavyweight cloud backends to build useful, privacy-first software. Repurposing old hardware to keep video streams strictly on the local network is a massive win for both security and sustainability.

Great work on this — looking forward to reading more of your deep dives!

Collapse
 
superfunicular profile image
Super Funicular

Thanks — and RoamSwitch is a good comparison point, because macOS network-state and Android background lifecycle are the same fight wearing different clothes: the OS has decided your process is not the point of the device.

One correction on the craftsmanship, though, in case it saves you time on your own background work: the foreground service is necessary but nowhere near sufficient. Doze will honour a foreground service, but the camera service type still has to be declared, and the thing that actually killed runs during development was OEM battery management sitting above AOSP policy — a build that survives eight hours on a Pixel can get reaped in twelve minutes on a Xiaomi with nothing in logcat but a generic exit reason. The useful habit was pulling ApplicationExitInfo on every restart instead of trusting a clean test on one device.

Curious how this lands on your side: does macOS give you anything like an exit-reason record when the system tears down a background network watcher, or do you have to infer it from your own state on next launch?

Collapse
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

That OEM battery killer note is painful and so relatable. The gulf between clean AOSP on a Pixel and the aggressive background reap on MIUI or OneUI is legendary, and pulling ApplicationExitInfo on boot is such a smart way to debug that madness.

To answer your question directly: macOS gives you pretty much nothing as clean as ApplicationExitInfo for a normal app. You basically have to infer it the dirty way.

The standard pattern on our side is leaving breadcrumbs. In applicationWillTerminate or our SIGTERM handler, we write a clean-shutdown marker to local storage. When the app boots, if that marker is missing, we know we took an ungraceful exit (SIGKILL, an OS watchdog kill, or a dead battery), which triggers a startup reconciliation routine to clean up any orphaned pf firewall rules or network state left behind.

macOS does write system diagnostic reports to disk when it kills a process for CPU/memory hogging (EXC_RESOURCE) or a frozen runloop, but trying to read and parse those .ips crash files in-app on next launch across sandbox boundaries is a headache nobody wants to maintain.

For background daemons managed by launchd, launchd handles automatic respawns and exit-code tracking. But for the menu-bar app itself, it is almost entirely self-inferred state.

It is always fun seeing how both platforms force us to fight the exact same lifecycle battles in completely different ways. Thanks for the great exchange!

Thread Thread
 
superfunicular profile image
Super Funicular

The breadcrumb pattern is the right answer, and I think I undersold the question. ApplicationExitInfo tells you why, but on exactly the OEM skins where it matters most the reason collapses to a generic code — so the bit that actually changes my behaviour is the same bit you're recovering: did we come back from an ungraceful exit, yes or no.

One inversion worth knowing if you ever port that pattern to Android, because it bit me: you can't hang the marker on the teardown. onDestroy isn't guaranteed to run on a kill, and a SIGKILL from OEM battery management gives you no handler at all — the callback you'd write the clean-shutdown marker in is precisely the one that gets skipped. So it has to run backwards: write a running marker when the capture session starts, clear it on a clean stop, and treat "marker still present at boot" as the ungraceful signal. Absence-of-cleanup rather than presence-of-goodbye.

Your orphaned pf rules have a direct analogue on my side too — a reaped run leaves a half-written MP4 with no moov atom and a stale notification channel, and both want the same boot-time reconciliation pass rather than a fix at the crash site.

And the .ips verdict matches my experience exactly: the moment a diagnostic needs a parser plus a sandbox exemption to read, it stops paying for its own maintenance.

Does RoamSwitch's reconciliation ever have to tell an OS kill apart from a user force-quit, or do both land in the same cleanup path?

Thread Thread
 
lafine_systemsdesign profile image
Tetsuharu Fujiki

That running-marker inversion is spot on. The fundamental flaw of relying on teardown hooks is that an uncatchable SIGKILL skips user-space signal handlers and Cocoa termination delegates entirely. The very condition you need to detect is what prevents the goodbye handler from running. We use that exact active-token pattern: create the state file on engine start, remove it on orderly exit. If it is still present when the process starts, the previous run was aborted ungracefully.

To answer your question directly: yes, both land in the exact same cleanup path, and they have to.

At the process level, there is no way to tell them apart. Whether a user triggers a force-quit through Activity Monitor (SIGKILL) or an OS mechanism aborts the process, execution halts immediately without entering user mode again. No metadata is passed down to tell you who issued the signal.

More importantly, the kernel-level consequence is identical. Unlike file descriptors or network sockets, which the kernel cleans up on process termination, macOS packet filter (pf) anchors stay active in memory. If RoamSwitch is killed while redirecting traffic or applying strict filters, those pf rules remain in the kernel indefinitely. Without a running daemon to handle the traffic, the user's connection simply drops.

Because of this, the boot-time reconciliation routine doesn't care who killed the process. Its only job is to query the pf device via pfctl, flush any orphaned com.tetsuharu.roamswitch anchors back to a safe baseline, verify routing table integrity, and clear the stale marker before starting normal monitoring.

The only distinction that actually matters is an orderly exit (Cmd+Q) where we can flush the pf anchors before terminating, versus an abrupt termination where cleanup must happen on the following launch.

Your MP4 moov atom analogy fits this reality perfectly. Whenever a user-space process manipulates persistent kernel or filesystem state, the recovery logic has to assume that any ungraceful termination leaves inconsistent state behind.

Thread Thread
 
superfunicular profile image
Super Funicular

Orphaned pf anchors are a sharper example than mine, because that is state the kernel deliberately will not reclaim for you. It is what makes a RoamSwitch crash actively harmful rather than merely inconvenient.

Android's version is stranger, and the asymmetry surprised me. The OS-owned half is genuinely safe: camera handles and wake locks get released through binder death recipients, so a SIGKILL cleans those up for free. What survives is state we handed to another process on our behalf - specifically a MediaStore row left at IS_PENDING=1. It is not in the gallery, not in our app's sandbox, and nothing ever reaps it, so the user cannot even find it to delete. Our reconciliation isn't flushing kernel state, it's asking MediaStore which of our own rows are still pending and then finalizing or dropping them.

Which surfaces the thing your marker quietly buys you: identity. Your anchor name is a constant. If two instances ever overlap, can the second one tell its anchors from the first's?