← Blog

How screenshot detection works on Android

By Edward Harker

Detecting a screenshot on Android sounds like a single callback problem. It is not. The platform has an official screenshot notification API from Android 14 (API 34), while older releases require watching the media database and deciding whether a new image looks like a screenshot.

BugScreen supports both paths. This post walks through the actual Kotlin implementation in the Android SDK, including the parts that are easy to miss: activity scoping, runtime permissions, duplicate MediaStore notifications, and the fact that detecting a screenshot is not the same as obtaining its pixels.

The version split

The SDK chooses its detector 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 is more than an API compatibility branch. The two implementations receive different data and have different permission models:

Android versionDetection mechanismDetection permissionScreenshot URI
Android 14+ (API 34+)Activity.ScreenCaptureCallbackDETECT_SCREEN_CAPTURE, install-timeNot provided
Android 5–13 (API 21–33)ContentObserver on MediaStore.ImagesMedia-read permission, runtimeProvided by the media change

The SDK manifest declares all three permissions needed across 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. From API 33, the equivalent media permission is READ_MEDIA_IMAGES. On API 34+, neither is required merely to receive the screen-capture event, but one is still needed if the app wants to find and attach the saved image.

Android 14+: an official signal, scoped to an Activity

The modern detector is deliberately small:

@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)
    }
}

Two details drive the surrounding architecture.

First, the callback reports an event, not an image. BugScreen passes null as the URI, and its unit test asserts exactly that. Code using this API should not imply that ScreenCaptureCallback grants access to the screenshot.

Second, registration belongs to an Activity, not the Application. Holding one detector forever would either miss screenshots after navigation to another activity or retain an activity that should be released. BugScreen therefore implements Application.ActivityLifecycleCallbacks and follows the foreground activity:

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 matter during lifecycle churn. Binding the same resumed activity twice is a no-op; moving to a different activity unregisters the old callback before registering the new one. The detector itself also makes start() and stop() idempotent. Robolectric tests verify one registration after two starts, no unregistration before a start, and successful re-registration after a stop.

There is one startup edge case: the SDK may be initialized after the first activity has already resumed. Its AndroidX Startup initializer tracks the current foreground activity, allowing the SDK to bind immediately rather than waiting for a background-to-foreground cycle. If a host removes AndroidX Startup’s provider, the normal lifecycle callback still binds on the next resume.

Detection is not image access

After the API 34 callback, BugScreen opens its reporter with autoAttach = true. Because the callback contained no URI, the reporter separately tries to locate the new file in MediaStore.

That second operation requires READ_MEDIA_IMAGES. If permission is absent, the UI shows a rationale before launching the runtime permission request. A denial falls back to Android’s photo picker rather than preventing the report.

When permission is available, ScreenshotLocator queries the screenshots directory and asks for the 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 it filters RELATIVE_PATH; older releases use the deprecated DATA column. On API 26 and later it uses the bundle query API because appending LIMIT 1 to SQL sort order is rejected on Android 11 and later. Older devices read the first row from the sorted cursor.

The lookup subtracts a five-second buffer from the capture time. This is intentional: the media row can be written just before ScreenCaptureCallback runs. It then checks DATE_ADDED itself and returns null if the newest screenshot is too old. Query failures, including missing permission, are caught and become a missing attachment instead of an app crash.

This separation is a useful design rule beyond this SDK: treat the platform callback as a signal, then make image retrieval an explicit and recoverable feature with its own consent flow.

Android 5–13: observing and classifying MediaStore changes

Before API 34, BugScreen registers a descendant-aware ContentObserver on the external images collection:

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

Every image insertion can produce a notification, so an observer notification alone is not proof of a screenshot. The detector queries the changed URI for DISPLAY_NAME, DATA, and DATE_ADDED, then applies two checks.

The row must first be recent:

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

Then either its display name or path must contain one of the implementation’s known indicators:

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

The comparison is case-insensitive. Tests cover a conventional name such as Screenshot_20260513.png, an ordinary camera image that must not match, a generic filename inside Pictures/Screenshots, every keyword variant, and a screenshot-shaped row that is ten seconds old and must be rejected.

This is a heuristic, not a platform guarantee. OEM naming and storage conventions can differ, and the DATA column is legacy storage metadata. That is the unavoidable tradeoff of supporting versions without a dedicated screenshot callback.

Why debounce and delay the legacy callback?

MediaStore can notify an observer more than once for the same write. BugScreen suppresses another detection within one second of the last accepted screenshot:

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 advances the debounce timestamp, so an unrelated image does not suppress the next valid event. The work runs on an IO coroutine; after a 100 ms write settling delay, delivery returns to the main thread. Tests drive two changes 500 ms apart and expect one callback, then repeat at a two-second interval and expect two.

Stopping the detector unregisters the observer and calls cancelChildren() on its coroutine scope. It does not cancel the injected scope itself, which allows the same detector to stop and start again. A test queues classification work, stops before the dispatcher runs it, and confirms that no stale callback escapes afterward.

Permission behavior by API level

The permission helper encodes two related but distinct questions:

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() means “what is required for detection?” It returns null on Android 14+ because DETECT_SCREEN_CAPTURE does not use a runtime prompt. getMediaReadPermission() means “what is required to retrieve the saved file?” and still returns READ_MEDIA_IMAGES on Android 14+.

BugScreen can request the legacy detection permission on startup when requestPermissionsOnStart is enabled. It waits for the first resumed activity and uses a small, transparent permission activity rather than attempting a runtime request from an application context. On modern Android, it defers the optional image-read prompt until there is an actual screenshot to attach.

What we learned from shipping both paths

The implementation reduces to a few principles useful in any Android screenshot-triggered feature:

  • Branch on API 34 because the official callback changes both the signal and permission model.
  • Follow the resumed activity; ScreenCaptureCallback is not application-scoped.
  • Do not conflate detection with access to the saved screenshot.
  • Treat legacy MediaStore detection as a recent-row classification problem, not as a raw observer event.
  • Expect duplicate notifications and files that are not fully written yet.
  • Make registration, unregistration, permission denial, and query failure safe to repeat or recover from.

The Android 14 API removes the guesswork from knowing that a capture occurred. It does not remove the engineering around lifecycle ownership, optional image access, or older Android releases. Those edges are where a screenshot detector becomes production code rather than a callback demo.

Frequently asked questions

Is there one screenshot-detection API for every Android version?

No. Android 14 introduced Activity.ScreenCaptureCallback. On older Android releases, an app can observe MediaStore changes and classify newly added images, provided it has the required media-read permission.

Does Android 14’s ScreenCaptureCallback return the screenshot image?

No. It reports that a screenshot occurred but does not provide a bitmap or content URI. If an app needs the image, that is a separate, permission-gated MediaStore operation.

Does DETECT_SCREEN_CAPTURE show a runtime permission prompt?

No. On Android 14 and later, DETECT_SCREEN_CAPTURE is an install-time permission. Reading the saved image from MediaStore can still require READ_MEDIA_IMAGES at runtime.

Why is screenshot detection tied to an Activity on Android 14?

registerScreenCaptureCallback is an Activity API. An SDK that works across a multi-activity app must register against the resumed activity and unregister when that activity pauses.