mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-21 13:42:38 -06:00
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:
parent
d6e85b1745
commit
66ef47239f
|
|
@ -67,11 +67,21 @@
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:theme="@style/Theme.RemoteDisplay.Fullscreen" />
|
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
|
<service
|
||||||
android:name=".service.WebSocketService"
|
android:name=".service.WebSocketService"
|
||||||
android:exported="false"
|
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 -->
|
<!-- Accessibility service for power controls -->
|
||||||
<service
|
<service
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import android.content.Intent
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.remotedisplay.player.service.ScreenCaptureService
|
import com.remotedisplay.player.service.MediaProjectionService
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transparent activity that requests MediaProjection permission.
|
* Transparent activity that requests MediaProjection permission.
|
||||||
|
|
@ -50,8 +50,11 @@ class ScreenCapturePermissionActivity : Activity() {
|
||||||
Companion.resultData = data?.clone() as? Intent
|
Companion.resultData = data?.clone() as? Intent
|
||||||
Companion.hasPermission = true
|
Companion.hasPermission = true
|
||||||
|
|
||||||
// Tell the service to start the projection
|
// #5: hand the consent to the dedicated mediaProjection foreground
|
||||||
ScreenCaptureService.startProjection(this, resultCode, data)
|
// 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)
|
getSharedPreferences("remote_display", MODE_PRIVATE)
|
||||||
.edit().putBoolean("screen_capture_granted", true).apply()
|
.edit().putBoolean("screen_capture_granted", true).apply()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,13 +54,9 @@ object ScreenCaptureService {
|
||||||
|
|
||||||
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
|
imageReader = ImageReader.newInstance(captureWidth, captureHeight, PixelFormat.RGBA_8888, 4)
|
||||||
|
|
||||||
virtualDisplay = projection.createVirtualDisplay(
|
// #5: Android 14+ requires a Callback registered BEFORE createVirtualDisplay,
|
||||||
"ScreenTinker",
|
// otherwise createVirtualDisplay throws IllegalStateException. (Was registered
|
||||||
captureWidth, captureHeight, density,
|
// after, which broke system capture on Android 14+.)
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
|
||||||
imageReader!!.surface, null, null
|
|
||||||
)
|
|
||||||
|
|
||||||
projection.registerCallback(object : MediaProjection.Callback() {
|
projection.registerCallback(object : MediaProjection.Callback() {
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
Log.i(TAG, "MediaProjection stopped by system")
|
Log.i(TAG, "MediaProjection stopped by system")
|
||||||
|
|
@ -68,6 +64,13 @@ object ScreenCaptureService {
|
||||||
}
|
}
|
||||||
}, null)
|
}, 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}")
|
Log.i(TAG, "MediaProjection started: ${captureWidth}x${captureHeight}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,16 @@ class WebSocketService : Service() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
config = ServerConfig(this)
|
config = ServerConfig(this)
|
||||||
deviceInfo = DeviceInfo(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
|
// Keep CPU alive so the WebSocket connection stays alive in background
|
||||||
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager
|
val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue