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.
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.
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().
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()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()returnsfalsefor 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.
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.
export ANDROID_HOME=~/Library/Android/sdk # or your SDK path
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkThen 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.
| 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 |
MIT — see LICENSE.
Extracted from SnipShot, an Android screenshot utility. Process notes on how that app was built: shipping-with-agents.