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 diff --git a/frontend/js/views/kiosk.js b/frontend/js/views/kiosk.js index 6463c99..6b6c684 100644 --- a/frontend/js/views/kiosk.js +++ b/frontend/js/views/kiosk.js @@ -1,5 +1,6 @@ import { showToast } from '../components/toast.js'; import { t } from '../i18n.js'; +import { esc } from '../utils.js'; const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); @@ -44,12 +45,12 @@ async function renderList(container) { 🖱
-
${p.name}
+
${esc(p.name)}
${t('kiosk.label')}
${t('kiosk.preview')} - +
`).join(''); @@ -85,7 +86,7 @@ async function renderEditor(container, pageId) { ${t('kiosk.back')}
-
${layout.name}
+
${esc(layout.name)}
${zonesText}${isTemplate ? ' • ' + t('layout.template_label') : ''}
@@ -97,7 +98,7 @@ function renderLayoutCard(layout, isTemplate) { ? `` : `` } - +
`; @@ -115,7 +116,7 @@ async function renderEditor(container, layoutId) { ${t('layout.back')}