mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -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: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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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}")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue