Skip to content

Repository files navigation

Offline Motion Camera

Turn an old Android phone into a motion-triggered surveillance camera that never touches the internet.

CI License: MIT minSdk 21 Kotlin Dependencies: none Tested on Android 6.0.1 Vibe coded


That drawer phone from 2015 has a camera, a battery, and nothing to do. This turns it into a security camera: it watches the preview, starts recording when something moves, keeps recording while movement continues, stops after 30 seconds of stillness, and deletes its own oldest clips when the card starts to fill up.

No accounts. No cloud. No streaming. No notifications beyond the one Android requires for a foreground service. No INTERNET permission at all — the app cannot reach the network even if something inside it tried to. Your footage stays on the phone.

Minimum requirement: Android 5.0 (API 21)

This is not a nominal floor that nobody checks. It is the constraint the entire design is built around — the deprecated camera API, the total absence of runtime dependencies, the plain framework widgets. Every one of those is a deliberate trade of elegance for "still works on a ten-year-old phone". See Android 5 Compatibility.

Android 5 support is verified statically, not on hardware. The only device it has run on so far is on Android 6.0.1. What that does and does not cover is spelled out under Tested hardware — please read it before deploying this somewhere that matters.


🤖 Vibe coded

This project was written by Claude Code, Anthropic's AI coding agent, working from a detailed specification written by @GianvitoMarzo. Every line of Kotlin, every test, and this README were produced by the AI; the requirements, the direction, the device testing, and the decision to ship were the human's.

Being honest about that matters, so here is what it does and does not mean:

What was actually verified

  • ✅ Builds clean, and 71 JVM unit tests pass — covering motion detection, the 30-second rule, cleanup ordering, the 500 MB threshold, and file naming.
  • Android Lint runs with NewApi and InlinedApi as errors, so an unguarded call to a newer API fails the build. This caught a real bug: FOREGROUND_SERVICE_TYPE_CAMERA is API 30 while the overload it is passed to is API 29 — guarding at 29 would have crashed on Android 10 exactly.
  • Every framework API the compiled APK references was audited against the platform's own api-versions.xml, walking the inheritance chain. All ten calls above API 21 sit behind a runtime guard. See How this was verified.
  • Run on real hardware — a Xiaomi Redmi Note 3 Pro on Android 6.0.1. Not, so far, on an actual Android 5 device: see Tested hardware for exactly which code paths that leaves unexercised.

What that still does not buy you

An AI wrote this, and AI-written code carries the confident-and-wrong failure mode. One of the bugs in this repository's history was exactly that: the agent reasoned from the javadoc of Camera.lock() that Camera.unlock() was unnecessary, wrote a comment explaining the reasoning, and shipped a recorder that failed on every single configuration. The documentation said the opposite, on the very next method.

So: read the code before you trust it with anything that matters. It is deliberately small — about 3,800 lines of Kotlin — and heavily commented, precisely so that reading it is realistic. If you find something wrong, an issue is very welcome.


Tested hardware

Device Android Status
Xiaomi Redmi Note 3 Pro 6.0.1 Working — detects motion and records

What "minSdk 21, tested on 6.0.1" actually means

The app is built for Android 5.0 and every API it uses is guarded for it, but the only hardware it has run on is Android 6.0.1. That gap is worth being precise about rather than papering over, so here is exactly how big it is.

The app contains version guards at five API levels. On Android 5.0 and on Android 6.0.1, four of the five resolve identically:

Guard API On Android 5.0 On Android 6.0.1
M 23 legacy branch modern branch ⚠️ differs
N 24 legacy branch legacy branch ✅ same
O 26 legacy branch legacy branch ✅ same
R 30 legacy branch legacy branch ✅ same
TIRAMISU 33 legacy branch legacy branch ✅ same

So the Android 6.0.1 run exercised the same pre-Nougat and pre-Oreo code paths Android 5 would take: stopForeground(boolean), the deprecated Notification.Builder(Context), no notification channel, the two-argument startForeground, and startService rather than startForegroundService. The entire camera, recorder, motion-detection and storage core — where all the device-specific risk lives — ran on byte-for-byte the same path.

What remains untested is the API 23 boundary: on Android 5 permissions are granted at install time, so the app skips the runtime-permission flow entirely (four places, all permission-related, plus PendingIntent.FLAG_IMMUTABLE). Those are short, simple branches, and Lint plus the dex audit confirm nothing in them calls an API above 21 — but "verified by static analysis" is not the same as "someone ran it".

Raising minSdk to 23 to match the tested device would be the wrong fix. There is no evidence anything is broken on 21, and Android 5 phones are precisely the drawer phones this app exists for. The honest move is to say what has and has not been run, and ask for help closing the gap.

Help close the gap

Old phones vary enormously in camera behaviour, and there is no way to automate this part: Google publishes no arm64 system image for API 21, so an Android 5 emulator cannot even run on an Apple Silicon Mac.

If you run this on a phone — especially anything on Android 5.x — please open a device report, whether it worked or not. Include the Camera ready: line from the in-app event log, which shows what was requested versus what the camera actually provided. A list of devices known to work, and known to fail, is worth more to this project than most code.


Contents

Using it · What it does · Building · Installing · Storage setup · Settings · Troubleshooting

How it works · Motion detection · Recordings & cleanup · Background service · Event log

Under the hood · Android 5 compatibility · Project layout · Tests · Known limitations

Community · Contributing · Privacy · License


What it does

  1. Opens the camera and monitors the preview continuously.
  2. Detects motion with a lightweight frame-difference algorithm — no ML, no cloud.
  3. Starts recording when motion is confirmed across several consecutive frames.
  4. Saves the video locally, to internal storage or a microSD card.
  5. Keeps recording while motion continues.
  6. Stops 30 seconds after the last significant movement.
  7. Keeps running with the app in the background and the screen off.
  8. Deletes its own oldest recordings when free space drops below 500 MB.

Building

Requirements:

  • JDK 17 or newer. The build is pinned to Java 17 bytecode. A JDK newer than 24 will not run Gradle 8.14, so pick 17 or 21 if you have several installed.
  • Android SDK with platform android-34 (compile target only — the app still runs on API 21).
  • No Android Studio needed; the Gradle wrapper is checked in.

Point local.properties at your SDK:

echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties

Then build:

JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew :app:assembleDebug

The APK lands in app/build/outputs/apk/debug/app-debug.apk.

Run the unit tests:

JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew :app:testDebugUnitTest

Installing

adb install -r app/build/outputs/apk/debug/app-debug.apk

On the phone, allow installation from unknown sources if you are copying the APK across manually. On first launch, grant the camera permission (Android 6+; on Android 5 it is granted at install time).

For a phone that will be left running permanently:

  • keep it plugged into power;
  • turn off any aggressive battery saver or "app sleep" feature for this app;
  • disable automatic system updates so a reboot does not silently interrupt monitoring — or enable Start monitoring on device boot in Settings.

Configuring storage

Open Storage from the main screen. Two kinds of destination are offered.

Built-in folders (recommended). These are the app-specific directories Android gives every app on each storage volume — one on internal storage, and one on the microSD card if the device has a slot. They appear as:

Internal storage   /storage/emulated/0/Android/data/com.offline.motioncam/files/recordings
SD card            /storage/1A2B-3C4D/Android/data/com.offline.motioncam/files/recordings

They need no permission on any Android version, they work on Android 5 and on Android 14 unchanged, and they are the most reliable option for a device that is meant to be left alone for weeks. Their one drawback is that Android deletes them if you uninstall the app.

A folder you choose. "Choose another folder…" opens the system folder picker (ACTION_OPEN_DOCUMENT_TREE, available from API 21) and lets you record into somewhere a person would actually look, such as DCIM on the SD card. The grant is persisted, so it survives reboots.

Whichever you pick, the app verifies the folder is writable before accepting it, and shows the current path and free space on the Storage screen.

If the SD card will not accept a folder

Since Android 4.4, apps cannot write anywhere they like on removable storage. If the picker refuses a folder, or the Storage screen shows NOT writable, either use the built-in SD card folder above, or pick the card's root in the folder picker and let the app create files there. The app never hardcodes /sdcard/...; every path comes from the system.


Settings

Setting Notes
Camera Rear or front, listed only if the device actually has it. Default: rear.
Resolution Read from the camera. Only sizes supported for both preview and recording are offered.
Frame rate Read from the camera, plus a "Maximum" option. Lower means less CPU and smaller files.
Flash Off (default), Automatic, Always on.
Motion sensitivity Low / Medium / High. Changes the per-cell threshold and the changed-area percentage.
Start monitoring on device boot Default off.
Optimized preset One tap: ~854x480, 15 fps, medium sensitivity, flash off.

All settings are saved and restored on restart.

About the flash

The default is Off, and starting a recording never turns the flash on by itself.

  • Always on lights the torch for the whole monitoring session.
  • Automatic lights the torch only when the scene is genuinely dark, using the average brightness the motion detector already computes. It has separate on and off thresholds and a minimum on-time, so it cannot flicker as its own light brightens the scene.

The camera API's FLASH_MODE_AUTO only governs the still-photo flash and does nothing during video, which is why "Automatic" is implemented as a brightness rule instead. If the device has no torch, both modes fall back to off rather than failing.


How motion detection works

camera preview frame (NV21)
          ↓
  take the Y (luma) plane        — no colour conversion needed
          ↓
  average down to a 32x24 grid   — 768 cells instead of ~1M pixels
          ↓
  signed difference vs the previous grid
          ↓
  subtract the mean difference   — cancels a uniform brightness change
          ↓
  count cells over the threshold
          ↓
  changed cells / total >= minimum percentage?
          ↓
      MOTION / NO MOTION

Details that matter in practice:

  • It never compares at full resolution. A 1280x720 frame is sampled down to 768 cells, each the average of up to 16 samples. Averaging within a cell is what stops the detector from chasing sensor noise.
  • Frames are analysed on an interval (200 ms by default), not one by one. A camera delivering 30 fps is still analysed 5 times a second.
  • Global brightness changes are cancelled out. Auto-exposure hunting or a light being switched on shifts every cell by roughly the same amount; subtracting the mean signed difference removes it, so it does not read as whole-frame motion.
  • The first frames after start are ignored while exposure and white balance settle.
  • No allocation in the steady state. The grids are allocated once and reused, and preview buffers are recycled back to the camera.

Every parameter lives in one place, MotionConfig:

Parameter Default Meaning
gridWidth / gridHeight 32 x 24 Size of the downscaled comparison grid
pixelThreshold 24 Per-cell luma delta (0–255) counting as changed
minChangedFraction 0.025 Fraction of cells that must change to call it motion
analysisIntervalMs 200 Minimum gap between analysed frames
compensateGlobalBrightness true Cancel uniform illumination shifts
warmUpFrames 10 Frames ignored after (re)start

The Low / Medium / High sensitivity setting selects preset combinations of pixelThreshold and minChangedFraction.

Start and stop rules

Recording never starts on a single odd frame. A confidence counter goes up on a motion frame and down on a still one, and recording starts only when it reaches motionFramesToStart (3 by default, so roughly 0.6 s of sustained movement). Alternating noise frames never reach the threshold.

NO RECORDING
     ↓ motion confirmed over 3 frames
START RECORDING
     ↓ any further motion
reset the 30 second timer
     ↓ 30 seconds with no motion
STOP RECORDING

After a stop, motion must be confirmed again from scratch before the next recording.

A clip is also rolled over into a new file after 10 minutes of continuous motion. A tree moving in the wind would otherwise produce one enormous file — and a file that is still open when the power goes is a file you lose entirely.


How recordings are stored

Files are named:

motion_2026-08-30_17-42-13.mp4

with _1, _2 appended if two clips start within the same second.

While a clip is being written it carries a partial_ prefix and is renamed on a clean stop. So a leftover partial_motion_...mp4 is a recording that was cut short by a crash or a power cut, and the app sweeps those away when monitoring next starts. The marker is a prefix rather than a .part suffix because document providers rewrite the extension of files they create, and anything after .mp4 does not survive that.

Each folder also gets a small offline_motion_camera.txt marker file, so you can tell at a glance which folder on a card belongs to this app.

Automatic cleanup

check free space
      ↓
free space < 500 MB?
      ↓ yes
delete the oldest recording created by this app
      ↓
check free space again  → still < 500 MB? → delete the next oldest → repeat

It stops as soon as free space is back above 500 MB, or when there is nothing left it is allowed to delete.

It only ever deletes files matching the motion_...mp4 pattern. A video you put in the same folder yourself is invisible to the cleanup, and the name is re-checked immediately before every deletion. The clip currently being recorded is always protected. If free space cannot be determined at all, nothing is deleted.

The check runs before every recording starts and once a minute while monitoring. If space runs out with nothing left to delete, the app stops recording and says so in the log rather than filling the volume.


How the background service works

Monitoring lives in MonitorService, a foreground service. The UI is only a control panel: closing the app, or never opening it again after a reboot, does not stop monitoring.

  • A foreground service keeps the process alive and is what makes camera use from the background legal on every Android version.
  • A partial wakelock is held only while monitoring, and released on every teardown path. It is needed because a foreground service alone does not stop the CPU suspending once the screen turns off — without it, frame analysis stops a few seconds after the display does. The screen itself is never held on.
  • Preview is rendered into a throwaway SurfaceTexture rather than a visible surface, which is what lets the camera keep running with no Activity alive and the screen off.
  • The service is START_STICKY, so Android brings it back if it is killed under memory pressure — which on an old phone left running for weeks will happen.
  • Everything touching the camera, the recorder or storage runs on a single dedicated thread. Calling the legacy camera API from two threads is the classic way to wedge the camera service on Android 5.

Screen off

Start monitoring, then turn the screen off normally. The camera, motion detection and recording all continue. Nothing in the app prevents the screen from turning off.

Recovery

If the camera reports an error or is taken by another app, the service releases everything and retries with a backoff (2 s, doubling, capped at 60 s) instead of crashing or hammering a camera service that has just died. A recording in progress when the camera disappears is closed and kept rather than discarded.


Event log

The main screen has an Event log button showing recent events, newest first:

17:42:13 Motion detected
17:42:13 Recording started: motion_2026-08-30_17-42-13.mp4
17:43:27 No motion for 30 s — recording stopped
17:43:27 Video saved: motion_2026-08-30_17-42-13.mp4

It is a bounded in-memory ring plus a small text file in the app's private storage — no database. The file is capped and lives away from the videos, so it can never be confused with a recording by the cleanup, and it survives the SD card being pulled.


Android 5 Compatibility

This is the section to read before changing anything.

The rule the code follows

Where a modern API and an older one both work, the older one is used, encapsulated in one place, and the reason is written down. Every call to an API newer than 21 is behind an explicit Build.VERSION.SDK_INT check.

Deliberate choices

android.hardware.Camera, not camera2. Camera2 also exists on API 21, but on Android 5 hardware it is almost always at LEGACY hardware level, where it is a slower shim over this same HAL and is buggy on many 2014–2015 devices. The legacy API is deprecated, so it is confined to camera/CameraController.kt, camera/CameraCapabilities.kt and record/VideoRecorder.kt, each marked @file:Suppress("DEPRECATION").

No AndroidX, no support library, no Material Components. The app has zero runtime dependencies. The UI uses plain Activity and framework widgets with @android:style/Theme.Material.Light.DarkActionBar, which exists from API 21 and behaves identically on every later version. This removes the single largest source of "works on Android 12, breaks on Android 5" surprises.

DocumentsContract directly, not androidx.documentfile. Same API, no dependency, and folder contents are listed with one projected query instead of one query per file per attribute — which matters in a folder holding weeks of clips.

PNG icons, not vector drawables. Vector drawables exist from API 21, but using one as a notification small icon crashes the system process on some API 21–23 devices, because the icon is inflated by a different process. Plain PNGs at four densities avoid the whole question.

No audio. Recording is video-only, so the app needs no RECORD_AUDIO permission and no FOREGROUND_SERVICE_MICROPHONE. This is a deliberate simplification, not an oversight.

The recorder tries several configurations before giving up. Encoders on old devices reject parameter combinations that look perfectly reasonable, so a failed start falls back in order: the requested size/rate/bitrate on H.264, then the bitrate and codec taken from the device's own CamcorderProfile for that resolution, then H.264 with no explicit rate or bitrate at all, then MPEG-4 SP. Whatever succeeds is used; if all four fail, the exact error from each is written to the event log, since this only ever goes wrong on real hardware where nobody is reading logcat.

No WRITE_EXTERNAL_STORAGE. App-specific external directories and SAF folders both work without it on every Android version.

Camera.unlock() is called before handing the camera to MediaRecorder; Camera.lock() is not called afterwards. The two halves are not symmetric, which is easy to get wrong. Camera.unlock() is documented as required before MediaRecorder.setCamera(), and without it prepare()/start() fail on every configuration. Camera.lock(), by contrast, is genuinely unnecessary from API 14 onward, because MediaRecorder.start() re-locks the camera for the app — which is also what keeps addCallbackBuffer legal while recording, and therefore what lets motion detection continue during a clip. If a handover fails, Camera.reconnect() reclaims the camera before the next attempt; skipping that leaves the camera unlocked and every subsequent attempt fails too.

APIs used above API 21, and their guards

This is the complete list, taken from the built APK rather than from memory — see How this was verified below.

API Since Guard
Activity.requestPermissions 23 SDK_INT >= M; permissions are install-time below
Context.checkSelfPermission 23 SDK_INT >= M; assumed granted below
Service.stopForeground(int) 24 SDK_INT >= N, else stopForeground(boolean)
Notification.Builder(Context, String) 26 SDK_INT >= O, else Notification.Builder(Context)
NotificationChannel and its methods 26 SDK_INT >= O; no channel exists below
NotificationManager.createNotificationChannel 26 SDK_INT >= O
NotificationManager.getNotificationChannel 26 SDK_INT >= O
Context.startForegroundService 26 SDK_INT >= O, else startService
Service.startForeground(int, Notification, int) 29 SDK_INT >= R, else the two-argument form
ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA 30 SDK_INT >= R
PendingIntent.FLAG_IMMUTABLE 23 SDK_INT >= M (inlined constant)
POST_NOTIFICATIONS 33 SDK_INT >= TIRAMISU
android:foregroundServiceType="camera" 29 Manifest attribute; the API 21 parser ignores unknown attributes

Two of these deserve a note, because both are easy to get wrong:

  • FOREGROUND_SERVICE_TYPE_CAMERA is API 30, not 29, even though the three-argument startForeground overload it is passed to arrived in 29. Guarding it at 29 compiles cleanly and then throws NoSuchFieldError on Android 10 exactly. It is guarded at 30; on API 29 the two-argument form is used and the manifest's foregroundServiceType still applies.
  • onRequestPermissionsResult is overridden without calling super. That method only exists on Activity from API 23, so the super call would be a NoSuchMethodError on Android 5. Nothing on that platform ever invokes it, but omitting the call makes the method safe to run anywhere; the base implementation is empty and this app uses no fragments.

Everything else — ACTION_OPEN_DOCUMENT_TREE, DocumentsContract.renameDocument, android.system.Os.fstatvfs, Environment.isExternalStorageRemovable(File), Notification.VISIBILITY_SECRET — is API 21 exactly, which is why those particular approaches were chosen.

Why targetSdk is 34 rather than 21

A low targetSdk would opt the app out of modern restrictions, but Android 14 refuses to install apps targeting below API 23 at all, and the app is meant to be usable on a newer spare phone too. Targeting 34 means the newer rules (runtime permissions, foreground service types, notification permission) are all handled explicitly — and every one of those code paths is skipped on Android 5.

How this was verified

Three checks, the first two run against the built APK:

  1. Android Lint with NewApi and InlinedApi promoted to errors, so the build fails rather than warns on an unguarded call. This is what caught the FOREGROUND_SERVICE_TYPE_CAMERA mistake above.
  2. A direct audit of the compiled dex. Every framework method and field the app's own classes reference — including methods inherited from Activity and Service and called on this, which the naive form of this check misses — is resolved against the platform's own api-versions.xml database, walking the inheritance chain. That produces the table above exhaustively, with no reliance on anyone remembering to annotate anything. Each entry was then confirmed to sit inside a Build.VERSION.SDK_INT check.
  3. Run on a real phone — a Xiaomi Redmi Note 3 Pro on Android 6.0.1. Camera behaviour, MediaRecorder and SD card quirks are hardware-specific and no amount of static analysis substitutes for this.

What has not been done is running it on an actual Android 5 device. Google publishes no arm64 system image for API 21, so an API 21 emulator cannot run on an Apple Silicon Mac at all; that verification needs an x86 host or the phone itself. Tested hardware sets out precisely which code paths that leaves unexercised — the short answer is that four of the five version guards behave identically on 5.0 and 6.0.1, and the one that differs is the runtime-permission flow.

Free space on a SAF folder

A document provider exposes no "free bytes" column, and API 21 has no StorageManager call that maps a document tree back to a volume. The app opens a file descriptor on a file inside the folder and calls android.system.Os.fstatvfs, which is itself API 21. That is what the offline_motion_camera.txt marker is for when the folder holds no clips yet.


Troubleshooting

"Could not record at …" in the status banner. Open the event log: the line beginning Recorder rejected every configuration lists what each fallback attempt reported. After a failure the app backs off (30 s, growing to 5 minutes) instead of retrying on every burst of motion.

Checking that a settings change took effect. Every time the camera opens, the event log records both what was requested and what the camera actually provided:

Camera ready: Rear 1280x720 @ 15fps (requested 1280x720 @ 15fps)

If the two differ, the camera does not support the requested combination and the closest available one was used. Settings only take effect the next time monitoring starts — they are read once per session, so stop and restart monitoring after changing them.

Nothing is detected at all. Check the log for Camera ready. If it is missing, the camera could not be opened — usually another app holds it, or the camera permission was revoked.

Known limitations

No pre-motion buffer. Recording starts when motion is confirmed, so roughly the first 0.6 s of movement is not in the file. Keeping the previous 5 seconds would mean encoding continuously into a circular buffer, which MediaRecorder cannot do — it writes straight to a file and cannot be rewound. Doing it properly would mean driving MediaCodec and MediaMuxer by hand with a rolling buffer of encoded frames, which on Android 5 LEGACY camera hardware is exactly the kind of fragile, device-specific code this project is meant to avoid. It is deferred to a later phase, as a secondary feature that is not worth trading stability for.

Motion detection during recording is device-dependent. Some devices stop delivering preview callbacks while MediaRecorder holds the camera. Recording itself is unaffected, but on those devices motion cannot extend a clip, so it ends on the 30-second rule and a new one starts immediately if movement continues. The app detects this, notes it in the event log ("No preview frames while recording"), and keeps working — you get several sequential clips instead of one long one.

No automatic rotation. The configuration UI is locked to portrait. Recordings are stamped with the correct orientation, computed from the sensor mounting angle and the device rotation at the moment recording starts. Rotating the phone mid-session is not handled; mount it and leave it.

App-specific folders are removed on uninstall. If you record into the built-in Internal storage or SD card folders, uninstalling the app deletes the recordings with it. Choose your own folder if that matters, or copy footage off first.

Video only, no audio. By design; see above.

The 30-second rule and the 10-minute segment limit are not exposed in the UI. They are constants in RecordingRuleConfig, in one place and easy to change.


Project layout

app/src/main/java/com/offline/motioncam/
├── MonitorService.kt                               foreground service, orchestration
├── BootReceiver.kt                                 optional start on boot
├── AppSettings.kt                                  persisted settings
├── EventLog.kt                                     in-memory ring + file
├── MonitorStatus.kt                                live state for the UI
├── ui/
│   ├── MainActivity.kt                             status panel, start/stop
│   ├── SettingsActivity.kt                         camera, resolution, fps, flash
│   ├── StorageActivity.kt                          storage folder selection
│   ├── EventLogActivity.kt                         event log viewer
│   └── Background.kt                               keeps storage I/O off the main thread
├── camera/
│   ├── CameraController.kt                         camera thread, preview, recovery
│   ├── CameraCapabilities.kt                       real device capabilities
│   ├── VideoProfiles.kt                            resolution / fps / bitrate rules
│   └── AutoFlashController.kt                      brightness-driven torch
├── motion/
│   ├── MotionConfig.kt                             every tunable, in one place
│   ├── MotionDetector.kt                           frame differencing
│   └── RecordingRules.kt                           start rule, 30 s stop rule
├── record/VideoRecorder.kt                         MediaRecorder session
└── storage/
    ├── StorageTarget.kt                            destination interface
    ├── FileStorageTarget.kt                        internal + microSD, no permissions
    ├── SafStorageTarget.kt                         user-chosen folder (API 21 SAF)
    ├── StorageTargets.kt                           discovery and restore
    ├── RecordingNaming.kt                          naming and recognition
    └── StorageCleaner.kt                           the 500 MB rule

Tests

71 unit tests run on the JVM with no device and no Robolectric. The motion, naming, cleanup and profile-selection logic is deliberately free of Android imports so it can be tested directly:

JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew :app:testDebugUnitTest
Suite Covers
MotionDetectorTest Frame differencing, thresholds, noise rejection, brightness compensation, odd frame sizes, buffer bounds
MotionStateMachineTest Start confirmation, the 30-second timer and its reset, segment rollover, ticks without frames
StorageCleanerTest Oldest-first deletion, the 500 MB threshold, protecting the in-progress clip, never touching foreign files
RecordingNamingTest Name format, round-tripping, chronological sorting, rejecting files the app did not create
VideoProfilesTest Resolution and frame-rate selection, bitrate clamping, orientation hints
AutoFlashControllerTest Torch hysteresis and minimum on-time

Hardware behaviour — the camera itself, MediaRecorder, real SD cards — cannot be unit-tested and needs a device.

Privacy

No INTERNET permission. No Firebase, no analytics, no crash reporting, no ads, no accounts, no login, no server, no streaming, no automatic uploads. Video never leaves the device.

This is enforced by the manifest, not by good intentions: without the INTERNET permission the app is incapable of opening a socket, whatever any future change might try to do.

A word on the law. Filming people has rules, and they differ by country — consent, signage, what you may record in a shared hallway, how long you may keep it. This app makes it easy to point a camera at something; making sure you are allowed to is on you.


Contributing

Contributions are very welcome, and device reports are worth more than code here — see Tested hardware.

  • 🐛 Report a bug — device and Android version required, they are what make a report actionable
  • 📱 Report a device — successes count too
  • Suggest a feature — please read the scope note first
  • 📖 CONTRIBUTING.md — setup, what a good pull request looks like
  • 🤖 CLAUDE.md — project conventions and the specific API-level traps that have already caught this codebase once. Written for AI assistants, but it is the shortest accurate description of how this code is meant to be changed.

The one rule that overrides everything: compatibility with Android 5.0 beats modernity. A change that works beautifully on Android 12 and breaks on Android 5 is not a change this project can take.

License

MIT — do what you like with it. Copyright © 2026 Gianvito Marzo.

Acknowledgements

Built for a specific, unglamorous purpose: giving a drawer full of obsolete phones something useful to do instead of going to landfill. If this saves one device from the bin, it has paid for itself.

Written with Claude Code. See Vibe coded for what that means and what it does not.

About

Turn an old Android phone into a fully offline motion-triggered security camera. No cloud, no accounts, no INTERNET permission. Built for Android 5.0 (API 21), tested on Android 6.0.1.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages