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
setupServiceCallbacks()
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?) {
@ -506,7 +517,11 @@ class MainActivity : AppCompatActivity() {
val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "")
if (contentId.isEmpty()) continue
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
if (!remoteUrl.isNullOrEmpty()) {
@ -671,11 +686,14 @@ class MainActivity : AppCompatActivity() {
}
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()
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
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()
}

View file

@ -8,7 +8,9 @@ import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.View
import android.view.WindowManager
@ -36,6 +38,23 @@ class ProvisioningActivity : AppCompatActivity() {
private lateinit var pairingSection: 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 {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as WebSocketService.LocalBinder
@ -95,6 +114,19 @@ class ProvisioningActivity : AppCompatActivity() {
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+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
@ -134,12 +166,91 @@ class ProvisioningActivity : AppCompatActivity() {
progressBar.visibility = View.VISIBLE
statusText.text = "Connecting to server..."
registered = false
armStuckTimer()
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() {
wsService?.onRegistered = { deviceId ->
runOnUiThread {
registered = true
cancelStuckTimer()
stopRepairTicker()
progressBar.visibility = View.GONE
// Hide the server/connect controls so the pairing code has the
// whole screen and stays visible on short/landscape phones.
@ -147,15 +258,31 @@ class ProvisioningActivity : AppCompatActivity() {
connectBtn.visibility = View.GONE
pairingSection.visibility = View.VISIBLE
pairingCodeText.text = wsService?.getPairingCode() ?: "------"
// The instruction is shown once, inside the pairing section; don't
// duplicate it in statusText.
statusText.text = ""
// The instruction is shown once, inside the pairing section; a re-pair adds a short
// note in statusText, a fresh setup leaves it blank.
statusText.text = if (repairMode) "This device was unpaired.\nEnter this code on the dashboard to re-pair." else ""
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 ->
runOnUiThread {
cancelStuckTimer()
stopRepairTicker()
statusText.text = "Paired as: $name"
// Transition to main activity
val intent = Intent(this, MainActivity::class.java)
@ -164,9 +291,23 @@ class ProvisioningActivity : AppCompatActivity() {
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() {
cancelStuckTimer()
stopRepairTicker()
if (bound) {
unbindService(connection)
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
// 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
@ -214,11 +219,43 @@ class SetupActivity : AppCompatActivity() {
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)

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
// cross-matching, and `.part` temps (partial/in-flight downloads) are never returned.
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 {

View file

@ -2,6 +2,7 @@ package com.remotedisplay.player.data
import android.os.SystemClock
import android.util.Log
import com.remotedisplay.player.util.DebugLog
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
@ -50,11 +51,12 @@ class DownloadCoordinator(
*/
fun ensure(contentId: String, filename: String) {
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.
if (!socketAlive()) return
if (now() < (nextAttemptAt[contentId] ?: 0L)) return // in failure backoff — don't storm
if (!inFlight.add(contentId)) return // single-flight: already downloading
if (!socketAlive()) { DebugLog.v("DownloadCoordinator", "ensure($contentId): socket not alive -> skip"); return }
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)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): already inFlight -> skip"); return } // single-flight: already downloading
DebugLog.v("DownloadCoordinator", "ensure($contentId): dispatching download '$filename'")
try {
executor.execute { runDownload(contentId, filename) }
} 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
if (currentlyPlayingId != null) {
val newIndex = items.indexOfFirst { it.contentId == currentlyPlayingId }
if (newIndex >= 0) {
// Current item still exists - don't interrupt, just update index
if (newIndex >= 0 && hasContentOnScreen) {
// Current item still exists AND is genuinely on screen - don't interrupt, just update index.
currentIndex = newIndex
Log.i("PlaylistController", "Current item still in playlist at index $newIndex, not interrupting")
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
// schedule-active AND downloaded item. Distinguish the two idle reasons: daypart
@ -205,8 +208,13 @@ class PlaylistController(
onPlaylistEmpty()
return
}
if (isRunning && currentIndex >= 0 && currentIndex < items.size) {
// Already playing something valid - don't restart
// #162: isRunning + a valid index are NOT proof the player is actually rendering. After a
// 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")
return
}

View file

@ -55,6 +55,9 @@ class WebSocketService : Service() {
// #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.
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
@ -220,6 +223,14 @@ class WebSocketService : Service() {
config.deviceToken = data.optString("device_token", "")
}
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}") } }
startHeartbeat()
}
@ -235,17 +246,11 @@ class WebSocketService : Service() {
watchdogAttempt = 0
}
safeOn("device:unpaired") {
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:unpaired") { handleServerRejection("device:unpaired (removed on server)") }
safeOn("device:auth-error") { args ->
val msg = (args.firstOrNull() as? JSONObject)?.optString("error", "Authentication failed") ?: "Authentication failed"
Log.w("WebSocketService", "Device auth rejected: $msg — clearing credentials for re-pair")
config.clearDeviceCredentials()
handler.post { try { onUnpaired?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onUnpaired cb: ${e.message}") } }
handleServerRejection("auth-error: $msg")
}
safeOn("device:paired") { args ->
@ -253,6 +258,10 @@ class WebSocketService : Service() {
val id = data.optString("device_id", "")
val name = data.optString("name", "Display")
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
// Server-provisioned settings PIN — unique per device, stored encrypted.
// 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}") }
}
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 {
val data = JSONObject().apply {
if (config.isProvisioned && config.isPaired) {
@ -442,11 +458,17 @@ class WebSocketService : Service() {
put("device_token", token)
}
} 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)
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("fingerprint", deviceInfo.getFingerprint()) } catch (e: Throwable) { Log.w("WebSocketService", "fingerprint: ${e.message}") }
@ -463,6 +485,82 @@ class WebSocketService : Service() {
.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 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 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) {
if (!enabled) return
try { sink?.invoke(tag, level, msg) } catch (_: Throwable) {}

View file

@ -388,6 +388,66 @@
android:paddingBottom="4dp" />
</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>
<Button