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> </intent-filter>
</receiver> </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 --> <!-- FileProvider for APK updates -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"

View file

@ -1,16 +1,9 @@
package com.remotedisplay.player.service package com.remotedisplay.player.service
import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.util.Log 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() { class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -20,70 +13,9 @@ class BootReceiver : BroadcastReceiver() {
action == "com.htc.intent.action.QUICKBOOT_POWERON") { action == "com.htc.intent.action.QUICKBOOT_POWERON") {
Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker") Log.i("BootReceiver", "Boot completed (action=$action), launching ScreenTinker")
// #96: boot + post-update relaunch share one cascade (overlay-direct -> FSI/
// Start the foreground service // tap-to-resume). See Relauncher.
try { Relauncher.relaunch(context, Relauncher.BOOT)
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}")
}
}
} }
} }
} }

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