- Android Studio (current stable build for your OS — check the documentation for the version matching your Gradle plugin)
- A physical Android device or an emulator with Google Play services (ML Kit's on-device text recognition is delivered through Play services)
- A Kotlin + Gradle project, package placeholder com.example.screenai
- adb on your PATH for verification
- Optional: your own OCR endpoint <your-ocr-endpoint> if you do not want on-device recognition
MediaProjection, runs OCR on the cropped region, and renders the recognized text in a small overlay card.
The overlay is four separate Android pieces glued together. Know which piece fails and the debugging is trivial:
| Piece | Android API / library | What it actually does |
|---|---|---|
| Floating button | `SYSTEM_ALERT_WINDOW` + `WindowManager` (`TYPE_APPLICATION_OVERLAY`) | Draws a view on top of other apps |
| Lifetime | Foreground `Service` + notification | Keeps the overlay and capture session alive when the app is backgrounded |
| Screenshot | `MediaProjection` + `VirtualDisplay` + `ImageReader` | Turns the display into a stream of `Bitmap` frames |
| OCR | ML Kit on-device text recognition, or your own endpoint | Converts a `Bitmap` into a string |
adb devices lists your phone or emulatorTYPE_APPLICATION_OVERLAY (check the documentation for the exact API level your target requires)Settings → About phone → tap Build number 7 times to unlock Developer options, then enable USB debugging<your-ocr-endpoint>, needs INTERNET permission and a key)Project layout you will create:
app/src/main/
├── AndroidManifest.xml
└── java/com/example/screenai/
├── MainActivity.kt // asks for overlay permission, starts the service
├── OverlayService.kt // floating button + result card
├── ScreenCapture.kt // MediaProjection, VirtualDisplay, ImageReader
└── OcrEngine.kt // ML Kit wrapper
Example: if you prefer to keep OCR fully local with a downloaded model instead of ML Kit, the options and setup steps are described in How to Run an AI Model Locally on Android: Apps and Setup.
What to do: declare the permissions, ask the user for the overlay grant through Settings.ACTION_MANAGE_OVERLAY_PERMISSION, then add the button view from a foreground service with TYPE_APPLICATION_OVERLAY.
Why: an overlay cannot be added by an Activity that is in the background, and the system only allows TYPE_APPLICATION_OVERLAY after an explicit, user-granted SYSTEM_ALERT_WINDOW permission. A foreground service is what keeps that window alive after the user returns to the launcher.
How to check: adb shell appops get com.example.screenai SYSTEM_ALERT_WINDOW must print SYSTEM_ALERT_WINDOW: allow, and the button must stay visible after pressing Home.
AndroidManifest.xml:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Required on recent API levels for a capture session; check the documentation
for the API level where this permission and service type became mandatory. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<application ...>
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".OverlayService"
android:exported="false"
android:foregroundServiceType="mediaProjection"/>
</application>
Permission request in MainActivity.kt:
if (!Settings.canDrawOverlays(this)) {
startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$packageName")
)
)
} else {
ContextCompat.startForegroundService(this, Intent(this, OverlayService::class.java))
}
Adding the button in OverlayService.kt:
val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = 24
y = 240
}
val button = TextView(this).apply {
text = "AI"
setPadding(32, 32, 32, 32)
setOnClickListener { captureAndRecognize() }
}
windowManager.addView(button, params)
Example: start the app with adb shell am start -n com.example.screenai/.MainActivity. A permission screen titled “Display over other apps” appears; after enabling it, restart the service and the AI badge stays on screen when you open Settings.
Why this works: FLAG_NOT_FOCUSABLE tells the system the window never wants keyboard focus, so taps pass through to whatever is underneath except on the button itself.

What to do: request capture consent with MediaProjectionManager.createScreenCaptureIntent(), pass resultCode and data to the service, build a VirtualDisplay feeding an ImageReader, read one frame, crop the region around the button, and hand the bitmap to OCR.
Why: MediaProjection is the only sanctioned way to read other apps' pixels — it produces a system consent dialog that the user must accept. ImageReader is the surface the virtual display renders into; you pull frames from it instead of receiving callbacks.
How to check: adb logcat -s ScreenAI:V prints frame 1080x2400 and then the recognized text; the overlay card shows the same string on screen.
// 1. Ask for consent (from the Activity)
val mpm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mpm.createScreenCaptureIntent(), REQ_CAPTURE)
// 2. Hand the grant to the foreground service
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQ_CAPTURE && resultCode == RESULT_OK) {
val intent = Intent(this, OverlayService::class.java).apply {
action = ACTION_START_CAPTURE
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, data)
}
ContextCompat.startForegroundService(this, intent)
}
}
// 3. Inside the service: virtual display -> ImageReader -> Bitmap
val metrics = resources.displayMetrics
val width = metrics.widthPixels
val height = metrics.heightPixels
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
val projection = mpm.getMediaProjection(resultCode, data)
virtualDisplay = projection.createVirtualDisplay(
"screenai-capture",
width, height, metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader.surface, null, null
)
val image = imageReader.acquireLatestImage() // always close it afterwards
val plane = image.planes[0]
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
val rowPadding = rowStride - pixelStride * width
val bitmap = Bitmap.createBitmap(
width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888
)
bitmap.copyPixelsFromBuffer(plane.buffer)
image.close()
virtualDisplay.release()
projection.stop()
Example: on a 1080×2400 screen, the returned bitmap is wider than 1080 because of rowPadding; that is normal. Crop with Bitmap.createBitmap(bitmap, x, y, w, h) around the button coordinates, keeping the crop inside 0..width and 0..height.
OCR with ML Kit (add the dependency and check the documentation for the current version):
// build.gradle.kts
implementation("com.google.mlkit:text-recognition:<current-version>")
val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
recognizer.process(InputImage.fromBitmap(cropped, 0))
.addOnSuccessListener { result -> Log.v("ScreenAI", result.text) }
.addOnFailureListener { e -> Log.e("ScreenAI", "OCR failed", e) }
Practical detail: hide the button (button.visibility = View.GONE) before acquiring the frame, otherwise the badge itself is captured and OCR may read the letters “AI” as content. Restore it in the success and failure listeners.

What to do: walk the three stages in order and confirm each one before moving on.
AI badge. Expected: the badge disappears, then a card appears with the recognized text.adb logcat -s ScreenAI:V before tapping. Expected lines, in this order: overlay added, frame 1080x2400, then the recognized string.MediaProjection grant is not reusable indefinitely (check the documentation for the lifetime rules).| What you see | What it means |
|---|---|
| Badge never appears | `SYSTEM_ALERT_WINDOW` not granted; check with `appops get` |
| Badge appears, nothing on tap | Capture consent was never returned; inspect the service `onStartCommand` extras |
| Card shows text, but only partial lines | Crop rectangle too small — widen it and re-test |
| `MaxImages (2) has already been acquired` in logcat | A previous frame was never closed |
Example: success looks like this in logcat for the string “Android version”:
V/ScreenAI: overlay added
V/ScreenAI: frame 1080x2400
V/ScreenAI: Android version
Problem. The badge never appears and logcat is empty.
android.view.WindowManager$BadTokenException: Unable to add window -- permission denied for window typewindowManager.addView() before SYSTEM_ALERT_WINDOW was granted, or the grant was revoked when the app was reinstalled.adb shell appops get com.example.screenai SYSTEM_ALERT_WINDOW
adb shell am force-stop com.example.screenai
adb shell am start -n com.example.screenai/.MainActivity
Problem. Tapping the badge does nothing, the app crashes silently.
java.lang.SecurityException: Media projections require a foreground service of type ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTIONgetMediaProjection() refuses to run unless the calling service is already foreground with the mediaProjection type.startForeground(id, notification) (and declare the type in the manifest, as in Step 1) before touching MediaProjection. Log the notification ID with Log.v("ScreenAI", "foreground started") and confirm it appears before the frame line.Problem. OCR returns an empty string on text you can clearly see.
java.lang.IllegalStateException: MaxImages (2) has already been acquired, call #close before acquiring more.imageReader.acquireLatestImage() was called twice without image.close(), so every later frame is stale or the reader is blocked.val image = imageReader.acquireLatestImage() ?: return
try {
// convert to bitmap
} finally {
image.close()
}
If the string is still empty, enlarge the crop rectangle — very small crops are the usual culprit.
Can I do this without sending screenshots to a server?
Yes. ML Kit text recognition runs on-device, and you can swap it for a locally loaded model following How to Run an AI Model Locally on Android: Apps and Setup.
Do I need root?
No. MediaProjection is a normal user-facing consent dialog; no root, no bootloader unlock, no system app.
Why does my own floating button show up inside the captured image?
Because the overlay is part of the composited display. Set its visibility to GONE, wait one frame, capture, then restore it.
Can I run the whole thing from a terminal instead of Android Studio?
Yes for the build step (./gradlew assembleDebug), and if you want a Linux shell on the phone for scripting next to the app, see How to Run Linux on Android Without Root: Termux, PRoot, and AI Tools.
You now have a foreground service that draws a floating button, a MediaProjection session that turns one tap into a cropped bitmap, and an OCR step that renders the recognized text back into an overlay card. Next, persist the recognized text to a local database and add a second button that sends it to a model of your choice — for a broader list of ready-made Android AI clients, see Best AI apps for Android in 2026.