fix(player): auto-relaunch after OTA self-update (#96)

After the OTA installs, PACKAGE_REPLACED kills the old process and nothing brought
MainActivity back, so updating screens dropped to the launcher (the 1.9.0 fleet bug). Add a
MY_PACKAGE_REPLACED receiver that relaunches via a shared Relauncher cascade (extracted from
BootReceiver so boot + post-update share one path):
  1. overlay-direct startActivity (SYSTEM_ALERT_WINDOW) - legal on all versions when granted
  2. full-screen-intent notification - auto-launches <14; on 14+ (USE_FULL_SCREEN_INTENT
     revoked) degrades to a VISIBLE, tappable "tap to resume" prompt - fail loud, never a
     silent dark screen

Emulator-proven on Android 16: MY_PACKAGE_REPLACED -> Relauncher[update] -> overlay-direct
(BAL_ALLOW_SAW_PERMISSION) -> MainActivity on the new version. Accessibility re-binds across
the package-replace (Service connected fires post-relaunch), so sequential OTAs keep their
auto-confirm.

Unattended OTA requires accessibility (auto-confirm the install) + overlay (relaunch); the
setup wizard grants both. A device where they're skipped degrades to the visible prompt.

Closes #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-12 22:21:50 -05:00 committed by screentinker
parent 5bcaca7c51
commit 6add29bf6a
4 changed files with 141 additions and 71 deletions

View file

@ -110,6 +110,18 @@
</intent-filter>
</receiver>
<!-- #96: relaunch the player after a self-update (OTA). MY_PACKAGE_REPLACED is
delivered to the freshly-installed app; the receiver relaunches via the same
cascade as boot so the screen doesn't drop to the launcher after an update. -->
<receiver
android:name=".service.PackageReplacedReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- FileProvider for APK updates -->
<provider
android:name="androidx.core.content.FileProvider"

View file

@ -1,16 +1,9 @@
package com.remotedisplay.player.service
import android.app.PendingIntent
import android.content.BroadcastReceiver
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 android.app.NotificationManager
import com.remotedisplay.player.MainActivity
import com.remotedisplay.player.RemoteDisplayApp
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@ -20,70 +13,9 @@ class BootReceiver : BroadcastReceiver() {
action == "com.htc.intent.action.QUICKBOOT_POWERON") {
Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker")
// Start the foreground service
try {
val serviceIntent = Intent(context, WebSocketService::class.java)
if (Build.VERSION.SDK_INT >= 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)
}
}
}

View file

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

View file

@ -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 911); 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}") }
}
}
}
}