mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
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:
parent
5bcaca7c51
commit
6add29bf6a
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue