screentinker/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt
ScreenTinker f60f677cf0 fix(android): player provisioning + playback robustness
Client-side fixes to the Android signage player, all validated end-to-end on a
Pixel-10 emulator (Android 16) against the alpha server.

- content download: a local item with "remote_url": null was mis-tagged as a
  remote stream (org.json optString returns the STRING "null" for a JSON null),
  so it was ack'd "ready" and NEVER downloaded — stranding the screen on
  "waiting for content" and only ever playing 1 of N files. Guard with isNull().
- playback (#162): PlaylistController trusted isRunning+currentIndex as "already
  playing" and never re-called playItem, permanently stranding a panel on
  "waiting for content" after a restart/OTA/content-not-ready-at-first-start.
  Guards now require hasContentOnScreen (a genuine render) before short-circuiting.
- provisioning: revert to the URL-entry screen if a connect attempt hangs >60s
  (wrong/unreachable URL) instead of an endless "Connecting to server…".
- re-pair: a server rejection (device:unpaired / auth-error) left the device
  connected-but-unregistered with no pairing code (stuck); a naive re-register
  then stormed the #150 reclaim guard ~20x/s. Now: re-register once, debounced +
  backed off; honor the reclaim-settle window with a stable "re-pairing available
  in Xs" countdown; show the code only once the server accepts it (isPairingCodeLive).
- status: a fully-online device could sit on a stale "Connecting to server…" when
  MainActivity was relaunched (CLEAR_TASK) after the service already registered —
  it now pulls a fresh playlist on bind so the real state renders.
- setup: add a Default Launcher (HOME role) step so a kiosk can be set as the
  default launcher without adb (prevents ~45s activity-recreate churn).
- debug: new DebugLog.v() streams the deep download/playback trace only while
  live dashboard debug is enabled; silent in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:34:47 -05:00

280 lines
12 KiB
Kotlin

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
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.remotedisplay.player.service.PowerAccessibilityService
class SetupActivity : AppCompatActivity() {
private lateinit var accessibilityStatus: TextView
private lateinit var installStatus: TextView
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)
// Skip setup if already completed
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
if (prefs.getBoolean("setup_complete", false)) {
proceedToNext()
return
}
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
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
accessibilityStatus = findViewById(R.id.accessibilityStatus)
installStatus = findViewById(R.id.installStatus)
notificationStatus = findViewById(R.id.notificationStatus)
enableAccessibilityBtn = findViewById(R.id.enableAccessibilityBtn)
enableInstallBtn = findViewById(R.id.enableInstallBtn)
continueBtn = findViewById(R.id.continueBtn)
// Show notification row on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
findViewById<View>(R.id.notificationRow).visibility = View.VISIBLE
findViewById<Button>(R.id.enableNotificationBtn).setOnClickListener {
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100
)
}
}
enableAccessibilityBtn.setOnClickListener {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
}
enableInstallBtn.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
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")
})
}
// Default launcher / HOME: a kiosk MUST be the default launcher, else Android returns to the
// stock launcher and tears down + recreates the player on a loop (it never renders). Request
// the HOME role (clean system dialog on API 29+); fall back to the Home-app picker in Settings.
findViewById<Button>(R.id.enableLauncherBtn).setOnClickListener { promptSetDefaultLauncher() }
// 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()
}
findViewById<TextView>(R.id.skipText).setOnClickListener {
prefs.edit().putBoolean("setup_complete", true).apply()
proceedToNext()
}
updateStatuses()
}
override fun onResume() {
super.onResume()
updateStatuses()
}
private fun updateStatuses() {
// Accessibility
val accessibilityEnabled = isAccessibilityEnabled()
accessibilityStatus.text = if (accessibilityEnabled) "ON" else "OFF"
accessibilityStatus.setTextColor(
if (accessibilityEnabled) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
enableAccessibilityBtn.visibility = if (accessibilityEnabled) View.GONE else View.VISIBLE
// Install unknown apps
val canInstall = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
packageManager.canRequestPackageInstalls()
} else true
installStatus.text = if (canInstall) "ON" else "OFF"
installStatus.setTextColor(
if (canInstall) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
enableInstallBtn.visibility = if (canInstall) View.GONE else View.VISIBLE
// Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val hasNotif = ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
notificationStatus.text = if (hasNotif) "ON" else "OFF"
notificationStatus.setTextColor(
if (hasNotif) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()
)
findViewById<Button>(R.id.enableNotificationBtn).visibility =
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
// Default launcher (HOME): kiosk foreground stability requires being the default launcher.
val isDefaultHome = isDefaultLauncher()
val launcherStatus = findViewById<TextView>(R.id.launcherStatus)
launcherStatus.text = if (isDefaultHome) "ON" else "OFF"
launcherStatus.setTextColor(if (isDefaultHome) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
findViewById<Button>(R.id.enableLauncherBtn).visibility = if (isDefaultHome) View.GONE else View.VISIBLE
// Update continue button text
val allGood = accessibilityEnabled && canInstall
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway"
}
private fun isDefaultLauncher(): Boolean {
val ri = packageManager.resolveActivity(
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME),
PackageManager.MATCH_DEFAULT_ONLY
)
return ri?.activityInfo?.packageName == packageName
}
private fun promptSetDefaultLauncher() {
// Android 10+ (Q): request the HOME role — a clean one-tap system dialog.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
val rm = getSystemService(android.app.role.RoleManager::class.java)
if (rm != null && rm.isRoleAvailable(android.app.role.RoleManager.ROLE_HOME) &&
!rm.isRoleHeld(android.app.role.RoleManager.ROLE_HOME)) {
startActivityForResult(rm.createRequestRoleIntent(android.app.role.RoleManager.ROLE_HOME), 200)
return
}
} catch (_: Exception) { /* fall through to the settings picker */ }
}
// Fallback: open the "Home app" picker in Settings (works on every version / OEM).
try { startActivity(Intent(Settings.ACTION_HOME_SETTINGS)) }
catch (_: Exception) { try { startActivity(Intent(Settings.ACTION_SETTINGS)) } catch (_: Exception) {} }
}
private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
val myComponent = ComponentName(this, PowerAccessibilityService::class.java)
return enabledServices.any {
it.resolveInfo.serviceInfo.let { si ->
ComponentName(si.packageName, si.name) == myComponent
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
updateStatuses()
}
private fun proceedToNext() {
startActivity(Intent(this, ProvisioningActivity::class.java))
finish()
}
}