From d9d7a8ae0fd7c05d2e4d682c86e4711a19baa2c2 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 17:44:49 -0500 Subject: [PATCH] feat(android): reliable boot-launch incl. Android TV (1.7.11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- VERSION | 2 +- android/app/build.gradle.kts | 4 +- android/app/src/main/AndroidManifest.xml | 2 + .../com/remotedisplay/player/MainActivity.kt | 3 + .../remotedisplay/player/RemoteDisplayApp.kt | 24 +- .../com/remotedisplay/player/SetupActivity.kt | 86 +++++- .../player/service/BootReceiver.kt | 26 +- .../src/main/res/layout/activity_setup.xml | 280 +++++++++++++++--- 8 files changed, 370 insertions(+), 57 deletions(-) diff --git a/VERSION b/VERSION index a412349..8f8b3f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.10 +1.7.11 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c66ac14..ab9c1ee 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 16cf27c..3767a67 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,8 @@ + + = 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) + } + ) } } } diff --git a/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt b/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt index fde4451..d696f63 100644 --- a/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt @@ -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(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" diff --git a/android/app/src/main/java/com/remotedisplay/player/service/BootReceiver.kt b/android/app/src/main/java/com/remotedisplay/player/service/BootReceiver.kt index e1711c2..90027fe 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/BootReceiver.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/BootReceiver.kt @@ -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) diff --git a/android/app/src/main/res/layout/activity_setup.xml b/android/app/src/main/res/layout/activity_setup.xml index b845e7a..7f86a7d 100644 --- a/android/app/src/main/res/layout/activity_setup.xml +++ b/android/app/src/main/res/layout/activity_setup.xml @@ -6,31 +6,42 @@ android:gravity="center" android:orientation="vertical" android:background="#111827" - android:padding="48dp" + android:padding="12dp" android:keepScreenOn="true"> + + + + + android:layout_marginBottom="3dp" /> + android:textSize="9sp" + android:layout_marginBottom="8dp" /> + android:layout_marginBottom="8dp"> + android:layout_marginBottom="5dp"> + android:textSize="8sp" />