DEV Community

Super Funicular
Super Funicular

Posted on

Storage, Not Heat, Is the Real Limit on 24/7 Android Recording: Segment Rotation, Retention Math, and Scoped Storage

Short answer: A phone recording 1080p at 8 Mbps writes about 3.6 GB per hour — roughly 86 GB a day. On a 128 GB phone with 90 GB free, that is about 25 hours before the disk is full. Any 24/7 local recording setup therefore needs three things: segment rotation (many short files, not one long one), a retention policy (something deletes the oldest segments), and graceful degradation (the recorder must survive a full disk without corrupting what it already wrote). This is a walkthrough of the Android APIs that make those three things work — setMaxFileSize + setNextOutputFile, MediaStore with IS_PENDING, and StorageManager.getAllocatableBytes() — and the math you need before you write any of it.

I wrote a while back that the hardest part of running a phone as a 24/7 camera isn't the camera — it's heat and the battery. That's true for the first week. Solve thermals, keep it on a charger, and the phone will happily record until it hits the next wall.

The next wall is storage. And storage is a more interesting engineering problem than heat, because heat is a physics problem you mitigate, while storage is a budget problem you design around. You cannot mitigate your way out of a finite disk. You can only decide, in advance, what you're willing to throw away.

This is the part of local-first camera architecture that nobody writes about, because cloud cameras hide it behind a subscription tier. When Ring tells you "30 days of history," that's not a feature — that's a retention policy someone else picked, running on someone else's disk budget. If you're recording locally, you own that decision. Which means you have to actually make it.

Here's how the pieces fit together on Android.

1. Do the math before you write any code

Video storage is one of the few problems where a back-of-the-envelope calculation tells you almost everything. The formula is boring:

bytes_per_second = bitrate_bits_per_second / 8
retention_seconds = free_bytes / bytes_per_second
Enter fullscreen mode Exit fullscreen mode

Bitrate, not resolution, is what fills your disk. Resolution influences the bitrate you need for a given quality, but a 1080p stream at 2 Mbps and a 720p stream at 2 Mbps consume disk at exactly the same rate. Everyone gets this backwards.

Run the numbers at some realistic H.264 bitrates:

Bitrate Per hour Per day Days on 90 GB free
8 Mbps (1080p, high quality) 3.6 GB 86.4 GB ~1.0
4 Mbps (1080p, moderate) 1.8 GB 43.2 GB ~2.1
2 Mbps (720p, decent) 0.9 GB 21.6 GB ~4.2
1 Mbps (720p, soft) 0.45 GB 10.8 GB ~8.3
0.5 Mbps (480p, monitoring-grade) 0.225 GB 5.4 GB ~16.7

That table is the entire product decision. If a user wants "a week of history" from a 128 GB phone, you are telling them to record at roughly 1 Mbps — and 1 Mbps at 1080p30 is a smeary mess on any scene with motion. So they either accept 720p, accept less history, or add a USB drive (I dug into that path in can I replace my Blink camera with an old Android phone, and what does free local storage on a USB actually look like).

There's no clever code that gets you out of this. Write the table down first. Everything below is just plumbing to make the table true.

2. One long file is the wrong shape

The naive implementation records to a single file until stopped. It's wrong for three separate reasons, and each one is worth understanding.

Reason one: the moov atom. An MP4 file has an index — the moov atom — that maps timestamps to byte offsets. MediaRecorder writes it when you call stop(), because it can't know the final layout until then. If your process dies before stop() — OOM kill, battery pull, a crash at hour 41 — the file has all your video data and no index. Most players will refuse to open it. You didn't lose a frame; you lost the map, which is worse, because it looks like you lost everything.

Segment rotation bounds this loss. With 5-minute segments, an ugly death costs you at most 5 minutes. With one 40-hour file, it costs you 40 hours. This is the same failure class I wrote about from a user's perspective in what happens to your footage when a free camera app shuts down — except here the thing shutting down is your own process.

Reason two: deletion granularity. A retention policy deletes things. If your recording is one file, the smallest thing you can delete is everything. Segments are the unit of forgetting. No segments, no retention policy.

Reason three: seeking. Scrubbing to hour 30 of a 40-hour MP4 means the player parses an enormous index. Segment files are individually cheap to open, and "seek" becomes "pick the file whose name contains the timestamp you want" — which is a filename sort, not a media problem.

So: many short files. Five minutes is a reasonable default: at 8 Mbps that's a ~300 MB segment — small enough to move around and cheap enough to lose, big enough that you're not thrashing the filesystem every few seconds.

3. Rotating without dropping frames

The obvious way to rotate is stop(), open a new file, start(). Don't. Tearing down and re-initializing the encoder takes hundreds of milliseconds, during which you are not recording. Do that every 5 minutes for a day and you've punched 288 holes in your timeline — and holes always land exactly when something happens.

Android has a first-class answer for this, added in API 26. MediaRecorder will hand off to a pre-supplied next file when the current one hits its size limit, without stopping the encoder. Three pieces:

// 1. Set the size limit — after setOutputFormat(), before prepare().
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
// ... video/audio source, encoder, bitrate config ...
recorder.setMaxFileSize(SEGMENT_BYTES)
recorder.prepare()
Enter fullscreen mode Exit fullscreen mode

The ordering there is not stylistic — setMaxFileSize() is documented to be called after setOutputFormat() and before prepare(), and you'll get an IllegalStateException for your trouble otherwise.

// 2. Listen for the 90% warning and queue the next file.
recorder.setOnInfoListener { _, what, _ ->
    when (what) {
        MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_APPROACHING -> {
            // Fired once per file, at 90% of the limit.
            // This is the ONLY window in which setNextOutputFile() is legal.
            val next = openNextSegmentFd()          // must be seekable
            recorder.setNextOutputFile(next)
        }
        MediaRecorder.MEDIA_RECORDER_INFO_NEXT_OUTPUT_FILE_STARTED -> {
            // The previous segment is now closed and finalized.
            // Safe to index it, expose it, and run retention.
            onSegmentClosed()
        }
        MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED -> {
            // You only get here if you missed the window. Recording stops.
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The contract is tighter than it looks, and it's worth being precise about it:

  • MEDIA_RECORDER_INFO_MAX_FILESIZE_APPROACHING fires once per file, at 90% of the limit. Once. If you miss it, or your handler throws, you don't get a second warning — you get MAX_FILESIZE_REACHED and the recording stops.
  • setNextOutputFile() is only legal between those two callbacks. Call it outside that window and you get an IllegalStateException.
  • The next file must be seekable, because the encoder needs to seek back and patch that moov atom. A regular file or a read-write ParcelFileDescriptor is fine. A pipe or a socket is not.
  • Once you've handed a file descriptor over, don't touch the file until stop().

That 90% number has a consequence people miss: your warning window is measured in bytes, but your reaction time is measured in seconds, and the exchange rate between them is the bitrate. At 8 Mbps with a 300 MB segment, the last 10% is about 30 MB, which is about 30 seconds of runway. Halve the segment size and you halve that window too. Thirty seconds is plenty — unless openNextSegmentFd() decides to do a MediaStore insert on the main thread while the device is under I/O pressure. Prepare the next descriptor cheaply, and prepare it off the main thread.

4. Where the bytes actually go: MediaStore and IS_PENDING

Since Android 10, scoped storage means you don't get to scribble into shared storage with raw filesystem paths. For video that the user should see in their gallery, the path is MediaStore, and it has a flag that exists precisely for our situation.

The problem: a partially-written video is a landmine. Other apps see it, index it, generate a thumbnail from a file with no index yet, and show the user a broken entry. IS_PENDING solves this — insert with IS_PENDING = 1 and the item is hidden from other apps until you flip it to 0.

val values = ContentValues().apply {
    put(MediaStore.Video.Media.DISPLAY_NAME, "cam_${timestamp}.mp4")
    put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
    put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/BackgroundCamera")
    put(MediaStore.Video.Media.IS_PENDING, 1)   // hidden from other apps
}

val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)!!

// "rw" — read-write, and therefore seekable, which setNextOutputFile() requires.
val pfd = resolver.openFileDescriptor(uri, "rw")!!
recorder.setNextOutputFile(pfd.fileDescriptor)

// ... later, on MEDIA_RECORDER_INFO_NEXT_OUTPUT_FILE_STARTED for the FOLLOWING
// segment — i.e. once THIS file is closed and finalized — publish it:
resolver.update(uri, ContentValues().apply {
    put(MediaStore.Video.Media.IS_PENDING, 0)
}, null, null)
Enter fullscreen mode Exit fullscreen mode

Note the timing on that last step, because it's the bug you'll actually ship: the segment isn't finalized when the next file is queued, it's finalized when the next file starts. Flip IS_PENDING to 0 on NEXT_OUTPUT_FILE_STARTED, not on MAX_FILESIZE_APPROACHING. Publish early and you've handed the gallery an unindexed MP4 — the exact broken thumbnail you were trying to avoid.

The nice property of IS_PENDING is that it degrades correctly. If your process dies with a segment still pending, the item stays hidden rather than surfacing as a corrupt file, and Android will clean up abandoned pending items on its own.

5. Ask the system how much room you have — don't guess

File.getUsableSpace() is the obvious call and it is the wrong one. It under-reports, because the system is often willing to delete other apps' cached files to satisfy your allocation. The right call is on StorageManager:

val storageManager = getSystemService(StorageManager::class.java)
val uuid = storageManager.getUuidForPath(targetDir)

// Larger than getUsableSpace(): includes reclaimable cache the system will evict.
val allocatable = storageManager.getAllocatableBytes(uuid)

if (allocatable >= SEGMENT_BYTES) {
    // Causes the system to actually evict cached files to make room.
    storageManager.allocateBytes(uuid, SEGMENT_BYTES)
}
Enter fullscreen mode Exit fullscreen mode

Two rules from the platform docs that matter for a recorder specifically:

  1. Allocating beyond getAllocatableBytes() fails, and because other apps are running concurrently, the value is inherently racy. Treat it as a strong hint, not a contract. Handle the failure anyway.
  2. Don't call allocateBytes() more than once every 60 seconds if you're progressively allocating an unbounded amount of space. The docs call this out using recording video as the example — which is to say, us. A per-segment allocation on a 5-minute rotation is comfortably inside that limit. A per-second "top up the budget" loop is exactly the antipattern the rule exists to prevent.

(There's a FLAG_ALLOCATE_AGGRESSIVE that makes the system consider more data for deletion, but it's gated behind a permission ordinary Play Store apps don't hold. Treat it as unavailable.)

6. Degrade in a ladder, not off a cliff

A 24/7 recorder will eventually meet a full disk — a big download, a system update, a user who filled the gallery. The difference between a good implementation and a bad one is entirely in what happens next.

The bad one calls start(), gets an IOException, and dies at 3 a.m. The user finds out at 9 a.m., which is three hours after the thing they wanted recorded.

The ladder, roughly in the order you should climb down:

  1. Rotate first. Before each new segment, if projected free space is under your floor, delete the oldest segment. Steady state for any continuous recorder is a ring buffer: one segment in, one segment out. The disk should sit at a stable high-water mark forever, not creep toward full.
  2. Then degrade quality. If deleting isn't keeping up — or you've hit the retention floor the user asked for — drop the bitrate. 4 Mbps to 2 Mbps doubles your remaining runway. Degraded video is worth infinitely more than no video, and this is a decision worth surfacing rather than making silently.
  3. Then stop cleanly. If you truly cannot proceed, call stop() so the current segment gets its moov atom, flip IS_PENDING to 0, post a notification that says what happened, and stay alive. A recorder that stops recording but keeps telling you it stopped is a working system. One that vanishes is not.

Never, ever let a full disk take the process down mid-segment. That's the one path that costs you data you already successfully recorded.

The ring-buffer default deserves emphasis: retention should be enforced on write, not on a timer. A nightly cleanup job is a nightly opportunity to be dead when it matters. Delete the oldest segment as part of opening the newest one, and the invariant holds continuously without a scheduler.

7. The policy question is the actual product decision

Everything above is plumbing. The interesting question is who decides what gets thrown away.

On a cloud camera, that decision is a pricing tier. Your footage lives on someone else's disk, so someone else picks the retention window, and it changes when their business model changes. That's not a technical constraint — it's an org chart.

Recording locally moves the decision to you, and it's a real trade, not a free lunch. You get: no subscription, no account, no third party with a copy, and a retention window bounded only by the disk you're willing to attach. You take on: being the person who decides how many days of history is enough, and being the person responsible for backing up anything you want to keep forever.

That's the trade Background Camera RemoteStream is built around. It records with the screen off, stores video locally on the device — no cloud, no account, no subscription — runs an embedded Ktor web server so you can watch the camera from a browser on your own network, and can push an unlisted YouTube Live stream when you need to see it from outside the house. The foreground service that keeps the camera alive with the screen off is the piece that makes any of this run unattended in the first place.

Honest scoping, because this post is engineering and not a pitch: the local-first model means you are the sysadmin. There's no AI motion classification deciding which clips matter. YouTube Live has real latency and isn't a low-latency monitor. The browser view works over your own network, so you need to be on that Wi-Fi to use it. And retention is a disk you have to think about — which is the entire point of this article, and also, honestly, the cost of the deal.

If you want the phone-as-camera setup without a monthly bill: Background Camera RemoteStream on Google Play · superfunicular.com

The summary I'd tape to the monitor

  • Bitrate fills the disk, not resolution. Do the retention table before the architecture.
  • Segments, not one long file — the moov atom means an unclosed MP4 is an unplayable MP4, and segments bound that loss.
  • setMaxFileSize() + setNextOutputFile() rotate without dropping frames. The window is one callback wide and the descriptor must be seekable.
  • IS_PENDING = 1 on insert, 0 on NEXT_OUTPUT_FILE_STARTED — not a moment sooner.
  • getAllocatableBytes(), not getUsableSpace(). Once per 60s, not once per second.
  • Enforce retention on write. A ring buffer has no bad night.

If you've built something similar and made different calls — particularly on segment length, or on whether to degrade bitrate silently versus

Top comments (0)