mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-15 06:43:27 -06:00
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:
parent
b72e964433
commit
f60f677cf0
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue