From f60f677cf0679a7857ea55ecf94edddfbe972f78 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 10 Jul 2026 11:34:47 -0500 Subject: [PATCH] fix(android): player provisioning + playback robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/remotedisplay/player/MainActivity.kt | 22 ++- .../player/ProvisioningActivity.kt | 147 +++++++++++++++++- .../com/remotedisplay/player/SetupActivity.kt | 37 +++++ .../remotedisplay/player/data/ContentCache.kt | 4 +- .../player/data/DownloadCoordinator.kt | 10 +- .../player/player/PlaylistController.kt | 16 +- .../player/service/WebSocketService.kt | 122 +++++++++++++-- .../com/remotedisplay/player/util/DebugLog.kt | 8 + .../src/main/res/layout/activity_setup.xml | 60 +++++++ 9 files changed, 400 insertions(+), 26 deletions(-) diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 347fa73..bcd5202 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -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() } diff --git a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt index 3921c95..0cb1c5f 100644 --- a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt @@ -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 diff --git a/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt b/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt index d696f63..9ca59d4 100644 --- a/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt @@ -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