Android tells you an app used your camera. It won't tell you which one.

Four detection paths, one attribution problem, and the OEMs that quietly break all of it. By .

Since Android 12, a small green dot appears in your status bar when an app uses your camera or microphone. Pull down the shade and you'll see which one.

It's a good feature. It's also nearly useless for the thing people actually want to know, which is: what has been using my camera and microphone while I wasn't looking?

The dot is instantaneous. It has no memory. If an app opens your mic for four seconds at 3am, the indicator dutifully appears and disappears while you're asleep, and nothing anywhere records that it happened. The Privacy Dashboard keeps a 24-hour history, but it's coarse, it's buried, and on many OEM skins it's been reorganised into somewhere you'll never find.

I wanted a timestamped log. Which app, when, for how long, and — the part that turned out to be hard — whether the screen was locked at the time.

This is what I learned building it.

The API that's supposed to do this

AppOpsManager is Android's internal bookkeeping for privileged operations. Every time an app uses the camera, the mic, location, or a dozen other things, an "op" is marked active against that app's UID.

Since API 29 there's a listener for exactly this:

appOps.startWatchingActive(
    arrayOf(AppOpsManager.OPSTR_CAMERA, AppOpsManager.OPSTR_RECORD_AUDIO),
    mainExecutor,
    listener
)

You get a callback with the package name and whether the op just became active or inactive. Perfect attribution, no guessing, straight from the system.

On a Pixel, this works exactly as documented. On Samsung and Sony, it works.

On ColorOS, OriginOS, and MIUI, it registers successfully, returns no error, and then simply never fires.

That's the first real lesson of Android sensor monitoring: the API surface is uniform and the behaviour is not. Nothing throws. Nothing warns you. Your listener is just never called, and if you built your whole feature on it, your app silently does nothing on a large share of the devices in the world.

So you need fallbacks. I ended up running four detection paths in parallel, all the time.

Path 2: ask instead of listen

If the system won't push events at you, poll for them. AppOpsManager.isOpActive() answers the same question on demand. Enumerate installed packages, check each one every couple of seconds, and diff against the previous state:

val active = appOps.isOpActive(opStr, uid, pkgName)
val key = "$pkgName::$opStr"
val wasActive = activeStates[key] ?: false
if (active != wasActive) {
    activeStates[key] = active
    broadcastEvent(pkgName, sensor, active, System.currentTimeMillis())
}

Two details matter here.

First, the baseline problem. On the very first poll, every currently-active app looks like a fresh transition. If you don't seed the state map before you start reporting, the user opens your app and immediately gets a wall of alerts for things that were already running. So the first pass records state and reports nothing.

Second, polling has a resolution floor. At a two-second interval, a sensor access shorter than the gap can slip between two polls entirely. You cannot fix this by polling faster — you'd shred the battery, and getInstalledPackages is not cheap. You fix it by having other paths that are event-driven.

And on some OEMs — ColorOS again — isOpActive is denied to third-party apps too. Both AppOps paths dead, on the same devices.

Paths 3 and 4: the OS-level signals nobody can block

There are two callbacks Android exposes that don't go through AppOps at all, and in my testing they fire on every device I've tried.

Camera. CameraManager.AvailabilityCallback tells you when a camera becomes unavailable — which is exactly what happens when some other process opens it.

Microphone. AudioManager.AudioRecordingCallback fires whenever the set of active recording configurations changes. Count the configs, compare to last time, and you know a recording started or stopped.

These are reliable, immediate, and OS-level. They are also completely anonymous. Android tells you the camera is now in use. It does not tell you by whom.

Which is the actual hard problem.

The multi-camera trap

Before attribution, a smaller trap worth knowing about.

Modern phones have four or five logical cameras — front, main, ultrawide, telephoto. When the user opens the camera app, every logical camera becomes unavailable to other processes. So onCameraUnavailable fires four or five times for one human action.

Naively, that's five "camera accessed" events in your log for one selfie. The fix is to treat the whole thing as a session with a reference count:

override fun onCameraUnavailable(cameraId: String) {
    val wasEmpty = unavailableCameras.isEmpty()
    unavailableCameras.add(cameraId)
    if (wasEmpty) {
        // first camera claimed — this is a real session start
    }
}

The session ends when the set empties again, not when the first camera is released. Otherwise switching from the front camera to the back mid-session reads as one session ending and another beginning.

The attribution problem

So the camera just turned on and Android won't say who did it. Now what?

The only public API that gets you close is UsageStatsManager. Query recent events, find the most recent ACTIVITY_RESUMED, and you have the foreground app. If the user just opened Instagram and the camera lit up, Instagram is a safe bet.

But "the foreground app did it" is a guess, and it's wrong in exactly the cases users care about most — when something in the background is using the mic. Blaming whatever happens to be on screen is worse than useless there. It's a false accusation in a product whose entire value is trustworthy accusations.

So the heuristic needs to know when it doesn't know. Three signals push an event into "background, unattributed":

Foreground staleness. If the foreground app hasn't changed in 30 seconds, the user isn't actively driving it. A camera turning on at that moment is suspicious, not incidental.

It's us. If the foreground app is my own app, the event definitionally didn't come from the foreground — the user is just looking at the log.

The permission sanity guard. This one came out of a real misfire. A media app kept getting blamed for microphone events because its media session made it look foreground-ish at the moment of an access — but it holds no RECORD_AUDIO permission, so it cannot be the app using the mic:

private fun holdsSensorPermission(pkgName: String, sensor: String): Boolean {
    val permission = when (sensor) {
        "camera" -> Manifest.permission.CAMERA
        "mic" -> Manifest.permission.RECORD_AUDIO
        else -> return true
    }
    return packageManager.checkPermission(permission, pkgName) ==
        PackageManager.PERMISSION_GRANTED
}

An app without the permission can never be blamed. It's an obvious rule in hindsight, and it eliminated most of the wrong answers.

When all of that lands on "background", I don't invent a culprit. I collect the apps that could have done it — installed, holding the permission, active within the last two hours, sorted most-recent-first — and present them as candidates.

Showing a user five suspects is honest. Showing them one wrong name is not.

Staying alive

A monitoring app that dies when the user swipes it away is monitoring nothing. Two things were necessary.

A buffer for when Dart is dead. The detection lives in a Kotlin foreground service; the UI is Flutter. Those have independent lifetimes — the service keeps running with no Flutter engine attached. So the service writes events to SharedPreferences itself, capped at 500, and Dart drains the buffer on next launch.

A heartbeat, to detect its own death. The service stamps the wall clock every 60 seconds. On launch, Dart compares that timestamp to now. A large gap means the service was killed — almost always by an OEM battery optimiser — and the user gets told there's a hole in their log.

That second one matters more than it sounds. Aggressive OEM process-killing is the single biggest threat to this category of app, and the honest response isn't to pretend it didn't happen. It's to say: between 2am and 7am I wasn't running, and I can't tell you what happened.

What I couldn't solve

The takeaway

The interesting engineering here wasn't any single API. It was accepting that no single detection path is trustworthy across the Android ecosystem, and designing for degradation: four paths in parallel, whichever fires first wins, later confirmations just refine the record.

And then — the part that took longest to get right — building a system that knows the difference between I know who did this and I know something happened and here's who could have done it.

For a tool whose only product is trust, that distinction is the whole thing.

Spytrap keeps a permanent, on-device log of every camera and microphone access. Flutter UI, native Kotlin detection service, Drift/SQLite for storage. No account, no cloud, nothing leaves the phone.

Get Spytrap on Google Play — $0.99 →