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>
This commit is contained in:
ScreenTinker 2026-07-10 11:34:47 -05:00
parent b72e964433
commit f60f677cf0
9 changed files with 400 additions and 26 deletions

View file

@ -88,6 +88,17 @@ class MainActivity : AppCompatActivity() {
bound = true bound = true
setupServiceCallbacks() setupServiceCallbacks()
wsService?.connect() wsService?.connect()
// If the service is ALREADY connected+registered when we bind (MainActivity relaunched
// via CLEAR_TASK right after a re-pair/reclaim, so the onRegistered that clears the boot
// "Connecting to server…" status fired before this Activity existed), catch the UI up by
// pulling a fresh playlist — its update drives the real status (playing / waiting-for-
// content / nothing-scheduled), replacing the stale "Connecting to server…". Without this
// a fully-online device could sit on "Connecting to server…" indefinitely. We keep the
// boot status until the playlist arrives (no blank screen) rather than blindly hiding it.
if (wsService?.isConnected() == true && !playlistController.isPlaying) {
ackedContent.clear()
wsService?.requestPlaylistRefresh()
}
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
@ -506,7 +517,11 @@ class MainActivity : AppCompatActivity() {
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "") val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
if (contentId.isEmpty()) continue if (contentId.isEmpty()) continue
val filename = item.optString("filename", "content") val filename = item.optString("filename", "content")
val remoteUrl = item.optString("remote_url", null) // org.json's optString(key, null) returns the STRING "null" when the value is JSON
// null (not the fallback) — so a local item with "remote_url": null was being
// misclassified as a remote stream, ack'd "ready", and NEVER downloaded, stranding
// the screen on "waiting for content". Guard with isNull() like widget_id/content_id above.
val remoteUrl = if (item.isNull("remote_url")) null else item.optString("remote_url", null)
// Skip remote URL content - it streams directly // Skip remote URL content - it streams directly
if (!remoteUrl.isNullOrEmpty()) { if (!remoteUrl.isNullOrEmpty()) {
@ -671,11 +686,14 @@ class MainActivity : AppCompatActivity() {
} }
wsService?.onUnpaired = { wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning") Log.w("MainActivity", "Device removed from server, going to provisioning for re-pair")
config.clearPlaylistCache() config.clearPlaylistCache()
handler.post { handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply { startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
// Tell provisioning this is a server-initiated re-pair (known-good URL) so it
// shows a "waiting for re-pair" status + the code instead of the URL entry.
putExtra("EXTRA_REPAIR", true)
}) })
finish() finish()
} }

View file

@ -8,7 +8,9 @@ import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
@ -36,6 +38,23 @@ class ProvisioningActivity : AppCompatActivity() {
private lateinit var pairingSection: View private lateinit var pairingSection: View
private lateinit var serverSection: View private lateinit var serverSection: View
private val handler = Handler(Looper.getMainLooper())
// Fix 1: revert to URL entry if a connect attempt hangs (almost always a wrong/unreachable URL).
private var stuckRunnable: Runnable? = null
private var registered = false
// Fix 2: server-initiated re-pair (device removed / auth-error) — URL is known-good, so we show
// a "waiting for re-pair" status + the pairing code instead of the URL entry, and never bounce
// back to URL entry on a slow connect (that's an outage, not a bad address).
private var repairMode = false
// Fix 2 (settle window): ticks the "re-pairing available in Xs" countdown while the server's
// #150 reclaim hold is in effect, so the screen is stable and honest instead of flickering.
private var repairTicker: Runnable? = null
companion object {
// How long to sit on "Connecting to server…" before assuming the URL is wrong.
private const val CONNECT_TIMEOUT_MS = 60_000L
}
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder val binder = service as WebSocketService.LocalBinder
@ -95,6 +114,19 @@ class ProvisioningActivity : AppCompatActivity() {
connectToServer(url) connectToServer(url)
} }
// Fix 2: arrived here because the server unpaired/rejected this device. The URL is known-good,
// so skip the URL entry — show a re-pair status and wait for the (fresh) pairing code. The
// service (still running) re-registers on the live socket, so a code is typically already
// available; showPairingIfReady() on bind renders it race-free.
repairMode = intent.getBooleanExtra("EXTRA_REPAIR", false)
if (repairMode) {
serverSection.visibility = View.GONE
connectBtn.visibility = View.GONE
progressBar.visibility = View.VISIBLE
statusText.text = "This device was unpaired by the server.\nWaiting for re-pair…"
startRepairTicker()
}
// Request notification permission on Android 13+ // Request notification permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@ -134,12 +166,91 @@ class ProvisioningActivity : AppCompatActivity() {
progressBar.visibility = View.VISIBLE progressBar.visibility = View.VISIBLE
statusText.text = "Connecting to server..." statusText.text = "Connecting to server..."
registered = false
armStuckTimer()
wsService?.connect(url) wsService?.connect(url)
} }
// Fix 1: if we can't register within CONNECT_TIMEOUT_MS the URL is almost certainly wrong or
// unreachable — stop hammering it and drop back to the URL entry so the operator can fix it,
// instead of sitting on "Connecting to server…" forever. Skipped in repairMode (known-good URL).
private fun armStuckTimer() {
cancelStuckTimer()
if (repairMode) return
stuckRunnable = Runnable {
if (isFinishing || registered) return@Runnable
try { wsService?.disconnect() } catch (_: Exception) {}
progressBar.visibility = View.GONE
serverSection.visibility = View.VISIBLE
connectBtn.visibility = View.VISIBLE
connectBtn.isEnabled = true
pairingSection.visibility = View.GONE
statusText.text = "Couldn't reach the server after 60s.\nCheck the URL and try again."
}
handler.postDelayed(stuckRunnable!!, CONNECT_TIMEOUT_MS)
}
private fun cancelStuckTimer() {
stuckRunnable?.let { handler.removeCallbacks(it) }
stuckRunnable = null
}
// Render the pairing code if the service already has one (unpaired + code present). Covers the
// re-pair race where the service re-registered before this (freshly recreated) activity bound.
private fun showPairingIfReady() {
// Only show the code once the SERVER has accepted it (pairable) — not a rejected/stale local
// code sitting in prefs during the reclaim-settle hold.
val code = wsService?.getPairingCode() ?: ""
if (wsService?.isPairingCodeLive() == true && code.isNotEmpty()) {
registered = true
cancelStuckTimer()
stopRepairTicker()
progressBar.visibility = View.GONE
serverSection.visibility = View.GONE
connectBtn.visibility = View.GONE
pairingSection.visibility = View.VISIBLE
pairingCodeText.text = code
statusText.text = if (repairMode) "This device was unpaired.\nEnter this code on the dashboard to re-pair." else ""
}
}
// Fix 2: while the server's reclaim-settle hold is active (nothing to show yet), tick a live
// "re-pairing available in Xs" countdown so the screen is stable and explains the wait, instead
// of flickering. Stops as soon as a pairing code is available (showPairingIfReady).
private fun startRepairTicker() {
stopRepairTicker()
repairTicker = object : Runnable {
override fun run() {
if (isFinishing) return
// A server-ACCEPTED code takes over the screen; a stale rejected one does not.
if (wsService?.isPairingCodeLive() == true) { showPairingIfReady(); return }
// Still waiting: keep the code section hidden and show the settle countdown.
serverSection.visibility = View.GONE
connectBtn.visibility = View.GONE
pairingSection.visibility = View.GONE
progressBar.visibility = View.VISIBLE
val remainingMs = wsService?.repairHoldRemainingMs() ?: 0L
statusText.text = if (remainingMs > 0)
"This display was recently active.\nRe-pairing available in ${(remainingMs + 999) / 1000}s…"
else
"This device was unpaired.\nWaiting for re-pair…"
handler.postDelayed(this, 1000L)
}
}
handler.post(repairTicker!!)
}
private fun stopRepairTicker() {
repairTicker?.let { handler.removeCallbacks(it) }
repairTicker = null
}
private fun setupServiceCallbacks() { private fun setupServiceCallbacks() {
wsService?.onRegistered = { deviceId -> wsService?.onRegistered = { deviceId ->
runOnUiThread { runOnUiThread {
registered = true
cancelStuckTimer()
stopRepairTicker()
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
// Hide the server/connect controls so the pairing code has the // Hide the server/connect controls so the pairing code has the
// whole screen and stays visible on short/landscape phones. // whole screen and stays visible on short/landscape phones.
@ -147,15 +258,31 @@ class ProvisioningActivity : AppCompatActivity() {
connectBtn.visibility = View.GONE connectBtn.visibility = View.GONE
pairingSection.visibility = View.VISIBLE pairingSection.visibility = View.VISIBLE
pairingCodeText.text = wsService?.getPairingCode() ?: "------" pairingCodeText.text = wsService?.getPairingCode() ?: "------"
// The instruction is shown once, inside the pairing section; don't // The instruction is shown once, inside the pairing section; a re-pair adds a short
// duplicate it in statusText. // note in statusText, a fresh setup leaves it blank.
statusText.text = "" statusText.text = if (repairMode) "This device was unpaired.\nEnter this code on the dashboard to re-pair." else ""
connectBtn.isEnabled = false connectBtn.isEnabled = false
} }
} }
// Fix 2: a REPEAT rejection while we're already on the re-pair screen must NOT re-navigate
// (that caused the flicker). Stay put and keep the countdown ticking (the service extended
// the hold). Overriding MainActivity's stale onUnpaired also stops it firing a new Activity.
wsService?.onUnpaired = {
runOnUiThread {
repairMode = true
serverSection.visibility = View.GONE
connectBtn.visibility = View.GONE
pairingSection.visibility = View.GONE
progressBar.visibility = View.VISIBLE
startRepairTicker()
}
}
wsService?.onPaired = { deviceId, name -> wsService?.onPaired = { deviceId, name ->
runOnUiThread { runOnUiThread {
cancelStuckTimer()
stopRepairTicker()
statusText.text = "Paired as: $name" statusText.text = "Paired as: $name"
// Transition to main activity // Transition to main activity
val intent = Intent(this, MainActivity::class.java) val intent = Intent(this, MainActivity::class.java)
@ -164,9 +291,23 @@ class ProvisioningActivity : AppCompatActivity() {
finish() finish()
} }
} }
// Re-pair path: the socket is usually already up (service kept running). Make sure it's
// connecting, then render any pairing code the service already issued (race-free). If we're
// still inside the reclaim-settle hold (no code yet), the ticker shows the countdown.
if (repairMode || wsService?.isAwaitingRepair() == true) {
repairMode = true
if (wsService?.isConnected() != true) {
try { wsService?.connect(config.serverUrl) } catch (_: Exception) {}
}
showPairingIfReady()
if (wsService?.isPairingCodeLive() != true) startRepairTicker()
}
} }
override fun onDestroy() { override fun onDestroy() {
cancelStuckTimer()
stopRepairTicker()
if (bound) { if (bound) {
unbindService(connection) unbindService(connection)
bound = false bound = false

View file

@ -110,6 +110,11 @@ class SetupActivity : AppCompatActivity() {
}) })
} }
// 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 // 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 // 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 // the user grants it. Older versions auto-grant it, so only show the row where
@ -214,11 +219,43 @@ class SetupActivity : AppCompatActivity() {
overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt()) overlayStatus.setTextColor(if (canOverlay) 0xFF22C55E.toInt() else 0xFFEF4444.toInt())
enableOverlayBtn.visibility = if (canOverlay) View.GONE else View.VISIBLE 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 // Update continue button text
val allGood = accessibilityEnabled && canInstall val allGood = accessibilityEnabled && canInstall
continueBtn.text = if (allGood) "Continue to Setup" else "Continue Anyway" 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 { private fun isAccessibilityEnabled(): Boolean {
val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)

View file

@ -33,7 +33,9 @@ class ContentCache internal constructor(
// Match "<id>.<ext>" exactly: the trailing dot stops an id that PREFIXES another id from // Match "<id>.<ext>" exactly: the trailing dot stops an id that PREFIXES another id from
// cross-matching, and `.part` temps (partial/in-flight downloads) are never returned. // cross-matching, and `.part` temps (partial/in-flight downloads) are never returned.
val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.endsWith(PART_SUFFIX) } val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.endsWith(PART_SUFFIX) }
return files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 } val hit = files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 }
com.remotedisplay.player.util.DebugLog.v("ContentCache", "getCachedFile($contentId): dir=${cacheDir.absolutePath} listFiles=${files?.size} -> ${hit?.name ?: "MISS"}")
return hit
} }
fun isContentCached(contentId: String): Boolean { fun isContentCached(contentId: String): Boolean {

View file

@ -2,6 +2,7 @@ package com.remotedisplay.player.data
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import com.remotedisplay.player.util.DebugLog
import java.util.Collections import java.util.Collections
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
@ -50,11 +51,12 @@ class DownloadCoordinator(
*/ */
fun ensure(contentId: String, filename: String) { fun ensure(contentId: String, filename: String) {
if (contentId.isEmpty()) return if (contentId.isEmpty()) return
if (cache.isContentCached(contentId)) { onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A) if (cache.isContentCached(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A)
// Socket down => the WATCHDOG owns recovery; don't hammer downloads over a dead connection. // Socket down => the WATCHDOG owns recovery; don't hammer downloads over a dead connection.
if (!socketAlive()) return if (!socketAlive()) { DebugLog.v("DownloadCoordinator", "ensure($contentId): socket not alive -> skip"); return }
if (now() < (nextAttemptAt[contentId] ?: 0L)) return // in failure backoff — don't storm if (now() < (nextAttemptAt[contentId] ?: 0L)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): in backoff until ${nextAttemptAt[contentId]} -> skip"); return } // in failure backoff — don't storm
if (!inFlight.add(contentId)) return // single-flight: already downloading if (!inFlight.add(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): already inFlight -> skip"); return } // single-flight: already downloading
DebugLog.v("DownloadCoordinator", "ensure($contentId): dispatching download '$filename'")
try { try {
executor.execute { runDownload(contentId, filename) } executor.execute { runDownload(contentId, filename) }
} catch (e: Throwable) { } catch (e: Throwable) {

View file

@ -154,12 +154,15 @@ class PlaylistController(
// Try to keep playing the current item if it's still in the list // Try to keep playing the current item if it's still in the list
if (currentlyPlayingId != null) { if (currentlyPlayingId != null) {
val newIndex = items.indexOfFirst { it.contentId == currentlyPlayingId } val newIndex = items.indexOfFirst { it.contentId == currentlyPlayingId }
if (newIndex >= 0) { if (newIndex >= 0 && hasContentOnScreen) {
// Current item still exists - don't interrupt, just update index // Current item still exists AND is genuinely on screen - don't interrupt, just update index.
currentIndex = newIndex currentIndex = newIndex
Log.i("PlaylistController", "Current item still in playlist at index $newIndex, not interrupting") Log.i("PlaylistController", "Current item still in playlist at index $newIndex, not interrupting")
return return
} }
// #162: if the item is still present but nothing is actually rendered (hasContentOnScreen
// = false, e.g. content wasn't downloaded when we first tried), do NOT trust the stale
// "playing" state — fall through to (re)pick a playable item and start below.
} }
// Current item was removed or nothing was playing - start from the first // Current item was removed or nothing was playing - start from the first
// schedule-active AND downloaded item. Distinguish the two idle reasons: daypart // schedule-active AND downloaded item. Distinguish the two idle reasons: daypart
@ -205,8 +208,13 @@ class PlaylistController(
onPlaylistEmpty() onPlaylistEmpty()
return return
} }
if (isRunning && currentIndex >= 0 && currentIndex < items.size) { // #162: isRunning + a valid index are NOT proof the player is actually rendering. After a
// Already playing something valid - don't restart // restore, or an onContentNotReady (content still downloading when start() first ran),
// isRunning stays true with a seeded currentIndex but NOTHING on screen — and the old guard
// then blocked every retry, stranding the panel on "waiting for content" even after the
// content finished downloading. Only short-circuit when an item is genuinely on screen;
// otherwise fall through and (re)start so playback reliably begins/recovers.
if (isRunning && currentIndex >= 0 && currentIndex < items.size && hasContentOnScreen) {
Log.i("PlaylistController", "Already playing ${items[currentIndex].filename}, not restarting") Log.i("PlaylistController", "Already playing ${items[currentIndex].filename}, not restarting")
return return
} }

View file

@ -55,6 +55,9 @@ class WebSocketService : Service() {
// #148: backoff before re-opening the single socket after a disconnect that Socket.IO // #148: backoff before re-opening the single socket after a disconnect that Socket.IO
// does NOT auto-reconnect (io server/client disconnect) — never a blind immediate re-open. // does NOT auto-reconnect (io server/client disconnect) — never a blind immediate re-open.
private const val RECONNECT_AFTER_EVICT_MS = 3000L private const val RECONNECT_AFTER_EVICT_MS = 3000L
// Fix 2: re-pair re-register backoff bounds (see handleServerRejection / scheduleRepairRegister).
private const val REPAIR_BACKOFF_MIN_MS = 3000L
private const val REPAIR_BACKOFF_MAX_MS = 60_000L
} }
// Callbacks // Callbacks
@ -220,6 +223,14 @@ class WebSocketService : Service() {
config.deviceToken = data.optString("device_token", "") config.deviceToken = data.optString("device_token", "")
} }
Log.i("WebSocketService", "Registered as: $newDeviceId") Log.i("WebSocketService", "Registered as: $newDeviceId")
pairingCodeLive = true // server accepted this registration — any shown code is now pairable
if (config.isPaired) {
resetRepairBackoff() // normal authenticated reconnect — fully exit repair mode
} else if (awaitingRepair) {
// Re-pair code ISSUED (settle window cleared): stop retrying and keep the code on
// screen until an admin claims it (device:paired) — don't re-register on reconnect.
repairRetryPending = false; repairBackoffMs = 0L; repairHoldUntilMs = 0L
}
handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } } handler.post { try { onRegistered?.invoke(newDeviceId) } catch (e: Throwable) { Log.e("WebSocketService", "onRegistered cb: ${e.message}") } }
startHeartbeat() startHeartbeat()
} }
@ -235,17 +246,11 @@ class WebSocketService : Service() {
watchdogAttempt = 0 watchdogAttempt = 0
} }
safeOn("device:unpaired") { safeOn("device:unpaired") { handleServerRejection("device:unpaired (removed on server)") }
Log.w("WebSocketService", "Device not found on server - clearing credentials")
config.clearDeviceCredentials()
handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
}
safeOn("device:auth-error") { args -> safeOn("device:auth-error") { args ->
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed" val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair") handleServerRejection("auth-error: $msg")
config.clearDeviceCredentials()
handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
} }
safeOn("device:paired") { args -> safeOn("device:paired") { args ->
@ -253,6 +258,10 @@ class WebSocketService : Service() {
val id = data.optString("device_id", "") val id = data.optString("device_id", "")
val name = data.optString("name", "Display") val name = data.optString("name", "Display")
config.setPaired(true) config.setPaired(true)
pairingCodeLive = false
resetRepairBackoff() // re-pair complete — exit the re-pair/hold state
// Pairing code consumed — drop it so a future re-pair mints a fresh one.
getSharedPreferences("remote_display", MODE_PRIVATE).edit().remove("pairing_code").apply()
config.deviceName = name config.deviceName = name
// Server-provisioned settings PIN — unique per device, stored encrypted. // Server-provisioned settings PIN — unique per device, stored encrypted.
// If the server doesn't send one (old server), ServerConfig generates a // If the server doesn't send one (old server), ServerConfig generates a
@ -432,7 +441,14 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") } } catch (e: Throwable) { Log.w("WebSocketService", "identity: ${e.message}") }
} }
private fun register() { private fun register(fromRepairRetry: Boolean = false) {
// While awaiting re-pair, ONLY the scheduled retry may register. A reconnect's EVENT_CONNECT
// register() during the hold would hit the reclaim guard again and restart the churn — and
// once a pairing code is shown, the server keeps it valid, so re-registering is unnecessary.
if (awaitingRepair && !config.isPaired && !fromRepairRetry) {
Log.i("WebSocketService", "register suppressed — awaiting re-pair (hold ${repairHoldRemainingMs()}ms)")
return
}
try { try {
val data = JSONObject().apply { val data = JSONObject().apply {
if (config.isProvisioned && config.isPaired) { if (config.isProvisioned && config.isPaired) {
@ -442,11 +458,17 @@ class WebSocketService : Service() {
put("device_token", token) put("device_token", token)
} }
} else { } else {
val pairingCode = (100000..999999).random().toString() // Reuse a stable pairing code across reconnects / re-pair prompts so an admin
// isn't chasing a rotating number mid-pairing; only mint one when we don't have
// one yet. Cleared on a successful device:paired so the NEXT pairing is fresh.
val prefs = getSharedPreferences("remote_display", MODE_PRIVATE)
var pairingCode = prefs.getString("pairing_code", "") ?: ""
if (pairingCode.isEmpty()) {
pairingCode = (100000..999999).random().toString()
prefs.edit().putString("pairing_code", pairingCode).apply()
}
put("pairing_code", pairingCode) put("pairing_code", pairingCode)
config.deviceId = "" config.deviceId = ""
getSharedPreferences("remote_display", MODE_PRIVATE)
.edit().putString("pairing_code", pairingCode).apply()
} }
try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") } try { put("device_info", deviceInfo.getDeviceInfo()) } catch (e: Throwable) { Log.w("WebSocketService", "device_info: ${e.message}") }
try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") } try { put("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") }
@ -463,6 +485,82 @@ class WebSocketService : Service() {
.getString("pairing_code", "") ?: "" .getString("pairing_code", "") ?: ""
} }
// Fix 2 re-pair backoff. A server that keeps rejecting registration — notably the #150
// fingerprint reclaim-settle window ("retry after it has been offline for 300 seconds") — must
// NOT be answered with a tight re-register loop. Without this, every auth-error triggered an
// immediate re-register that hit the guard again ~20x/sec (a self-inflicted storm, worse than
// the stuck screen it replaced). Debounce to a SINGLE pending retry with exponential backoff.
@Volatile private var repairRetryPending = false
private var repairBackoffMs = 0L
// #150 reclaim-settle: when the server says "retry after it has been offline for N seconds",
// HOLD the re-pair screen for that whole window (all registration suppressed) instead of churning
// — the operator sees a stable "re-pairing available in Xs" countdown, and we retry exactly ONCE
// when it elapses. awaitingRepair spans from the first rejection until an actual device:paired
// (or a normal authenticated reconnect), so the "waiting for re-pair" screen never flickers.
@Volatile private var awaitingRepair = false
@Volatile private var repairHoldUntilMs = 0L
// register() stores the pairing code locally BEFORE emitting, so getPairingCode() is non-empty
// even for a registration the server then REJECTS (reclaim-settle). This flag tracks whether the
// server actually ACCEPTED it (device:registered) — only then is the code pairable and shown.
@Volatile private var pairingCodeLive = false
/** True from the first server rejection until the device is (re)paired — UI stays on re-pair. */
fun isAwaitingRepair(): Boolean = awaitingRepair
/** Milliseconds left in the reclaim-settle hold (0 once elapsed) — drives the UI countdown. */
fun repairHoldRemainingMs(): Long = maxOf(0L, repairHoldUntilMs - SystemClock.elapsedRealtime())
/** True only when the shown pairing code is server-accepted (pairable) — not a rejected/stale one. */
fun isPairingCodeLive(): Boolean = pairingCodeLive && !config.isPaired
// Pull the settle window out of the #150 reclaim message ("...offline for 300 seconds.").
private fun parseSettleSeconds(reason: String): Int =
Regex("offline for (\\d+) seconds").find(reason)?.groupValues?.get(1)?.toIntOrNull() ?: 0
/**
* The server rejected this device mid-session (removed from the dashboard -> device:unpaired,
* or reclaim-settle / bad token -> device:auth-error). Clear credentials, surface the re-pair
* screen ONCE, honor any reclaim-settle window, and schedule ONE re-register.
*
* We never re-register inline (that stormed the reclaim guard) or disconnect/reconnect (that
* thrashed the socket). While awaitingRepair, ALL registration is suppressed except the single
* scheduled retry, so the screen is stable no register/reject/register churn.
*/
private fun handleServerRejection(reason: String) {
val settleSec = parseSettleSeconds(reason)
Log.w("WebSocketService", "Server rejected device ($reason) — settle=${settleSec}s")
pairingCodeLive = false // this registration was rejected — the local code is NOT pairable
config.clearDeviceCredentials()
if (settleSec > 0) repairHoldUntilMs = SystemClock.elapsedRealtime() + settleSec * 1000L
if (!awaitingRepair) {
awaitingRepair = true
handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
}
scheduleRepairRegister()
}
private fun scheduleRepairRegister() {
if (repairRetryPending) return // debounce: one pending retry per window kills the storm
repairRetryPending = true
val hold = repairHoldUntilMs - SystemClock.elapsedRealtime()
val delay = if (hold > 0) hold else { // honor the reclaim-settle window verbatim; else back off
repairBackoffMs = if (repairBackoffMs <= 0L) REPAIR_BACKOFF_MIN_MS
else minOf(repairBackoffMs * 2, REPAIR_BACKOFF_MAX_MS)
repairBackoffMs
}
Log.i("WebSocketService", "re-register for pairing in ${delay}ms")
handler.postDelayed({
repairRetryPending = false
if (socket?.connected() == true && !config.isPaired) register(fromRepairRetry = true)
}, delay)
}
/** Re-pair complete (device:paired, or a normal authenticated reconnect) — clear all repair state. */
private fun resetRepairBackoff() {
repairRetryPending = false
repairBackoffMs = 0L
awaitingRepair = false
repairHoldUntilMs = 0L
}
private var heartbeatCount = 0 private var heartbeatCount = 0
private fun startHeartbeat() { private fun startHeartbeat() {

View file

@ -18,6 +18,14 @@ object DebugLog {
fun w(tag: String, msg: String) { Log.w(tag, msg); send(tag, "w", msg) } fun w(tag: String, msg: String) { Log.w(tag, msg); send(tag, "w", msg) }
fun e(tag: String, msg: String) { Log.e(tag, msg); send(tag, "e", msg) } fun e(tag: String, msg: String) { Log.e(tag, msg); send(tag, "e", msg) }
/**
* Verbose diagnostics for hot paths (per-recheck cache probes, per-item download decisions).
* Emitted to logcat AND streamed to the dashboard ONLY while remote debug is enabled completely
* silent otherwise, so it never spams logcat in production. Use for the deep "why isn't it
* playing / downloading" trace an operator turns on from device-detail live debug.
*/
fun v(tag: String, msg: String) { if (!enabled) return; Log.i(tag, msg); send(tag, "i", msg) }
private fun send(tag: String, level: String, msg: String) { private fun send(tag: String, level: String, msg: String) {
if (!enabled) return if (!enabled) return
try { sink?.invoke(tag, level, msg) } catch (_: Throwable) {} try { sink?.invoke(tag, level, msg) } catch (_: Throwable) {}

View file

@ -388,6 +388,66 @@
android:paddingBottom="4dp" /> android:paddingBottom="4dp" />
</LinearLayout> </LinearLayout>
<!-- Default launcher / HOME. A signage kiosk MUST be the device's default launcher, or Android
keeps returning to the stock launcher and the player is torn down + recreated on a loop
(never renders). Not applicable where you can't set a launcher (e.g. some Android TV). -->
<LinearLayout
android:id="@+id/launcherRow"
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="Default Launcher"
android:textColor="#F1F5F9"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Set this app as Home so the display stays foreground (kiosk)"
android:textColor="#64748B"
android:textSize="8sp" />
</LinearLayout>
<TextView
android:id="@+id/launcherStatus"
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/enableLauncherBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="Set"
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> </LinearLayout>
<Button <Button