From 66ef47239fc9cf8b42b63f3310ec5d7283a029ec Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 17:19:56 -0500 Subject: [PATCH 1/2] 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) --- android/app/src/main/AndroidManifest.xml | 14 ++- .../player/ScreenCapturePermissionActivity.kt | 9 +- .../player/service/MediaProjectionService.kt | 101 ++++++++++++++++++ .../player/service/ScreenCaptureService.kt | 17 +-- .../player/service/WebSocketService.kt | 11 +- 5 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/com/remotedisplay/player/service/MediaProjectionService.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d1643f0..5a4cc48 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -67,11 +67,21 @@ android:screenOrientation="landscape" android:theme="@style/Theme.RemoteDisplay.Fullscreen" /> - + + android:foregroundServiceType="mediaPlayback" /> + + + = 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() + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/service/ScreenCaptureService.kt b/android/app/src/main/java/com/remotedisplay/player/service/ScreenCaptureService.kt index 9eb32c2..3961ce2 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/ScreenCaptureService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/ScreenCaptureService.kt @@ -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}") } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index c0c3fbf..1574556 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -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 From ba3e2cc785de0bf10b876bb51b8fd806e0a0f747 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 19:02:19 -0500 Subject: [PATCH 2/2] fix(security): patch quick-win findings from the codebase review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five low-risk, high-value fixes surfaced by the security review: #3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC, pre-auth branding resolver and the login-page