Originally answered on Quora: "How do you record a video with the screen off in Android?". This is the dev.to canonical — the long, code-level version of the same architecture, written for developers shipping a camera-adjacent Android app.
If you've tried to build an Android camera app that records with the screen off, you've hit the wall I'm about to describe. The Camera2 session works fine while the app is foregrounded. You lock the screen. A few seconds later — sometimes thirty, sometimes ten minutes, sometimes after a Doze maintenance window — the frames stop. No exception, no broken pipe, no helpful log. Just silence.
This article is a tour of the four-system problem that creates that silence, and the architecture Background Camera RemoteStream (https://play.google.com/store/apps/details?id=com.superfunicular.digicam&utm_source=devto&utm_medium=article&utm_campaign=2026w22) uses to keep a Camera2 session alive across screen-off, Doze, App Standby, low-memory pressure, and the OEM-specific battery-saver patches that ship on real phones.
It also covers the two pieces the Quora answer left on the cutting-room floor: how to handle orientation when there's no Activity context to read rotation from, and how to keep focus and exposure sane across a multi-hour recording where lighting can change from sunset to streetlight without the user touching the device.
The four systems that will kill your camera session
You are not fighting one thing. You are fighting four overlapping power-management systems, and each one has a different lever:
The foreground-service lifecycle. If your camera capture lives in a regular service, the system can kill it for memory pressure within seconds of the screen turning off. The lever here is
ForegroundServiceType.CAMERA(added in API 29, required on API 34+) with a persistent notification.Doze mode. After the device has been stationary and unplugged for a while, the system batches background work into windows. Network calls are deferred, alarms are coalesced, and — critically for our case — wakelocks held without the right foreground-service type can be revoked. The lever is having the right foreground-service type, plus a partial wakelock with the correct tag.
App Standby Buckets. Even if you survive Doze, the system bucketizes your app (active → working set → frequent → rare → restricted) based on user-perceived usage. A "rare"-bucket app can have its camera and network capabilities curtailed during periods of non-interaction. The lever is the foreground-service notification: as long as the user can see and dismiss it, the bucket stays warm.
OEM patches. This is the layer the AOSP documentation does not warn you about. Xiaomi's MIUI, Huawei's EMUI, Samsung's One UI, OPPO's ColorOS, Vivo's FunTouchOS, OnePlus's OxygenOS — each one has a vendor battery optimizer that aggressively kills background camera and recording apps even when AOSP's rules say the app is allowed to keep running. The lever varies by OEM, but the universal shape is: a per-app "autostart" / "allow background activity" toggle that the user has to flip manually. You cannot programmatically opt in; the best you can do is detect the OEM, link the user to the right Settings page, and explain the trade-off.
If you only address #1, your session survives ten minutes on a clean Pixel and dies in three on a Xiaomi. If you address #1–#3 but not #4, you ship a 1-star review storm from users on every Chinese OEM. We learned this the hard way and rewrote the model.
The architecture in one diagram (described)
┌──────────────────────────────────────────────────┐
│ Activity (UI) │
│ ─ binds to CameraService for status only │
│ ─ does NOT own the CameraCaptureSession │
└──────────────────────────────────────────────────┘
│ bind
▼
┌──────────────────────────────────────────────────┐
│ CameraService (Foreground, type=camera|microphone)│
│ ─ holds the CameraDevice │
│ ─ holds the CameraCaptureSession │
│ ─ holds a PARTIAL_WAKE_LOCK │
│ ─ posts the persistent notification │
│ ─ owns a MediaRecorder OR a MediaCodec encoder │
│ ─ also hosts the Ktor server (port 8080) │
└──────────────────────────────────────────────────┘
│ frames
▼
┌────────────────┴─────────────────┐
▼ ▼
File on local storage Ktor HTTP handler
(MP4, hours of continuous) (MJPEG/HLS to LAN viewer)
The single most important architectural decision is: the Activity does not own the camera session. The Activity is a thin client that binds to the service and reads status. The service owns the CameraDevice, the CameraCaptureSession, the encoder, the wakelock, and the HTTP server. When the screen turns off, the Activity goes through its normal onPause → onStop lifecycle, and nothing happens to the camera session, because the session was never owned by the Activity in the first place.
This is the inverse of every Camera2 tutorial you've ever read. The tutorials open the camera in the Activity because they're showing you a viewfinder. We don't have a viewfinder when the screen is off, so the viewfinder ownership pattern is exactly wrong for our use case.
Step 1: The foreground service declaration
In AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<service
android:name=".CameraService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
The foregroundServiceType="camera|microphone" line is the one most tutorials get wrong post-API 30. Without it, calling startForeground() will throw ForegroundServiceTypeException on API 34+, and your service dies the moment you try to attach the camera.
In CameraService.onCreate() you call startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or FOREGROUND_SERVICE_TYPE_MICROPHONE). The notification must be visible and non-dismissible. The user being able to see the recording is the price of being allowed to do the recording, and it's also a privacy win — we don't hide that we're using the camera.
Step 2: Opening the camera without a SurfaceView
This is where the AOSP samples stop being useful. Every sample opens the camera and feeds frames into a TextureView or SurfaceView so you can see the preview. With the screen off, the SurfaceView's surface is not valid; the window is not visible; you can't even create the view tree from a service in the first place.
The fix is to send the camera's output directly to surfaces that don't require a visible window:
- A
MediaRecorder.getSurface()for MP4 recording to local storage. - An
ImageReader.getSurface()withImageFormat.YUV_420_888for the frame stream that goes to the HTTP server (we re-encode each frame as JPEG inline for the MJPEG response).
Both of these surfaces are background-valid. You build the SessionConfiguration with these as the only output targets — no preview surface at all — and the CameraCaptureSession will happily run for hours with the screen off.
val recorderSurface = mediaRecorder.surface
val imageReaderSurface = imageReader.surface
val outputs = listOf(
OutputConfiguration(recorderSurface),
OutputConfiguration(imageReaderSurface)
)
val sessionConfig = SessionConfiguration(
SessionConfiguration.SESSION_REGULAR,
outputs,
executor,
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
val request = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD).apply {
addTarget(recorderSurface)
addTarget(imageReaderSurface)
set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO)
}.build()
session.setRepeatingRequest(request, null, handler)
}
override fun onConfigureFailed(session: CameraCaptureSession) { /* … */ }
}
)
cameraDevice.createCaptureSession(sessionConfig)
The TEMPLATE_RECORD template is the right choice over TEMPLATE_PREVIEW because it sets the auto-exposure and auto-focus modes for sustained recording, not for a viewfinder-feedback loop. CONTROL_AF_MODE_CONTINUOUS_VIDEO keeps focus drifting smoothly with the scene rather than hunting; it's the same mode the system camera uses while filming.
Step 3: The wakelock
Foreground service + camera type is necessary but not always sufficient. On most stock Android builds, the foreground-service-camera type implicitly keeps the CPU awake. On some OEM builds it does not — particularly during long uninterrupted recording sessions where the system aggressively tries to deepen sleep. The defensive move is to take a PARTIAL_WAKE_LOCK while the camera session is running and release it the instant recording stops.
val pm = getSystemService(POWER_SERVICE) as PowerManager
val wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"BackgroundCameraRemoteStream:CameraSession"
).apply { setReferenceCounted(false) }
// when starting:
wakeLock.acquire(8 * 60 * 60 * 1000L) // 8h timeout
// when stopping:
if (wakeLock.isHeld) wakeLock.release()
The timeout matters. An unbounded acquire() is a battery footgun; eight hours is the rough upper bound of a continuous recording session on a 4000mAh device, and the system will tear the session down anyway when the camera-capable battery floor is hit. If you somehow run longer, you can re-acquire on session resume.
The tag (BackgroundCameraRemoteStream:CameraSession) is not cosmetic. The system attributes wakelock cost to that tag in battery debug dumps; making it human-readable makes the battery-blame audit easier when a user reports drain.
Step 4: Orientation without an Activity
The orientation problem is the part most "background camera" tutorials skip. With a foreground Activity, you read display.rotation and feed it into your CaptureRequest. With the screen off and no Activity, the Display you'd want to query is dark and unreliable. We tried three approaches and only one of them holds.
What does not work: querying WindowManager.defaultDisplay.rotation from the service. On most devices it returns the last-known value, but on some Samsung and Xiaomi builds it returns ROTATION_0 regardless of how the device was oriented when the screen turned off — particularly if SensorManager was set to landscape-locked by the launcher.
What also does not work: subscribing to the OrientationEventListener during recording. The listener fires only when the screen is on. When the screen turns off, you stop getting callbacks. So you end up frozen on whatever the last reading was — which is fine 80% of the time and catastrophic the other 20%.
What does work: read Sensor.TYPE_ROTATION_VECTOR (or TYPE_GAME_ROTATION_VECTOR) directly from the SensorManager at recording start, derive a stable orientation from the rotation matrix, write it into the MediaRecorder.setOrientationHint() once, and don't change it during the session. The recording orientation is not actually expected to track the device through the recording — setOrientationHint() is a one-shot tag baked into the MP4 metadata. Any "rotation tracking" you want during the recording itself belongs in encoder-level metadata, not in the session config.
private fun stableOrientationAtRecordStart(): Int {
val rotationVector = FloatArray(3)
val rotationMatrix = FloatArray(9)
// … listener that fills rotationVector, then:
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
val orientations = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientations)
val azimuthDeg = Math.toDegrees(orientations[0].toDouble())
// Map azimuth → 0/90/180/270 for setOrientationHint
return when {
azimuthDeg in -45.0..45.0 -> 0
azimuthDeg in 45.0..135.0 -> 90
azimuthDeg in -135.0..-45.0 -> 270
else -> 180
}
}
Two notes. First, on a phone you've mounted upside down (the very common case for an old phone clipped to a window with a phone holder), this gives you the correct upside-down orientation, which MediaRecorder will then bake in so the recording plays right-side-up on any video viewer. Second, if you want to expose the orientation to the LAN MJPEG viewer too, write the value to a shared AtomicInteger and read it in the Ktor handler's Content-Disposition or a custom X-Orientation header. Browsers won't act on the header but a custom viewer can.
Step 5: Auto-focus and auto-exposure across a multi-hour recording
TEMPLATE_RECORD gives you sensible starting modes (CONTROL_AF_MODE_CONTINUOUS_VIDEO and CONTROL_AE_MODE_ON) but it doesn't solve the hardest case: a baby monitor or security camera that records from late afternoon through deep night. Lighting changes by 6–8 stops over a few hours. Auto-exposure will silently clamp at min ISO and you'll see a long stretch of underexposed footage before the scene actually warrants it.
Three knobs to turn:
1. Cap the exposure-time ceiling. The system's default AE algorithm will happily push exposure to multi-hundred-millisecond shutter times in low light, which gives you motion-blurred mush on a baby monitor where you want to be able to see a baby moving. We cap at 1/30s (≈33ms) so motion is recognizable even at the cost of more grain.
val maxExposureTimeNs = 33_000_000L // 1/30s
captureRequestBuilder.set(
CaptureRequest.SENSOR_EXPOSURE_TIME,
maxExposureTimeNs
)
captureRequestBuilder.set(
CaptureRequest.CONTROL_AE_MODE,
CaptureRequest.CONTROL_AE_MODE_OFF // disable AE so the cap holds
)
captureRequestBuilder.set(
CaptureRequest.SENSOR_SENSITIVITY,
3200 // let ISO ride to compensate
)
The trade-off is grain in dark rooms vs. blur in dark rooms. Grain you can see through. Blur you can't.
2. Disable AF hunting during recording. Continuous-video AF is great for an actively-filmed scene, terrible for a static-mount security camera that just wants to keep the same focal plane sharp. Switch to CONTROL_AF_MODE_OFF and lock the lens at a hand-picked diopter once the recording stabilizes. We auto-detect "the scene is static" by checking SENSOR_FRAME_DURATION variance over the first 60 seconds and lock AF if variance is low.
captureRequestBuilder.set(
CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.CONTROL_AF_MODE_OFF
)
captureRequestBuilder.set(
CaptureRequest.LENS_FOCUS_DISTANCE,
stableFocusDiopter // measured during the 60s warmup
)
For an actively-filmed scene (a baby in a crib, a pet moving around) we keep CONTROL_AF_MODE_CONTINUOUS_VIDEO. The point isn't "always do X" — it's "detect the scene type once, then commit."
3. Fix the white balance. CONTROL_AWB_MODE_AUTO will drift visibly when a streetlight clicks on at dusk. Lock white balance to CONTROL_AWB_MODE_INCANDESCENT (or whatever fits) once the scene has stabilized. Same logic as AF: detect once, commit.
If you skip these three, your hours-long recording will look fine in the first ten minutes and progressively wrong over the next three hours. With them, the recording looks consistent end to end — which is exactly the property a baby monitor or security camera needs.
Step 6: Surviving Doze and the OEM kill
On AOSP, the foreground-service-camera type plus the wakelock plus the persistent notification is enough. On OEM Android, it isn't.
The defense is twofold:
First, request battery-optimization exemption. You don't get to set this programmatically — the user has to confirm — but you can route them to the right screen:
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
Google's policy is to allow this prompt only for apps that demonstrably need it (alarm clocks, navigation, accessibility, etc.). A long-running camera that records with the screen off qualifies, and Play review has consistently accepted ours on that justification.
Second, detect the OEM and offer a one-tap deep-link to its specific autostart screen. The shape of the code is:
val manufacturer = Build.MANUFACTURER.lowercase()
val intent = when (manufacturer) {
"xiaomi" -> Intent().setComponent(ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"))
"huawei" -> Intent().setComponent(ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"))
"samsung" -> Intent().setComponent(ComponentName(
"com.samsung.android.lool",
"com.samsung.android.sm.ui.battery.BatteryActivity"))
"oppo", "realme" -> Intent().setComponent(ComponentName(
"com.coloros.safecenter",
"com.coloros.privacypermissionsentry.PermissionTopActivity"))
"vivo" -> Intent().setComponent(ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity"))
"oneplus" -> Intent().setComponent(ComponentName(
"com.oneplus.security",
"com.oneplus.security.chainlaunch.view.ChainLaunchAppListActivity"))
else -> null
}
These component names are stable in practice but not contractual — OEMs occasionally rename activities between major versions. You wrap each startActivity() in a try/catch and fall back to the generic battery-optimization screen if the component is missing.
The user-experience principle: on first launch, after the user grants camera + audio, you run an OEM detection and show a one-screen wizard explaining that this OEM kills background recording aggressively, and offering a button that drops them on the right Settings page. Skipping this step is the single biggest cause of "the app stopped recording overnight" reviews in our crash and feedback corpus.
Step 7: Throwing the embedded HTTP server in
The reason any of this is worth doing is that once you have a long-running camera session, you can do interesting things with the frames. The two most valuable in our case are: write them to a local file (which the user retrieves over USB or by sharing from the app), and serve them on the LAN to a browser running on any other device.
We use Ktor as the embedded HTTP server, hosted inside the same foreground service. Ktor's embeddedServer(Netty, port = 8080) runs in its own coroutine scope; we hook the ImageReader.OnImageAvailableListener to a BroadcastChannel<ByteArray> that the Ktor handler subscribes to.
imageReader.setOnImageAvailableListener({ reader ->
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
val jpegBytes = yuvToJpeg(image, quality = 80)
image.close()
frameChannel.trySend(jpegBytes)
}, handler)
The Ktor handler then serves it as multipart/x-mixed-replace (MJPEG), which is the cheapest streaming format that works in every browser without HLS infrastructure or WebRTC negotiation:
get("/stream.mjpeg") {
call.respondOutputStream(ContentType("multipart", "x-mixed-replace; boundary=frame")) {
val sub = frameChannel.openSubscription()
for (frame in sub) {
write("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.size}\r\n\r\n".toByteArray())
write(frame)
write("\r\n".toByteArray())
flush()
}
}
}
Two important details: bind to 0.0.0.0 not 127.0.0.1 so the LAN can reach you, and gate the endpoint with a simple token (PIN shown on the device, required as ?t=NNNN) so a roommate on the same Wi-Fi can't tap into your nursery feed. Local-only does not mean unauthenticated.
What we measured
After all seven steps, on a Pixel 6 (Android 14) we get continuous 1080p recording for 8h+ on a single charge with the screen off. On a Xiaomi Redmi Note 12 with autostart enabled, we get roughly the same. With autostart not enabled, the session dies on average at 47 minutes — which matches what the in-app review pattern was telling us before the OEM-wizard work landed. The OEM wizard moved the "session died overnight" support-email rate to roughly 1/100th of what it was.
The exposure-cap + AF-lock + AWB-lock combo (Step 5) was a separate before/after: in our internal dogfood we previously had reviews complaining about "blurry baby" or "the whole video went greenish after sunset." After committing those three, the same reviewers reported the recording was "watchable end to end."
Trade-offs we made on purpose
A few choices that look wrong if you skim the implementation:
No video preview in the recording Activity. A preview surface adds two more buffers in the camera pipeline, costs ~8% extra battery, and is unnecessary for our use case (the user already pointed the phone before locking the screen). When the user wants to verify framing, they open the LAN viewer in a browser on another device, which uses the same MJPEG endpoint that runs whether the recording Activity is open or not.
MJPEG over WebRTC for the LAN stream. WebRTC is technically superior — lower latency, adaptive bitrate, congestion-aware. But it requires a signaling server, which violates the local-only architectural promise. MJPEG works zero-config across the LAN. The latency penalty (~400ms vs. ~80ms) is invisible for a security/baby-monitor use case where the user is watching for events, not piloting a drone.
No background uploads anywhere. No "smart clip detection in the cloud," no "AI motion summaries," none of the features that would make our app score better on a feature-matrix comparison. We made this trade explicit and made it the architectural point. The reason is in our Meari breach architectural breakdown: the moment you add an authenticated cloud broker, you become subject to the failure mode that broke 378 brands of cameras in a single key-extraction in May 2026. The point of our architecture is to be structurally incapable of being that breach.
Cross-links for further reading
- The original Camera2 + Ktor architecture writeup is the system-design overview this article is the implementation deep-dive of.
- The how-to-record-video-in-the-background guide is the higher-level primer if this article was too deep too fast.
- The keep-the-camera-running-with-the-screen-off guide is the user-facing version (no Kotlin, just what to do).
- The Texas v. Netflix architectural response explains why a local-only baby-monitor / security-camera architecture is also the only architecture that's structurally incapable of producing the data trail the complaint accuses Netflix of building.
- The Texas v. Meta AI Glasses investigation response takes the same architectural lens to "always-on" capture devices.
- The build-in-public Part 2 retro covers what the first month of public Play Store metrics taught us about positioning a privacy-first app against feature-richer cloud competitors.
- The Build-in-Public Week 3 update covers the Quora-first → dev.to canonical channel-pivot experiment this article is the first end-to-end completion of.
The actual app
If you want to use this rather than rebuild it, the production version of everything above ships in Background Camera RemoteStream on Google Play: https://play.google.com/store/apps/details?id=com.superfunicular.digicam&utm_source=devto&utm_medium=article&utm_campaign=2026w22 — free, no account, no cloud, recording stays local, LAN streaming via the embedded web server, optional YouTube Live streaming (Pro feature) on your own channel. Source-of-truth feedback channel is the Play Store reviews; we read every one.
— Built by Super Funicular LLC. Website: https://superfunicular.com





















