Edward Harker

How screenshot detection works on Android

Detecting a screenshot on Android sounds like a single-callback problem. It isn’t.

Android 14 introduced an official screenshot detection API, but supporting older releases still means watching MediaStore and deciding whether a newly added image looks like a screenshot. The two approaches provide different information, require different permissions and fail in different ways.

I recently had to implement both. Here’s what I learned.

There are really two implementations

The first decision happens at runtime:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    // Bind ScreenCaptureCallback to the resumed Activity.
    val binder = ModernDetectorActivityBinder(onScreenshot)
    application.registerActivityLifecycleCallbacks(binder)
} else {
    // Observe new images in MediaStore.
    val detector = LegacyScreenshotDetector(context) { uri -> onScreenshot(uri) }
    detector.start()
}

This isn’t just the usual compatibility branch around a renamed API. The behaviour on either side is fundamentally different:

Android version How detection works Permission needed for detection Screenshot URI
Android 14+ (API 34+) Activity.ScreenCaptureCallback DETECT_SCREEN_CAPTURE (install-time) Not provided
Android 5–13 (API 21–33) ContentObserver on MediaStore.Images Media-read permission (runtime) Provided by the media change

The manifest needs to cover all of those versions:

<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />

READ_EXTERNAL_STORAGE is capped at API 32. Android 13 replaces it with READ_MEDIA_IMAGES. On Android 14+, neither is needed just to receive the capture event—but media permission may still be needed if the app wants the image itself.

Android 14 gives you a signal, not an image

The detector for Android 14 is tiny:

@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
internal class ModernScreenshotDetector(
    private val activity: Activity,
    private val onScreenshotDetected: (Uri?) -> Unit,
) {
    val screenCaptureCallback = Activity.ScreenCaptureCallback {
        onScreenshotDetected(null)
    }

    fun start() {
        activity.registerScreenCaptureCallback(
            activity.mainExecutor,
            screenCaptureCallback,
        )
    }

    fun stop() {
        activity.unregisterScreenCaptureCallback(screenCaptureCallback)
    }
}

There are two catches hidden in that small amount of code.

The first is that ScreenCaptureCallback only reports that a capture happened. It doesn’t return a bitmap or a content URI. Detection and access to the screenshot are separate operations, with separate permission requirements.

The second is that registration belongs to an Activity, not the Application. Keeping one detector around forever would either miss screenshots after the user moved to another activity or retain an activity that should have been released.

I used Application.ActivityLifecycleCallbacks to make the detector follow whichever activity is in the foreground:

override fun onActivityResumed(activity: Activity) {
    bind(activity)
}

override fun onActivityPaused(activity: Activity) {
    if (activity === boundActivity) stopCurrent()
}

fun bind(activity: Activity) {
    if (activity === boundActivity && current != null) return
    stopCurrent()
    current = ModernScreenshotDetector(activity, onScreenshotDetected).also {
        it.start()
    }
    boundActivity = activity
}

The identity checks make lifecycle churn harmless. Binding the same resumed activity twice does nothing; moving to a different activity unregisters the old callback before registering the new one. I also made start() and stop() idempotent and tested repeated starts, stopping before a start, and registering again after a stop.

There is a slightly awkward startup case too. An SDK can be initialized after the first activity has already resumed, in which case waiting for the next lifecycle callback is too late. I track the current foreground activity during AndroidX Startup so the detector can bind immediately. If a host app removes the Startup provider, it still binds normally on the next resume.

Finding the image is a separate job

When the Android 14 callback fires, I open the reporter and then separately try to find the newly created screenshot in MediaStore.

That lookup requires READ_MEDIA_IMAGES. Without it, BugScreen can ask for permission or fall back to Android’s photo picker; the missing permission shouldn’t prevent the report itself from opening.

With permission, the lookup filters the screenshots directory and asks for its newest row:

val selection =
    "${MediaStore.Images.Media.RELATIVE_PATH} LIKE ?"
val selectionArgs = arrayOf("%Screenshots%")

val args = Bundle().apply {
    putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection)
    putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs)
    putString(
        ContentResolver.QUERY_ARG_SQL_SORT_ORDER,
        "${MediaStore.Images.Media.DATE_ADDED} DESC",
    )
    putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
}

On API 29 and later I filter on RELATIVE_PATH; older versions need the deprecated DATA column. On API 26 and later I use the bundle query API because Android 11 rejects the old trick of appending LIMIT 1 to the SQL sort order.

I search from five seconds before the callback time. The media row can be written just before the capture callback arrives, so using the event time as a hard lower bound occasionally misses the correct image. After querying, I still check DATE_ADDED and reject the result if it is too old.

Failures—including a missing permission—become a missing attachment rather than an app crash. This distinction ended up being the most useful design rule in the whole implementation: the platform callback is a signal; retrieving the image is an optional, recoverable operation with its own consent flow.

Before Android 14, watch MediaStore and make an educated guess

Older versions have no screenshot callback. I register a descendant-aware ContentObserver on the external images collection instead:

contentResolver.registerContentObserver(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    true,
    observer,
)

That observer sees every new image, not just screenshots. Each change has to be classified by querying its DISPLAY_NAME, DATA and DATE_ADDED.

First, the image must be recent:

val currentTimeSeconds = currentTimeMillis() / 1000
if (currentTimeSeconds - dateAdded > 5) return false

Then its display name or path has to contain something screenshot-shaped:

val screenshotKeywords = listOf(
    "screenshot",
    "screen_shot",
    "screencap",
    "screen_capture",
    "screen-",
    "/screenshots/",
    "/screencapture/",
)

The comparison is case-insensitive. A normal camera image doesn’t match; an oddly named image inside Pictures/Screenshots does.

This will always be a heuristic. Manufacturers can choose different filenames and storage locations, and DATA is legacy storage metadata. Supporting Android releases without a dedicated callback means accepting that there is no perfect signal.

MediaStore is noisy

One screenshot write can generate more than one observer notification. Without a debounce, a single screenshot can open the reporter twice.

val currentTime = currentTimeMillis()
if (currentTime - lastScreenshotTime < 1_000L) return@launch

if (isScreenshot(uri)) {
    lastScreenshotTime = currentTime
    delay(100) // Let the file finish writing.
    handler.post { onScreenshotDetected(uri) }
}

Only a successfully classified screenshot updates lastScreenshotTime. An unrelated photo therefore can’t suppress a real screenshot that arrives immediately after it.

The classification runs on an IO coroutine. After a short delay to let the file finish writing, the callback returns to the main thread. When detection stops, I unregister the observer and cancel its child jobs so that queued work can’t deliver a stale callback. I leave the injected scope itself alive, which allows the same detector to stop and start again.

The permissions answer depends on the question

I initially found it useful to split the permission helper in two:

fun getRequiredPermission(): String? =
    if (SDK_INT >= 34) null else getMediaReadPermission()

fun getMediaReadPermission(): String =
    if (SDK_INT >= 33) READ_MEDIA_IMAGES else READ_EXTERNAL_STORAGE

getRequiredPermission() answers “what is required to detect a screenshot?” On Android 14 the answer is no runtime permission, because DETECT_SCREEN_CAPTURE is granted at install time.

getMediaReadPermission() answers “what is required to retrieve the saved image?” On Android 14 that is still READ_MEDIA_IMAGES.

Conflating those questions leads either to unnecessary permission prompts or to code that assumes the modern callback contains an image. It doesn’t.

What survived contact with production code

The complete implementation is mostly a collection of edges around two fairly small APIs:

  • Branch at API 34 because both the detection mechanism and permission model change.
  • Follow the resumed activity because ScreenCaptureCallback isn’t application-scoped.
  • Treat detection and access to the saved image as separate capabilities.
  • On older Android versions, classify recent MediaStore rows instead of trusting raw observer events.
  • Expect duplicate notifications and files that are not quite finished writing.
  • Make registration, cleanup, permission denial and query failure safe to repeat or recover from.

Android 14 removed the guesswork from knowing a capture occurred. It didn’t remove the lifecycle work, the optional image lookup or the need to support older devices.

I implemented this as the screenshot trigger in BugScreen, a bug-reporting SDK I’m building. It opens a report with the screenshot and relevant device context already attached—which is a much nicer payoff than all the lifecycle plumbing suggests.