feat(android): reliable boot-launch incl. Android TV (1.7.11)

The player has a launcher (category.HOME) + a boot receiver, but auto-start was
unreliable where you can't set a home launcher (Android TV) and on Android 14+,
where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot
full-screen launcher silently no-ops.

Boot launch:
- BootReceiver now does a direct background startActivity when 'display over other
  apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch
  restriction, and the one path that works on Android TV. Full-screen-intent
  notification kept as a fallback (locked screen / no overlay).
- Boot notification moved to a dedicated HIGH-importance channel (full-screen
  intents are only honored from one), and it auto-dismisses once the UI is up.

Setup screen — new permission rows so operators can grant what boot-launch needs:
- Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+)
- Background Activity (battery-optimization exemption)
- Display Over Apps (SYSTEM_ALERT_WINDOW)
Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue
fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now
states updates are signature-verified, so it doesn't read as 'install anything'.

Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched
(Direct launch via overlay) and the boot notice cleared itself; all rows toggle.
This commit is contained in:
ScreenTinker 2026-06-09 17:44:49 -05:00
parent acd93377e7
commit d9d7a8ae0f
8 changed files with 370 additions and 57 deletions

View file

@ -1 +1 @@
1.7.10
1.7.11

View file

@ -11,8 +11,8 @@ android {
applicationId = "com.remotedisplay.player"
minSdk = 26
targetSdk = 34
versionCode = 13
versionName = "1.7.10"
versionCode = 14
versionName = "1.7.11"
}
signingConfigs {

View file

@ -14,6 +14,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:name=".RemoteDisplayApp"

View file

@ -100,6 +100,9 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main)
// The display is up now — clear the boot "Starting display…" notification.
(getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager)?.cancel(999)
// Fullscreen immersive
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (

View file

@ -10,6 +10,9 @@ class RemoteDisplayApp : Application() {
companion object {
const val CHANNEL_ID = "remote_display_service"
const val CHANNEL_NAME = "ScreenTinker Service"
// Separate HIGH-importance channel for the boot full-screen-intent launch.
// A full-screen intent is only honored from a high-importance channel.
const val BOOT_CHANNEL_ID = "remote_display_boot"
}
override fun onCreate() {
@ -19,16 +22,19 @@ class RemoteDisplayApp : Application() {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "ScreenTinker background service"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
description = "ScreenTinker background service"
setShowBadge(false)
}
)
manager.createNotificationChannel(
NotificationChannel(BOOT_CHANNEL_ID, "ScreenTinker Startup", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Launches the display on boot"
setShowBadge(false)
}
)
}
}
}

View file

@ -2,11 +2,15 @@ package com.remotedisplay.player
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.os.Bundle
import android.provider.Settings
import android.view.View
@ -26,8 +30,15 @@ class SetupActivity : AppCompatActivity() {
private lateinit var notificationStatus: TextView
private lateinit var enableAccessibilityBtn: Button
private lateinit var enableInstallBtn: Button
private lateinit var fullscreenStatus: TextView
private lateinit var enableFullscreenBtn: Button
private lateinit var batteryStatus: TextView
private lateinit var enableBatteryBtn: Button
private lateinit var overlayStatus: TextView
private lateinit var enableOverlayBtn: Button
private lateinit var continueBtn: Button
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -40,6 +51,9 @@ class SetupActivity : AppCompatActivity() {
setContentView(R.layout.activity_setup)
// App's UI is up — clear the boot "Starting display…" notification.
getSystemService(NotificationManager::class.java)?.cancel(999)
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
@ -75,11 +89,60 @@ class SetupActivity : AppCompatActivity() {
enableInstallBtn.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = android.net.Uri.parse("package:$packageName")
data = Uri.parse("package:$packageName")
})
}
}
fullscreenStatus = findViewById(R.id.fullscreenStatus)
enableFullscreenBtn = findViewById(R.id.enableFullscreenBtn)
batteryStatus = findViewById(R.id.batteryStatus)
enableBatteryBtn = findViewById(R.id.enableBatteryBtn)
overlayStatus = findViewById(R.id.overlayStatus)
enableOverlayBtn = findViewById(R.id.enableOverlayBtn)
// Display-over-other-apps: alternate boot-launch path. With this granted the
// boot receiver can directly start the activity from the background, which
// works where you can't set a launcher (e.g. Android TV).
enableOverlayBtn.setOnClickListener {
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION).apply {
data = Uri.parse("package:$packageName")
})
}
// Launch-on-boot needs USE_FULL_SCREEN_INTENT, which Android 14+ auto-revokes
// for non-calling apps — so the boot full-screen launcher silently fails until
// the user grants it. Older versions auto-grant it, so only show the row where
// it can actually be off.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// USE_FULL_SCREEN_INTENT is auto-granted before Android 14 — hide the row.
findViewById<View>(R.id.fullscreenRow).visibility = View.GONE
} else {
enableFullscreenBtn.setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
})
}
}
}
// Battery-optimization exemption keeps the boot receiver from being deferred
// and the app from being killed in standby (esp. on OEM / TV boxes).
enableBatteryBtn.setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
}
continueBtn.setOnClickListener {
prefs.edit().putBoolean("setup_complete", true).apply()
proceedToNext()
@ -130,6 +193,27 @@ class SetupActivity : AppCompatActivity() {
if (hasNotif) View.GONE else View.VISIBLE
}
// Launch on boot (full-screen intent — only restrictable on Android 14+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val canFsi = getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
fullscreenStatus.text = if (canFsi) "ON" else "OFF"
fullscreenStatus.setTextColor(if (canFsi) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableFullscreenBtn.visibility = if (canFsi) View.GONE else View.VISIBLE
}
// Battery optimization exemption
val ignoringBattery = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(packageName)
batteryStatus.text = if (ignoringBattery) "ON" else "OFF"
batteryStatus.setTextColor(if (ignoringBattery) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableBatteryBtn.visibility = if (ignoringBattery) View.GONE else View.VISIBLE
// Display over other apps
val canOverlay = Settings.canDrawOverlays(this)
overlayStatus.text = if (canOverlay) "ON" else "OFF"
overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableOverlayBtn.visibility = if (canOverlay) View.GONE else View.VISIBLE
// Update continue button text
val allGood = accessibilityEnabled && canInstall
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"

View file

@ -5,6 +5,7 @@ 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
@ -33,18 +34,31 @@ class BootReceiver : BroadcastReceiver() {
Log.e("BootReceiver", "Failed to start service: ${e.message}")
}
// Use a full-screen intent to launch the activity (bypasses Android 12+ restrictions)
try {
val launchIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
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.CHANNEL_ID)
val notification = NotificationCompat.Builder(context, RemoteDisplayApp.BOOT_CHANNEL_ID)
.setContentTitle("ScreenTinker")
.setContentText("Starting display...")
.setSmallIcon(android.R.drawable.ic_media_play)

View file

@ -6,31 +6,42 @@
android:gravity="center"
android:orientation="vertical"
android:background="#111827"
android:padding="48dp"
android:padding="12dp"
android:keepScreenOn="true">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="RemoteDisplay Setup"
android:textColor="#3B82F6"
android:textSize="32sp"
android:textSize="12sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
android:layout_marginBottom="3dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable these permissions for full remote control"
android:textColor="#94A3B8"
android:textSize="16sp"
android:layout_marginBottom="40dp" />
android:textSize="9sp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="500dp"
android:layout_width="420dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="32dp">
android:layout_marginBottom="8dp">
<!-- Accessibility Service -->
<LinearLayout
@ -38,7 +49,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
android:layout_marginBottom="5dp">
<LinearLayout
android:layout_width="0dp"
@ -51,7 +62,7 @@
android:layout_height="wrap_content"
android:text="Accessibility Service"
android:textColor="#F1F5F9"
android:textSize="18sp"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
@ -59,7 +70,7 @@
android:layout_height="wrap_content"
android:text="Required for remote control (Home, Back, touch, gestures)"
android:textColor="#64748B"
android:textSize="13sp" />
android:textSize="8sp" />
</LinearLayout>
<TextView
@ -68,20 +79,24 @@
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="14sp"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableAccessibilityBtn"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Display Over Other Apps -->
@ -90,7 +105,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
android:layout_marginBottom="5dp">
<LinearLayout
android:layout_width="0dp"
@ -103,15 +118,15 @@
android:layout_height="wrap_content"
android:text="Install Unknown Apps"
android:textColor="#F1F5F9"
android:textSize="18sp"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Required for automatic OTA updates"
android:text="OTA updates — only our signature-verified builds install"
android:textColor="#64748B"
android:textSize="13sp" />
android:textSize="8sp" />
</LinearLayout>
<TextView
@ -120,20 +135,24 @@
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="14sp"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableInstallBtn"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Notification Permission (Android 13+) -->
@ -143,7 +162,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp"
android:layout_marginBottom="5dp"
android:visibility="gone">
<LinearLayout
@ -157,7 +176,7 @@
android:layout_height="wrap_content"
android:text="Notifications"
android:textColor="#F1F5F9"
android:textSize="18sp"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
@ -165,7 +184,7 @@
android:layout_height="wrap_content"
android:text="Required for background service"
android:textColor="#64748B"
android:textSize="13sp" />
android:textSize="8sp" />
</LinearLayout>
<TextView
@ -174,33 +193,215 @@
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="14sp"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableNotificationBtn"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="20dp"
android:paddingEnd="20dp" />
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Launch on boot / full-screen (Android 14+ restricts USE_FULL_SCREEN_INTENT) -->
<LinearLayout
android:id="@+id/fullscreenRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp"
android:visibility="visible">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Launch on Boot"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Required to auto-start the display after power-on"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/fullscreenStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableFullscreenBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Battery optimization exemption (boot + run reliability on OEM/TV boxes) -->
<LinearLayout
android:id="@+id/batteryRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp"
android:visibility="visible">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Background Activity"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Keep running and auto-start reliably"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/batteryStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableBatteryBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
<!-- Display over other apps: alternate boot-launch path (works where you
can't set a launcher, e.g. Android TV). -->
<LinearLayout
android:id="@+id/overlayRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="5dp"
android:visibility="visible">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Display Over Apps"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Lets the display launch itself on boot (TV boxes)"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/overlayStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="OFF"
android:textColor="#EF4444"
android:textSize="9sp"
android:textStyle="bold"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/enableOverlayBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Enable"
android:textColor="#FFFFFF"
android:textSize="9sp"
android:background="@drawable/button_primary"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</LinearLayout>
</LinearLayout>
<Button
android:id="@+id/continueBtn"
android:layout_width="500dp"
android:layout_height="48dp"
android:layout_width="420dp"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:text="Continue to Setup"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textSize="11sp"
android:textStyle="bold"
android:background="@drawable/button_primary" />
android:background="@drawable/button_primary"
android:paddingTop="10dp"
android:paddingBottom="10dp" />
<TextView
android:id="@+id/skipText"
@ -208,8 +409,11 @@
android:layout_height="wrap_content"
android:text="Skip (remote control features will be limited)"
android:textColor="#64748B"
android:textSize="13sp"
android:layout_marginTop="16dp"
android:textSize="8sp"
android:layout_marginTop="6dp"
android:padding="8dp" />
</LinearLayout>
</ScrollView>
</LinearLayout>