diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3767a67..bfa891e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -110,6 +110,18 @@
+
+
+
+
+
+
+
= Build.VERSION_CODES.O) {
- context.startForegroundService(serviceIntent)
- } else {
- context.startService(serviceIntent)
- }
- Log.i("BootReceiver", "WebSocket service started")
- } catch (e: Exception) {
- Log.e("BootReceiver", "Failed to start service: ${e.message}")
- }
-
- val launchIntent = Intent(context, MainActivity::class.java).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
- }
-
- // Primary: with "display over other apps" granted, a direct background
- // startActivity is permitted — the most reliable launch, and the one that
- // works on Android TV where you can't set a home launcher.
- if (Settings.canDrawOverlays(context)) {
- try {
- context.startActivity(launchIntent)
- Log.i("BootReceiver", "Direct launch (overlay permission)")
- } catch (e: Exception) {
- Log.e("BootReceiver", "Direct launch failed: ${e.message}")
- }
- }
-
- // Fallback: full-screen-intent notification (covers a locked screen / when
- // the overlay permission isn't granted).
- try {
- val pendingIntent = PendingIntent.getActivity(
- context, 0, launchIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
- )
-
- val notification = NotificationCompat.Builder(context, RemoteDisplayApp.BOOT_CHANNEL_ID)
- .setContentTitle("ScreenTinker")
- .setContentText("Starting display...")
- .setSmallIcon(android.R.drawable.ic_media_play)
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setCategory(NotificationCompat.CATEGORY_ALARM)
- .setFullScreenIntent(pendingIntent, true)
- .setAutoCancel(true)
- .build()
-
- val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- nm.notify(999, notification)
-
- Log.i("BootReceiver", "Full-screen intent notification sent")
- } catch (e: Exception) {
- Log.e("BootReceiver", "Failed to launch via notification: ${e.message}")
- // Fallback: try direct launch
- try {
- val launchIntent = Intent(context, MainActivity::class.java).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
- }
- context.startActivity(launchIntent)
- } catch (e2: Exception) {
- Log.e("BootReceiver", "Direct launch also failed: ${e2.message}")
- }
- }
+ // #96: boot + post-update relaunch share one cascade (overlay-direct -> FSI/
+ // tap-to-resume). See Relauncher.
+ Relauncher.relaunch(context, Relauncher.BOOT)
}
}
}
diff --git a/android/app/src/main/java/com/remotedisplay/player/service/PackageReplacedReceiver.kt b/android/app/src/main/java/com/remotedisplay/player/service/PackageReplacedReceiver.kt
new file mode 100644
index 0000000..e621eba
--- /dev/null
+++ b/android/app/src/main/java/com/remotedisplay/player/service/PackageReplacedReceiver.kt
@@ -0,0 +1,23 @@
+package com.remotedisplay.player.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+
+/**
+ * #96: fires after the player updates itself via the OTA. When the app installs a new APK of
+ * its own package, the system sends ACTION_MY_PACKAGE_REPLACED to the freshly-installed app
+ * (in a new process). Without this, PACKAGE_REPLACED kills the old process and nothing brings
+ * MainActivity back - the screen drops to the launcher, which is the 1.9.0 fleet bug.
+ *
+ * Relaunch through the exact same cascade as boot (see [Relauncher]).
+ */
+class PackageReplacedReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
+ Log.i("PackageReplaced", "App updated (MY_PACKAGE_REPLACED) - relaunching")
+ Relauncher.relaunch(context, Relauncher.UPDATE)
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/remotedisplay/player/service/Relauncher.kt b/android/app/src/main/java/com/remotedisplay/player/service/Relauncher.kt
new file mode 100644
index 0000000..ba49e10
--- /dev/null
+++ b/android/app/src/main/java/com/remotedisplay/player/service/Relauncher.kt
@@ -0,0 +1,103 @@
+package com.remotedisplay.player.service
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.remotedisplay.player.MainActivity
+import com.remotedisplay.player.RemoteDisplayApp
+
+/**
+ * Brings the player back to the foreground after a trigger (device boot or a self-update).
+ * Shared by [BootReceiver] and [PackageReplacedReceiver] so both relaunch through the SAME
+ * cascade (#96).
+ *
+ * A BroadcastReceiver runs in the background, and Android 10+ blocks a bare startActivity
+ * from the background. The cascade, most-reliable first:
+ *
+ * 1. Overlay-direct startActivity — legal on EVERY version IF SYSTEM_ALERT_WINDOW is
+ * granted (the documented background-activity-launch exemption). Covers MAXHUB
+ * (elevated), any properly-onboarded device, and Fire OS 7 (Android 9, no restriction).
+ * 2. Notification — on Android <14 a full-screen intent AUTO-LAUNCHES the activity (covers
+ * FireOS, which is Android 9–11); on 14+, where USE_FULL_SCREEN_INTENT is auto-revoked,
+ * it degrades to a VISIBLE, tappable "tap to resume" prompt. That is the requirement
+ * (a) fail-loud path: human-recoverable, never a silent dark screen. The server sees
+ * the device's next check-in — or its absence — via the #96 update logging.
+ *
+ * The only device class with no path here is vanilla Android 14+ with neither the overlay
+ * granted nor the app set as home launcher — for those it stops at the visible prompt.
+ */
+object Relauncher {
+ private const val TAG = "Relauncher"
+ const val UPDATE = "update"
+ const val BOOT = "boot"
+
+ fun relaunch(context: Context, reason: String) {
+ // Keep the WS foreground service alive (it drives playback + reconnect).
+ try {
+ val svc = Intent(context, WebSocketService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(svc)
+ else context.startService(svc)
+ Log.i(TAG, "[$reason] WebSocket service started")
+ } catch (e: Exception) {
+ Log.e(TAG, "[$reason] Failed to start service: ${e.message}")
+ }
+
+ val launchIntent = Intent(context, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+
+ // 1. Overlay-direct: the most reliable bg-launch path when the overlay is granted.
+ var launched = false
+ if (Settings.canDrawOverlays(context)) {
+ try {
+ context.startActivity(launchIntent)
+ launched = true
+ Log.i(TAG, "[$reason] Direct launch (overlay permission)")
+ } catch (e: Exception) {
+ Log.e(TAG, "[$reason] Direct launch failed: ${e.message}")
+ }
+ }
+
+ // 2. Notification: <14 full-screen-intent auto-launch; 14+/no-overlay the visible
+ // tap-to-resume prompt. Posted even if (1) launched, so a 14+ device that could
+ // not auto-launch always has a tappable way back (fail loud, never dark).
+ postRelaunchNotification(context, launchIntent, reason, launched)
+ }
+
+ private fun postRelaunchNotification(context: Context, launchIntent: Intent, reason: String, alreadyLaunched: Boolean) {
+ try {
+ val pi = PendingIntent.getActivity(
+ context, 0, launchIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ val isUpdate = reason == UPDATE
+ val builder = NotificationCompat.Builder(context, RemoteDisplayApp.BOOT_CHANNEL_ID)
+ .setContentTitle(if (isUpdate) "ScreenTinker updated" else "ScreenTinker")
+ .setContentText(if (isUpdate) "Tap to resume the display" else "Starting display...")
+ .setSmallIcon(android.R.drawable.ic_media_play)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setContentIntent(pi) // tap -> launch (the path on 14+ where FSI is revoked)
+ .setFullScreenIntent(pi, true) // <14: auto-launch
+ .setAutoCancel(true)
+ // Fail-loud: if we could not auto-launch (14+, no overlay), keep the prompt
+ // sticky until the operator taps it to resume.
+ if (isUpdate && !alreadyLaunched) builder.setOngoing(true)
+
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ nm.notify(999, builder.build())
+ Log.i(TAG, "[$reason] Relaunch notification posted (fullScreenIntent + tappable, ongoing=${isUpdate && !alreadyLaunched})")
+ } catch (e: Exception) {
+ Log.e(TAG, "[$reason] Notification failed: ${e.message}")
+ if (!alreadyLaunched) {
+ // last-ditch: try a direct launch even though bg-launch may be blocked.
+ try { context.startActivity(launchIntent) } catch (e2: Exception) { Log.e(TAG, "[$reason] Last-ditch launch failed: ${e2.message}") }
+ }
+ }
+ }
+}