fix(android): Android 14+ MediaProjection / foreground-service compliance (#5)

On Android 14+ (targetSdk 34) the app could fail to run at all on newer devices
(Pixel 10, onn HD stick). Root cause: the always-on WebSocketService called the
2-arg startForeground(), which claims EVERY foreground-service type declared in
the manifest - including mediaProjection. Android 14 rejects starting a
mediaProjection-typed FGS without a MediaProjection consent token, so the core
service threw on launch and the player never came up. Matches the reporter's
"screen recording policy" hunch - via the FGS type, not the capture trigger.

Fixes:
- WebSocketService now claims ONLY mediaPlayback (explicit
  startForeground(..., FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), API>=29 guarded;
  2-arg on older). Manifest type narrowed to mediaPlayback.
- New MediaProjectionService (manifest type mediaProjection), started only AFTER
  the user grants consent. It enters the foreground with the mediaProjection type
  BEFORE getMediaProjection() (required on 14+), then drives ScreenCaptureService.
  The consent Activity now hands the result to this service instead of calling
  getMediaProjection() directly (an Activity can't hold that FGS type).
- ScreenCaptureService: register the MediaProjection.Callback BEFORE
  createVirtualDisplay() (Android 14 throws IllegalStateException otherwise).

Verified: Kotlin compiles, manifest merges (WebSocketService=mediaPlayback,
MediaProjectionService=mediaProjection), signed debug APK builds. NOT yet
verified on-device - needs a Pixel 10 / onn-stick run + logcat to confirm the
exact crash is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-08 17:19:56 -05:00
parent d6e85b1745
commit 66ef47239f
5 changed files with 139 additions and 13 deletions

View file

@ -67,11 +67,21 @@
android:screenOrientation="landscape"
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
<!-- WebSocket foreground service -->
<!-- WebSocket foreground service. #5: declares ONLY mediaPlayback - the
always-on service must not claim the mediaProjection FGS type, which
Android 14+ rejects unless a projection consent token is held. -->
<service
android:name=".service.WebSocketService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|mediaProjection" />
android:foregroundServiceType="mediaPlayback" />
<!-- #5: dedicated MediaProjection foreground service for system screen
capture. Started only after the user grants consent, so claiming the
mediaProjection FGS type is valid on Android 14+. -->
<service
android:name=".service.MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<!-- Accessibility service for power controls -->
<service

View file

@ -6,7 +6,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import com.remotedisplay.player.service.ScreenCaptureService
import com.remotedisplay.player.service.MediaProjectionService
/**
* Transparent activity that requests MediaProjection permission.
@ -50,8 +50,11 @@ class ScreenCapturePermissionActivity : Activity() {
Companion.resultData = data?.clone() as? Intent
Companion.hasPermission = true
// Tell the service to start the projection
ScreenCaptureService.startProjection(this, resultCode, data)
// #5: hand the consent to the dedicated mediaProjection foreground
// service. It must enter the foreground with the mediaProjection FGS
// type BEFORE getMediaProjection() on Android 14+ - an Activity can't
// do that, so we can't call getMediaProjection() directly here.
MediaProjectionService.start(this, resultCode, data)
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putBoolean("screen_capture_granted", true).apply()

View file

@ -0,0 +1,101 @@
package com.remotedisplay.player.service
import android.app.Activity
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.remotedisplay.player.RemoteDisplayApp
/**
* #5: Foreground service that owns the MediaProjection FGS type for system-wide
* screen capture (the `enable_system_capture` command).
*
* Android 14+ requires an FGS of type `mediaProjection` to be running - started
* AFTER the user grants consent - before MediaProjectionManager.getMediaProjection()
* may be called. An Activity can't enter that foreground state, so the consent
* Activity hands the result here. Kept separate from WebSocketService so the
* always-on service never claims the mediaProjection type at boot.
*/
class MediaProjectionService : Service() {
companion object {
private const val TAG = "MediaProjectionSvc"
private const val NOTIF_ID = 2
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
/** Start the projection FGS with the user's consent result. */
fun start(context: Context, resultCode: Int, data: Intent) {
val intent = Intent(context, MediaProjectionService::class.java).apply {
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, data)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, MediaProjectionService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Enter the foreground with the mediaProjection type FIRST (required on
// Android 14+ before getMediaProjection()).
startForegroundCompat()
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
?: Activity.RESULT_CANCELED
@Suppress("DEPRECATION")
val data: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent?.getParcelableExtra(EXTRA_RESULT_DATA)
}
if (resultCode != Activity.RESULT_OK || data == null) {
Log.e(TAG, "Missing/invalid projection consent; stopping service")
stopSelf()
return START_NOT_STICKY
}
return try {
ScreenCaptureService.startProjection(this, resultCode, data)
START_STICKY
} catch (e: Throwable) {
Log.e(TAG, "startProjection failed: ${e.message}", e)
stopSelf()
START_NOT_STICKY
}
}
private fun startForegroundCompat() {
val notif = NotificationCompat.Builder(this, RemoteDisplayApp.CHANNEL_ID)
.setContentTitle("ScreenTinker")
.setContentText("Screen capture active")
.setSmallIcon(android.R.drawable.ic_menu_camera)
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIF_ID, notif)
}
}
override fun onDestroy() {
// Release the projection when the service goes away.
try { ScreenCaptureService.stop() } catch (_: Throwable) {}
super.onDestroy()
}
}

View file

@ -54,13 +54,9 @@ object ScreenCaptureService {
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker",
captureWidth, captureHeight, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
// #5: Android 14+ requires a Callback registered BEFORE createVirtualDisplay,
// otherwise createVirtualDisplay throws IllegalStateException. (Was registered
// after, which broke system capture on Android 14+.)
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped by system")
@ -68,6 +64,13 @@ object ScreenCaptureService {
}
}, null)
virtualDisplay = projection.createVirtualDisplay(
"ScreenTinker",
captureWidth, captureHeight, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader!!.surface, null, null
)
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
}

View file

@ -53,7 +53,16 @@ class WebSocketService : Service() {
super.onCreate()
config = ServerConfig(this)
deviceInfo = DeviceInfo(this)
startForeground(1, createNotification())
// #5: claim ONLY the mediaPlayback FGS type. The 2-arg startForeground
// claims every manifest-declared type, and on Android 14+ claiming
// mediaProjection without a consent token throws and kills the service at
// boot (the "app won't run on newer Android" symptom). Screen capture has
// its own mediaProjection-typed service (MediaProjectionService).
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
startForeground(1, createNotification(), android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(1, createNotification())
}
// Keep CPU alive so the WebSocket connection stays alive in background
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager