Skip to content

Repository files navigation

Android scroll capture without MediaProjection

Area and scrolling ("long") screenshots on Android using AccessibilityService.takeScreenshot() and injected scroll gestures, instead of MediaProjection.

This is a working reference sample extracted from a shipped app. It is small on purpose: one service, one stitcher, one demo activity. The parts worth copying are the ones that are not obvious from the API docs.


Why not MediaProjection

MediaProjection is the documented way to capture the screen, and for most cases it is the right one. It loses here for three specific reasons:

MediaProjection AccessibilityService
Consent System dialog on every session (Android 14+) One-time toggle in settings
Notification Persistent foreground-service notification required None
Can scroll the screen No Yes, via dispatchGesture()

For a screenshot utility, a consent dialog before every single capture is not a papercut — it is the product. And scrolling captures are impossible with MediaProjection at all, because nothing in that API can move the content.

What it costs. takeScreenshot() requires minSdk 30 (Android 11). If you ship on Google Play you will need an accessibility declaration and a disclosure video at review, and Android 13+ adds "Restricted settings" friction for sideloaded installs. Decide that trade before you build on this.


The two things that are not in the docs

1. A plain swipe makes lists fling — and a flinging list cannot be stitched

dispatchGesture() with a single swipe stroke behaves like a real flick: the content keeps moving after the gesture ends. The next screenshot then catches mid-animation content, at an offset you cannot predict.

The fix is to make the finger stop before lifting. Dispatch the move stroke with willContinue = true, then continue it with a zero-length "hold" stroke:

val move = GestureDescription.StrokeDescription(movePath, 0, 600L, /* willContinue = */ true)
dispatchGesture(GestureDescription.Builder().addStroke(move).build(), object : GestureResultCallback() {
    override fun onCompleted(g: GestureDescription?) {
        val holdPath = Path().apply { moveTo(x, yEnd); lineTo(x, yEnd) }
        val hold = move.continueStroke(holdPath, 0, 150L, /* willContinue = */ false)
        dispatchGesture(GestureDescription.Builder().addStroke(hold).build(), …, handler)
    }
}, handler)

Then wait ~550 ms before the next capture so the scroll settles. See ScrollCaptureService.scrollDown().

2. The first capture after the service binds can fail spuriously

takeScreenshot() can come back with a transient internal error on the first call after the service connects — reproducible on emulators right after a rebind. One attempt is not trustworthy; retry two or three times with a short delay before reporting failure. See takeShotWithRetry().

Also: the result arrives as a hardware buffer. Copy it into a software bitmap and close the buffer, or you leak graphics memory:

val hw = Bitmap.wrapHardwareBuffer(screenshot.hardwareBuffer, screenshot.colorSpace)
val bmp = hw?.copy(Bitmap.Config.ARGB_8888, false)
hw?.recycle()
screenshot.hardwareBuffer.close()

Stitching: match the frames, don't trust the scroll distance

The tempting shortcut — "I scrolled by N pixels, append the bottom N pixels" — does not survive contact with real views. How far content actually moves depends on the view, its fling behaviour, and how much content is left.

ScrollStitcher instead takes a horizontal band from the bottom of the previous frame and searches for it in the new one, comparing per-row luma signatures (160 samples per row, every 3rd row). What it took to make that reliable:

  • Pick a textured band. A band cut blindly from the bottom can land on a solid background and match everywhere. chooseTemplateStart() walks upward until it finds a band with enough vertical luma activity.
  • Reject ambiguous matches. Chat bubbles and list rows match in many places. The winner must beat every non-adjacent alternative by a margin, or the frame is dropped.
  • Detect "nothing moved". If the band still matches its own old position, the screen is static — you are at the bottom, or this view does not scroll. That is the signal to stop, and offer() returns false for it.
  • Crop the system bars out of every frame after the first, or you stitch the status bar into the middle of the image.

The thresholds in ScrollStitcher.Companion were tuned against real screens; treat them as a starting point, not as constants handed down from on high.


Keep the service blind

The service declares only what it uses:

<accessibility-service
    android:canTakeScreenshot="true"
    android:canPerformGestures="true"
    … />

No accessibilityEventTypes, no canRetrieveWindowContent. It therefore receives no events and cannot read anything on screen. This is worth doing even where it is not strictly required: an accessibility service is a scary permission to grant, and a config that visibly cannot read the screen is the one thing that makes the request defensible — to a reviewer and to a user.


Running it

export ANDROID_HOME=~/Library/Android/sdk       # or your SDK path
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

Then enable Scroll Capture in Settings → Accessibility, come back to the app and press Capture scrolling. The demo activity holds a long scrollable text block so there is something to scroll; the stitched result appears at the bottom of the screen.

Requires Android 11 (API 30) or newer.


Files

File What's in it
ScrollCaptureService.kt The accessibility service: takeScreenshot(), retry, the two-stroke scroll gesture, and the capture loop
ScrollStitcher.kt Frame matching and stitching
MainActivity.kt Demo harness
res/xml/accessibility_service_config.xml The service declaration, and why it is minimal

License

MIT — see LICENSE.

Extracted from SnipShot, an Android screenshot utility. Process notes on how that app was built: shipping-with-agents.

About

Area and scrolling screenshots on Android without MediaProjection — AccessibilityService.takeScreenshot() + injected scroll gestures, with the frame stitching that makes it work

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages