How to Capture Screenshots from an Android MediaProjection Background Service
Build a user-visible Android foreground service for MediaProjection capture, handle Android 14 rules, resize safely, and manage screenshot frames.
Direct answer: Android background screen capture must run as a user-visible foreground service that owns an active MediaProjection session. Ask for consent in an activity, start the correctly typed foreground service, retrieve the projection there, register a callback, create one VirtualDisplay backed by your own Surface, and release everything when Android stops the session. createVirtualDisplay() routes pixels to a surface; it does not return a bitmap or image file.
This is authorized, user-visible capture. It is not silent or unattended recording. The user must approve each session, and Android can revoke it when the user stops sharing, the screen locks, another projection starts, or your process dies.
1. How the capture architecture works
Keep these responsibilities separate:
| Part | Responsibility |
|---|---|
| Activity | Requests consent with createScreenCaptureIntent() and receives the result. |
| Foreground service | Runs with the mediaProjection service type and owns the active session. |
| MediaProjection | Authorizes and routes display or app-window content. |
| VirtualDisplay | Publishes frames to a Surface. |
| Frame pipeline | Consumes the surface, converts frames to an image format, and writes or uploads them. |
Android’s official guide covers the consent, projection, virtual-display, callback, and resize lifecycle: Media projection, MediaProjection API reference, and Android 14 behavior changes.
2. Manifest declarations
Declare the base foreground-service permission. Apps targeting Android 14 (API 34) or newer also need the type-specific permission and a service declaration with foregroundServiceType="mediaProjection".
<manifest ...>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<application ...>
<service
android:name=".CaptureService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
</application>
</manifest>
The second permission is required for current Android 14 targeting rules. Check the behavior for the target SDK you ship.
3. Request consent from an Activity
Request a fresh consent intent for every capture session. Do not continue if the user denies it, and do not reuse an old result to start another session.
class MainActivity : ComponentActivity() {
private val captureLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK || result.data == null) {
// The user denied capture. End this attempt.
return@registerForActivityResult
}
val serviceIntent = Intent(this, CaptureService::class.java).apply {
putExtra(CaptureService.EXTRA_RESULT_CODE, result.resultCode)
putExtra(CaptureService.EXTRA_RESULT_DATA, result.data)
}
ContextCompat.startForegroundService(this, serviceIntent)
}
fun beginCapture() {
val manager = getSystemService(MediaProjectionManager::class.java)
captureLauncher.launch(manager.createScreenCaptureIntent())
}
}
Call beginCapture() from an explicit user action such as a “Start sharing” button. The service must be promoted to the media-projection foreground type before calling getMediaProjection() on Android 14+ targets.
4. Start the foreground service and create one projection
The service below shows the lifecycle and the important ordering. The frame-consumption code is intentionally represented by an interface: the platform sends frames to your surface, but Android does not define one universal bitmap encoder or persistence design.
class CaptureService : Service() {
companion object {
const val EXTRA_RESULT_CODE = "result_code"
const val EXTRA_RESULT_DATA = "result_data"
private const val CHANNEL_ID = "screen_capture"
private const val NOTIFICATION_ID = 1001
}
private var projection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var outputSurface: Surface? = null
private var framePipeline: FramePipeline? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
?: return START_NOT_STICKY
val resultData = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
?: return START_NOT_STICKY
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_screen_share)
.setContentTitle("Screen sharing active")
.setContentText("Tap to stop capture")
.setOngoing(true)
.build()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
)
startProjection(resultCode, resultData)
return START_NOT_STICKY
}
private fun startProjection(resultCode: Int, data: Intent) {
val manager = getSystemService(MediaProjectionManager::class.java)
projection = manager.getMediaProjection(resultCode, data)
projection?.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
// Android or the user revoked the session.
releaseCapture()
stopSelf()
}
}, Handler(Looper.getMainLooper()))
val metrics = resources.displayMetrics
val width = metrics.widthPixels
val height = metrics.heightPixels
val density = metrics.densityDpi
// FramePipeline must create and own a valid Surface before this call.
framePipeline = FramePipeline(width, height).also { it.start() }
outputSurface = framePipeline!!.surface
virtualDisplay = projection!!.createVirtualDisplay(
"ScreenshotCapture",
width,
height,
density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
outputSurface,
object : VirtualDisplay.Callback() {},
Handler(Looper.getMainLooper())
)
}
private fun releaseCapture() {
virtualDisplay?.release()
virtualDisplay = null
outputSurface?.release()
outputSurface = null
framePipeline?.close()
framePipeline = null
projection?.unregisterCallback(/* retain the callback instance in production */)
projection?.stop()
projection = null
}
override fun onDestroy() {
releaseCapture()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID, "Screen capture", NotificationManager.IMPORTANCE_LOW
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
interface FramePipeline : Closeable {
val surface: Surface
fun start()
}
In production, retain the exact MediaProjection.Callback instance so you can pass it to unregisterCallback(). Also ensure your pipeline owns the surface and closes its image-reader, encoder, threads, and file handles.
5. Obtain frames from the Surface
The projection API ends at a surface. A common design is an ImageReader configured for the capture size, using its surface as the virtual-display output, then converting each acquired image to PNG, JPEG, or another format on a worker thread. The exact buffer conversion depends on your chosen API level, pixel format, alpha handling, and whether you need a still image or a video stream.
- Do not treat
createVirtualDisplay()as a screenshot function. - Acquire and close every image promptly; otherwise the producer stalls when buffers fill.
- Copy pixels before closing an image if encoding happens asynchronously.
- Bound your queue and drop stale frames when only the latest screenshot matters.
- Write files off the main thread and close streams on cancellation.
6. Handle Android 14 app-window selection and resizing
Android 14 lets the user share a single app window. Its dimensions are not necessarily known when you request consent. Register the projection callback and respond to content-size changes by resizing the existing display and replacing or resizing its surface.
private val projectionCallback = object : MediaProjection.Callback() {
override fun onCapturedContentResize(width: Int, height: Int) {
val display = virtualDisplay ?: return
val pipeline = framePipeline ?: return
pipeline.resize(width, height)
display.resize(width, height, resources.displayMetrics.densityDpi)
display.setSurface(pipeline.surface)
}
override fun onStop() {
releaseCapture()
stopSelf()
}
}
For rotation and other configuration changes, use VirtualDisplay.resize() and setSurface(). Do not create a second virtual display on the same session.
7. Full display versus selected app window
| Scope | Initial sizing | Privacy and behavior |
|---|---|---|
| Full display | Use maximum display bounds for initial dimensions. | Can include everything visible on the display; handle rotation and configuration changes. |
| Single app window | Wait for captured-content resize information after selection. | Narrower user-approved scope; dimensions can change as the selected content changes. |
8. Session rules you must enforce
- Ask for consent before every capture session.
- Start the media-projection foreground service before
getMediaProjection()on Android 14+ targets. - Register
MediaProjection.CallbackbeforecreateVirtualDisplay(). - For Android 14+ targets, use one projection instance for one
createVirtualDisplay()call. - After
onStop(), release the display, surface, pipeline, and projection, then require fresh consent. - Keep the foreground notification visible while capture is active.
9. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
SecurityException on a second display |
Reusing a projection or consent result on Android 14+. | End the old session and request consent again; create only one virtual display per projection. |
IllegalStateException from createVirtualDisplay() |
No callback was registered first, or the projection was already stopped. | Register the callback before display creation and abort if onStop() ran. |
| Foreground-service start or type error | Missing manifest permission/type, or service started with the wrong type. | Add both foreground-service declarations for API 34 targets and call startForeground() with the media-projection type. |
| Black or stale images | Surface is invalid, dimensions do not match, or images are not being acquired and closed. | Verify surface ownership, resize both pipeline and display, and drain buffers on a worker thread. |
| Capture stops after screen lock | Android revoked the projection. | Handle onStop(), release resources, and ask the user to start a new session. |
| Only part of the app window appears | Selected-window dimensions changed after consent. | Implement onCapturedContentResize() and update the existing display and surface. |
| Service process is killed | Process death or device resource pressure. | Persist only resumable app state; do not assume the projection token can be reused. Require fresh consent. |
10. Performance, reliability, and cost
- Throughput: Encode on a dedicated worker. A still-image workflow usually needs only one or a few frames; avoid retaining the whole stream.
- Memory: Match buffer dimensions to the selected content and release images immediately.
- Latency: Warm the pipeline before requesting the frame you need, then wait for a complete image rather than sleeping for a fixed duration.
- Reliability: Treat callbacks as authoritative. A successful start does not guarantee the session remains active.
- Privacy: Store only the frames required by your feature and clearly explain capture scope in the foreground notification and UI.
- Cost: MediaProjection itself has no per-screenshot API fee. Your costs come from device CPU, memory, storage, network transfer, and any downstream image service.
11. Or skip the browser setup
If your goal is a screenshot of a web page rather than the Android device display, ScreenshotNeo provides a single HTTP request. It removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
There are 1,000 free screenshots each month with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
12. FAQ
Can an ordinary background service capture the screen?
No. The supported design is a user-visible foreground service with an active, user-approved MediaProjection session.
Can I capture without showing consent?
No. Android requires user consent for each capture session.
Does MediaProjection return a Bitmap?
No. It routes content to your surface. Your frame pipeline must acquire, convert, encode, and store the image.
Can I keep a projection token forever?
No. Sessions are revocable, and current Android rules require a fresh consent flow for a new session.
Should I create a new VirtualDisplay after rotation?
No. Resize the existing display and update its surface.


