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')}